diff options
64 files changed, 2364 insertions, 580 deletions
@@ -81,7 +81,8 @@ export default { }, unseenNotificationsCount () { return this.unseenNotifications.length - } + }, + showFeaturesPanel () { return this.$store.state.instance.showFeaturesPanel } }, methods: { scrollToTop () { diff --git a/src/App.scss b/src/App.scss index dedc294d..52484f59 100644 --- a/src/App.scss +++ b/src/App.scss @@ -725,3 +725,17 @@ nav { margin-right: 0.8em; } } + +.login-hint { + text-align: center; + + @media all and (min-width: 801px) { + display: none; + } + + a { + display: inline-block; + padding: 1em 0px; + width: 100%; + } +} diff --git a/src/App.vue b/src/App.vue index 8a4e02c4..fa5736e5 100644 --- a/src/App.vue +++ b/src/App.vue @@ -30,7 +30,7 @@ <user-panel></user-panel> <nav-panel></nav-panel> <instance-specific-panel v-if="showInstanceSpecificPanel"></instance-specific-panel> - <features-panel v-if="!currentUser"></features-panel> + <features-panel v-if="!currentUser && showFeaturesPanel"></features-panel> <who-to-follow-panel v-if="currentUser && suggestionsEnabled"></who-to-follow-panel> <notifications v-if="currentUser"></notifications> </div> @@ -38,6 +38,11 @@ </div> </div> <div class="main"> + <div v-if="!currentUser" class="login-hint panel panel-default"> + <router-link :to="{ name: 'login' }" class="panel-body"> + {{ $t("login.hint") }} + </router-link> + </div> <transition name="fade"> <router-view></router-view> </transition> diff --git a/src/boot/after_store.js b/src/boot/after_store.js index 5693dcc6..53ecc083 100644 --- a/src/boot/after_store.js +++ b/src/boot/after_store.js @@ -55,10 +55,10 @@ const afterStoreSetup = ({ store, i18n }) => { } copyInstanceOption('nsfwCensorImage') - copyInstanceOption('theme') copyInstanceOption('background') copyInstanceOption('hidePostStats') copyInstanceOption('hideUserStats') + copyInstanceOption('hideFilteredStatuses') copyInstanceOption('logo') store.dispatch('setInstanceOption', { @@ -84,8 +84,10 @@ const afterStoreSetup = ({ store, i18n }) => { copyInstanceOption('loginMethod') copyInstanceOption('scopeCopy') copyInstanceOption('subjectLineBehavior') + copyInstanceOption('postContentType') copyInstanceOption('alwaysShowSubjectInput') copyInstanceOption('noAttachmentLinks') + copyInstanceOption('showFeaturesPanel') if ((config.chatDisabled)) { store.dispatch('disableChat') @@ -93,6 +95,9 @@ const afterStoreSetup = ({ store, i18n }) => { store.dispatch('initializeSocket') } + return store.dispatch('setTheme', config['theme']) + }) + .then(() => { const router = new VueRouter({ mode: 'history', routes: routes(store), diff --git a/src/components/about/about.js b/src/components/about/about.js index b4433b4e..ae1cb182 100644 --- a/src/components/about/about.js +++ b/src/components/about/about.js @@ -7,6 +7,9 @@ const About = { InstanceSpecificPanel, FeaturesPanel, TermsOfServicePanel + }, + computed: { + showFeaturesPanel () { return this.$store.state.instance.showFeaturesPanel } } } diff --git a/src/components/about/about.vue b/src/components/about/about.vue index bf87e0b8..13dec87c 100644 --- a/src/components/about/about.vue +++ b/src/components/about/about.vue @@ -1,7 +1,7 @@ <template> <div class="sidebar"> <instance-specific-panel></instance-specific-panel> - <features-panel></features-panel> + <features-panel v-if="showFeaturesPanel"></features-panel> <terms-of-service-panel></terms-of-service-panel> </div> </template> diff --git a/src/components/autocomplete_input/autocomplete_input.js b/src/components/autocomplete_input/autocomplete_input.js new file mode 100644 index 00000000..2a959fd1 --- /dev/null +++ b/src/components/autocomplete_input/autocomplete_input.js @@ -0,0 +1,150 @@ +import Completion from '../../services/completion/completion.js' +import { take, filter, map } from 'lodash' + +const AutoCompleteInput = { + props: [ + 'id', + 'classObj', + 'value', + 'placeholder', + 'autoResize', + 'multiline', + 'drop', + 'dragoverPrevent', + 'paste', + 'keydownMetaEnter', + 'keyupCtrlEnter' + ], + components: {}, + mounted () { + this.autoResize && this.resize(this.$refs.textarea) + const textLength = this.$refs.textarea.value.length + this.$refs.textarea.setSelectionRange(textLength, textLength) + }, + data () { + return { + caret: 0, + highlighted: 0, + text: this.value + } + }, + computed: { + users () { + return this.$store.state.users.users + }, + emoji () { + return this.$store.state.instance.emoji || [] + }, + customEmoji () { + return this.$store.state.instance.customEmoji || [] + }, + textAtCaret () { + return (this.wordAtCaret || {}).word || '' + }, + wordAtCaret () { + const word = Completion.wordAtPosition(this.text, this.caret - 1) || {} + return word + }, + candidates () { + const firstchar = this.textAtCaret.charAt(0) + if (firstchar === '@') { + const query = this.textAtCaret.slice(1).toUpperCase() + const matchedUsers = filter(this.users, (user) => { + return user.screen_name.toUpperCase().startsWith(query) || + user.name && user.name.toUpperCase().startsWith(query) + }) + if (matchedUsers.length <= 0) { + return false + } + // eslint-disable-next-line camelcase + return map(take(matchedUsers, 5), ({screen_name, name, profile_image_url_original}, index) => ({ + // eslint-disable-next-line camelcase + screen_name: `@${screen_name}`, + name: name, + img: profile_image_url_original, + highlighted: index === this.highlighted + })) + } else if (firstchar === ':') { + if (this.textAtCaret === ':') { return } + const matchedEmoji = filter(this.emoji.concat(this.customEmoji), (emoji) => emoji.shortcode.startsWith(this.textAtCaret.slice(1))) + if (matchedEmoji.length <= 0) { + return false + } + return map(take(matchedEmoji, 5), ({shortcode, image_url, utf}, index) => ({ + screen_name: `:${shortcode}:`, + name: '', + utf: utf || '', + // eslint-disable-next-line camelcase + img: utf ? '' : this.$store.state.instance.server + image_url, + highlighted: index === this.highlighted + })) + } else { + return false + } + } + }, + methods: { + setCaret ({target: {selectionStart}}) { + this.caret = selectionStart + }, + cycleBackward (e) { + const len = this.candidates.length || 0 + if (len > 0) { + e.preventDefault() + this.highlighted -= 1 + if (this.highlighted < 0) { + this.highlighted = this.candidates.length - 1 + } + } else { + this.highlighted = 0 + } + }, + cycleForward (e) { + const len = this.candidates.length || 0 + if (len > 0) { + if (e.shiftKey) { return } + e.preventDefault() + this.highlighted += 1 + if (this.highlighted >= len) { + this.highlighted = 0 + } + } else { + this.highlighted = 0 + } + }, + replace (replacement) { + this.text = Completion.replaceWord(this.text, this.wordAtCaret, replacement) + const el = this.$el.querySelector('textarea') + el.focus() + this.caret = 0 + }, + replaceCandidate (e) { + const len = this.candidates.length || 0 + if (this.textAtCaret === ':' || e.ctrlKey) { return } + if (len > 0) { + e.preventDefault() + const candidate = this.candidates[this.highlighted] + const replacement = candidate.utf || (candidate.screen_name + ' ') + this.text = Completion.replaceWord(this.text, this.wordAtCaret, replacement) + const el = this.$el.querySelector('textarea') || this.$el.querySelector('input') + el.focus() + this.caret = 0 + this.highlighted = 0 + } + }, + resize (e) { + const target = e.target || e + if (!(target instanceof window.Element)) { return } + const vertPadding = Number(window.getComputedStyle(target)['padding-top'].substr(0, 1)) + + Number(window.getComputedStyle(target)['padding-bottom'].substr(0, 1)) + // Auto is needed to make textbox shrink when removing lines + target.style.height = 'auto' + target.style.height = `${target.scrollHeight - vertPadding}px` + if (target.value === '') { + target.style.height = null + } + } + } +} + +export default AutoCompleteInput diff --git a/src/components/autocomplete_input/autocomplete_input.vue b/src/components/autocomplete_input/autocomplete_input.vue new file mode 100644 index 00000000..56233535 --- /dev/null +++ b/src/components/autocomplete_input/autocomplete_input.vue @@ -0,0 +1,104 @@ +<template> + <div style="display: flex; flex-direction: column;"> + <textarea + v-if="multiline" + ref="textarea" + rows="1" + :value="text" :class="classObj" :id="id" :placeholder="placeholder" + @input="text = $event.target.value, $emit('input', $event.target.value), autoResize && resize($event)" + @click="setCaret" + @keyup="setCaret" + @keydown.down="cycleForward" + @keydown.up="cycleBackward" + @keydown.shift.tab="cycleBackward" + @keydown.tab="cycleForward" + @keydown.enter="replaceCandidate" + @drop="drop && drop()" + @dragover.prevent="dragoverPrevent && dragoverPrevent()" + @paste="paste && paste()" + @keydown.meta.enter="keydownMetaEnter && keydownMetaEnter()" + @keyup.ctrl.enter="keyupCtrlEnter && keyupCtrlEnter()"> + </textarea> + <input + v-else + ref="textarea" + :value="text" :class="classObj" :id="id" :placeholder="placeholder" + @input="text = $event.target.value, $emit('input', $event.target.value), autoResize && resize($event)" + @click="setCaret" + @keyup="setCaret" + @keydown.down="cycleForward" + @keydown.up="cycleBackward" + @keydown.shift.tab="cycleBackward" + @keydown.tab="cycleForward" + @keydown.enter="replaceCandidate" + @drop="drop && drop()" + @dragover.prevent="dragoverPrevent && dragoverPrevent()" + @paste="paste && paste()" + @keydown.meta.enter="keydownMetaEnter && keydownMetaEnter()" + @keyup.ctrl.enter="keyupCtrlEnter && keyupCtrlEnter()"/> + <div style="position:relative;" v-if="candidates"> + <div class="autocomplete-panel"> + <div v-for="candidate in candidates" @click="replace(candidate.utf || (candidate.screen_name + ' '))"> + <div class="autocomplete" :class="{ highlighted: candidate.highlighted }"> + <span v-if="candidate.img"><img :src="candidate.img"></img></span> + <span v-else>{{candidate.utf}}</span> + <span>{{candidate.screen_name}}<small>{{candidate.name}}</small></span> + </div> + </div> + </div> + </div> + </div> +</template> + +<script src="./autocomplete_input.js"></script> + +<style lang="scss"> +@import '../../_variables.scss'; + +.autocomplete-panel { + margin: 0 0.5em 0 0.5em; + border-radius: $fallback--tooltipRadius; + border-radius: var(--tooltipRadius, $fallback--tooltipRadius); + position: absolute; + z-index: 1; + box-shadow: 1px 2px 4px rgba(0, 0, 0, 0.5); + // this doesn't match original but i don't care, making it uniform. + box-shadow: var(--popupShadow); + min-width: 75%; + background: $fallback--bg; + background: var(--bg, $fallback--bg); + color: $fallback--lightText; + color: var(--lightText, $fallback--lightText); +} + +.autocomplete { + cursor: pointer; + padding: 0.2em 0.4em 0.2em 0.4em; + border-bottom: 1px solid rgba(0, 0, 0, 0.4); + display: flex; + + img { + width: 24px; + height: 24px; + border-radius: $fallback--avatarRadius; + border-radius: var(--avatarRadius, $fallback--avatarRadius); + object-fit: contain; + } + + span { + line-height: 24px; + margin: 0 0.1em 0 0.2em; + } + + small { + margin-left: .5em; + color: $fallback--faint; + color: var(--faint, $fallback--faint); + } + + &.highlighted { + background-color: $fallback--fg; + background-color: var(--lightBg, $fallback--fg); + } +} +</style> diff --git a/src/components/follow_list/follow_list.js b/src/components/follow_list/follow_list.js new file mode 100644 index 00000000..acdb216d --- /dev/null +++ b/src/components/follow_list/follow_list.js @@ -0,0 +1,63 @@ +import UserCard from '../user_card/user_card.vue' + +const FollowList = { + data () { + return { + loading: false, + bottomedOut: false, + error: false + } + }, + props: ['userId', 'showFollowers'], + created () { + window.addEventListener('scroll', this.scrollLoad) + if (this.entries.length === 0) { + this.fetchEntries() + } + }, + destroyed () { + window.removeEventListener('scroll', this.scrollLoad) + this.$store.dispatch('clearFriendsAndFollowers', this.userId) + }, + computed: { + user () { + return this.$store.getters.userById(this.userId) + }, + entries () { + return this.showFollowers ? this.user.followers : this.user.friends + }, + showActions () { return this.$store.state.users.currentUser.id === this.userId } + }, + methods: { + fetchEntries () { + if (!this.loading) { + const command = this.showFollowers ? 'addFollowers' : 'addFriends' + this.loading = true + this.$store.dispatch(command, this.userId).then(entries => { + this.error = false + this.loading = false + this.bottomedOut = entries.length === 0 + }).catch(() => { + this.error = true + this.loading = false + }) + } + }, + scrollLoad (e) { + const bodyBRect = document.body.getBoundingClientRect() + const height = Math.max(bodyBRect.height, -(bodyBRect.y)) + if (this.loading === false && + this.bottomedOut === false && + this.$el.offsetHeight > 0 && + (window.innerHeight + window.pageYOffset) >= (height - 750) + ) { + this.fetchEntries() + } + } + }, + components: { + UserCard + } +} + +export default FollowList diff --git a/src/components/follow_list/follow_list.vue b/src/components/follow_list/follow_list.vue new file mode 100644 index 00000000..7be2e7b7 --- /dev/null +++ b/src/components/follow_list/follow_list.vue @@ -0,0 +1,34 @@ +<template> + <div class="follow-list"> + <user-card + v-for="entry in entries" + :key="entry.id" :user="entry" + :showFollows="!showFollowers" + :showActions="showActions" + /> + <div class="text-center panel-footer"> + <a v-if="error" @click="fetchEntries" class="alert error"> + {{$t('general.generic_error')}} + </a> + <i v-else-if="loading" class="icon-spin3 animate-spin"/> + <span v-else-if="bottomedOut"></span> + <a v-else @click="fetchEntries">{{$t('general.more')}}</a> + </div> + </div> +</template> + +<script src="./follow_list.js"></script> + +<style lang="scss"> + +.follow-list { + .panel-footer { + padding: 10px; + } + + .error { + font-size: 14px; + } +} + +</style> diff --git a/src/components/media_upload/media_upload.js b/src/components/media_upload/media_upload.js index 42d900d3..1c874faa 100644 --- a/src/components/media_upload/media_upload.js +++ b/src/components/media_upload/media_upload.js @@ -3,19 +3,10 @@ import statusPosterService from '../../services/status_poster/status_poster.serv import fileSizeFormatService from '../../services/file_size_format/file_size_format.js' const mediaUpload = { - mounted () { - const input = this.$el.querySelector('input') - - input.addEventListener('change', ({target}) => { - for (var i = 0; i < target.files.length; i++) { - let file = target.files[i] - this.uploadFile(file) - } - }) - }, data () { return { - uploading: false + uploading: false, + uploadReady: true } }, methods: { @@ -56,6 +47,18 @@ const mediaUpload = { } else { e.dataTransfer.dropEffect = 'none' } + }, + clearFile () { + this.uploadReady = false + this.$nextTick(() => { + this.uploadReady = true + }) + }, + change ({target}) { + for (var i = 0; i < target.files.length; i++) { + let file = target.files[i] + this.uploadFile(file) + } } }, props: [ diff --git a/src/components/media_upload/media_upload.vue b/src/components/media_upload/media_upload.vue index 768d3565..fcdc3471 100644 --- a/src/components/media_upload/media_upload.vue +++ b/src/components/media_upload/media_upload.vue @@ -3,7 +3,7 @@ <label class="btn btn-default" :title="$t('tool_tip.media_upload')"> <i class="icon-spin4 animate-spin" v-if="uploading"></i> <i class="icon-upload" v-if="!uploading"></i> - <input type="file" style="position: fixed; top: -100em" multiple="true"></input> + <input type="file" v-if="uploadReady" @change="change" style="position: fixed; top: -100em" multiple="true"></input> </label> </div> </template> diff --git a/src/components/notification/notification.js b/src/components/notification/notification.js index f95a329f..7d9807de 100644 --- a/src/components/notification/notification.js +++ b/src/components/notification/notification.js @@ -1,5 +1,5 @@ import Status from '../status/status.vue' -import StillImage from '../still-image/still-image.vue' +import UserAvatar from '../user_avatar/user_avatar.vue' import UserCardContent from '../user_card_content/user_card_content.vue' import { highlightClass, highlightStyle } from '../../services/user_highlighter/user_highlighter.js' import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator' @@ -13,7 +13,7 @@ const Notification = { }, props: [ 'notification' ], components: { - Status, StillImage, UserCardContent + Status, UserAvatar, UserCardContent }, methods: { toggleUserExpanded () { diff --git a/src/components/notification/notification.vue b/src/components/notification/notification.vue index f91c90cc..a0a55cba 100644 --- a/src/components/notification/notification.vue +++ b/src/components/notification/notification.vue @@ -2,7 +2,7 @@ <status v-if="notification.type === 'mention'" :compact="true" :statusoid="notification.status"></status> <div class="non-mention" :class="[userClass, { highlighted: userStyle }]" :style="[ userStyle ]"v-else> <a class='avatar-container' :href="notification.action.user.statusnet_profile_url" @click.stop.prevent.capture="toggleUserExpanded"> - <StillImage class='avatar-compact' :class="{'better-shadow': betterShadow}" :src="notification.action.user.profile_image_url_original"/> + <UserAvatar :compact="true" :betterShadow="betterShadow" :src="notification.action.user.profile_image_url_original"/> </a> <div class='notification-right'> <div class="usercard notification-usercard" v-if="userExpanded"> diff --git a/src/components/notifications/notifications.scss b/src/components/notifications/notifications.scss index 5c4ca1b9..bc81d45c 100644 --- a/src/components/notifications/notifications.scss +++ b/src/components/notifications/notifications.scss @@ -36,26 +36,7 @@ border-color: $fallback--border; border-color: var(--border, $fallback--border); - .avatar-compact { - width: 32px; - height: 32px; - box-shadow: var(--avatarStatusShadow); - border-radius: $fallback--avatarAltRadius; - border-radius: var(--avatarAltRadius, $fallback--avatarAltRadius); - overflow: hidden; - line-height: 0; - - &.better-shadow { - box-shadow: var(--avatarStatusShadowInset); - filter: var(--avatarStatusShadowFilter) - } - - &.animated::before { - display: none; - } - } - - &:hover .animated.avatar-compact { + &:hover .animated.avatar { canvas { display: none; } diff --git a/src/components/post_status_form/post_status_form.js b/src/components/post_status_form/post_status_form.js index 88bc736f..8e30264d 100644 --- a/src/components/post_status_form/post_status_form.js +++ b/src/components/post_status_form/post_status_form.js @@ -1,8 +1,8 @@ import statusPoster from '../../services/status_poster/status_poster.service.js' import MediaUpload from '../media_upload/media_upload.vue' +import AutoCompleteInput from '../autocomplete_input/autocomplete_input.vue' import fileTypeService from '../../services/file_type/file_type.service.js' -import Completion from '../../services/completion/completion.js' -import { take, filter, reject, map, uniqBy } from 'lodash' +import { reject, map, uniqBy } from 'lodash' const buildMentionsString = ({user, attentions}, currentUser) => { let allAttentions = [...attentions] @@ -28,13 +28,10 @@ const PostStatusForm = { 'subject' ], components: { - MediaUpload + MediaUpload, + AutoCompleteInput }, mounted () { - this.resize(this.$refs.textarea) - const textLength = this.$refs.textarea.value.length - this.$refs.textarea.setSelectionRange(textLength, textLength) - if (this.replyTo) { this.$refs.textarea.focus() } @@ -61,16 +58,13 @@ const PostStatusForm = { submitDisabled: false, error: null, posting: false, - highlighted: 0, newStatus: { spoilerText: this.subject || '', status: statusText, - contentType: 'text/plain', nsfw: false, files: [], visibility: scope - }, - caret: 0 + } } }, computed: { @@ -82,59 +76,6 @@ const PostStatusForm = { direct: { selected: this.newStatus.visibility === 'direct' } } }, - candidates () { - const firstchar = this.textAtCaret.charAt(0) - if (firstchar === '@') { - const query = this.textAtCaret.slice(1).toUpperCase() - const matchedUsers = filter(this.users, (user) => { - return user.screen_name.toUpperCase().startsWith(query) || - user.name && user.name.toUpperCase().startsWith(query) - }) - if (matchedUsers.length <= 0) { - return false - } - // eslint-disable-next-line camelcase - return map(take(matchedUsers, 5), ({screen_name, name, profile_image_url_original}, index) => ({ - // eslint-disable-next-line camelcase - screen_name: `@${screen_name}`, - name: name, - img: profile_image_url_original, - highlighted: index === this.highlighted - })) - } else if (firstchar === ':') { - if (this.textAtCaret === ':') { return } - const matchedEmoji = filter(this.emoji.concat(this.customEmoji), (emoji) => emoji.shortcode.startsWith(this.textAtCaret.slice(1))) - if (matchedEmoji.length <= 0) { - return false - } - return map(take(matchedEmoji, 5), ({shortcode, image_url, utf}, index) => ({ - screen_name: `:${shortcode}:`, - name: '', - utf: utf || '', - // eslint-disable-next-line camelcase - img: utf ? '' : this.$store.state.instance.server + image_url, - highlighted: index === this.highlighted - })) - } else { - return false - } - }, - textAtCaret () { - return (this.wordAtCaret || {}).word || '' - }, - wordAtCaret () { - const word = Completion.wordAtPosition(this.newStatus.status, this.caret - 1) || {} - return word - }, - users () { - return this.$store.state.users.users - }, - emoji () { - return this.$store.state.instance.emoji || [] - }, - customEmoji () { - return this.$store.state.instance.customEmoji || [] - }, statusLength () { return this.newStatus.status.length }, @@ -167,56 +108,16 @@ const PostStatusForm = { }, formattingOptionsEnabled () { return this.$store.state.instance.formattingOptionsEnabled + }, + defaultPostContentType () { + return typeof this.$store.state.config.postContentType === 'undefined' + ? this.$store.state.instance.postContentType + : this.$store.state.config.postContentType } }, methods: { - replace (replacement) { - this.newStatus.status = Completion.replaceWord(this.newStatus.status, this.wordAtCaret, replacement) - const el = this.$el.querySelector('textarea') - el.focus() - this.caret = 0 - }, - replaceCandidate (e) { - const len = this.candidates.length || 0 - if (this.textAtCaret === ':' || e.ctrlKey) { return } - if (len > 0) { - e.preventDefault() - const candidate = this.candidates[this.highlighted] - const replacement = candidate.utf || (candidate.screen_name + ' ') - this.newStatus.status = Completion.replaceWord(this.newStatus.status, this.wordAtCaret, replacement) - const el = this.$el.querySelector('textarea') - el.focus() - this.caret = 0 - this.highlighted = 0 - } - }, - cycleBackward (e) { - const len = this.candidates.length || 0 - if (len > 0) { - e.preventDefault() - this.highlighted -= 1 - if (this.highlighted < 0) { - this.highlighted = this.candidates.length - 1 - } - } else { - this.highlighted = 0 - } - }, - cycleForward (e) { - const len = this.candidates.length || 0 - if (len > 0) { - if (e.shiftKey) { return } - e.preventDefault() - this.highlighted += 1 - if (this.highlighted >= len) { - this.highlighted = 0 - } - } else { - this.highlighted = 0 - } - }, - setCaret ({target: {selectionStart}}) { - this.caret = selectionStart + postStatusCopy () { + this.postStatus(this.newStatus) }, postStatus (newStatus) { if (this.posting) { return } @@ -250,6 +151,7 @@ const PostStatusForm = { visibility: newStatus.visibility, contentType: newStatus.contentType } + this.$refs.mediaUpload.clearFile() this.$emit('posted') let el = this.$el.querySelector('textarea') el.style.height = 'auto' @@ -300,18 +202,6 @@ const PostStatusForm = { fileDrag (e) { e.dataTransfer.dropEffect = 'copy' }, - resize (e) { - const target = e.target || e - if (!(target instanceof window.Element)) { return } - const vertPadding = Number(window.getComputedStyle(target)['padding-top'].substr(0, 1)) + - Number(window.getComputedStyle(target)['padding-bottom'].substr(0, 1)) - // Auto is needed to make textbox shrink when removing lines - target.style.height = 'auto' - target.style.height = `${target.scrollHeight - vertPadding}px` - if (target.value === '') { - target.style.height = null - } - }, clearError () { this.error = null }, diff --git a/src/components/post_status_form/post_status_form.vue b/src/components/post_status_form/post_status_form.vue index 398f1871..ef3a7901 100644 --- a/src/components/post_status_form/post_status_form.vue +++ b/src/components/post_status_form/post_status_form.vue @@ -16,26 +16,20 @@ :placeholder="$t('post_status.content_warning')" v-model="newStatus.spoilerText" class="form-cw"> - <textarea - ref="textarea" - @click="setCaret" - @keyup="setCaret" v-model="newStatus.status" :placeholder="$t('post_status.default')" rows="1" class="form-control" - @keydown.down="cycleForward" - @keydown.up="cycleBackward" - @keydown.shift.tab="cycleBackward" - @keydown.tab="cycleForward" - @keydown.enter="replaceCandidate" - @keydown.meta.enter="postStatus(newStatus)" - @keyup.ctrl.enter="postStatus(newStatus)" - @drop="fileDrop" - @dragover.prevent="fileDrag" - @input="resize" - @paste="paste"> - </textarea> + <auto-complete-input v-model="newStatus.status" + :classObj="{ 'form-control': true }" + :placeholder="$t('post_status.default')" + :autoResize="true" + :multiline="true" + :drop="fileDrop" + :dragoverPrevent="fileDrag" + :paste="paste" + :keydownMetaEnter="postStatusCopy" + :keyupCtrlEnter="postStatusCopy"/> <div class="visibility-tray"> <span class="text-format" v-if="formattingOptionsEnabled"> <label for="post-content-type" class="select"> - <select id="post-content-type" v-model="newStatus.contentType" class="form-control"> + <select id="post-content-type" v-model="defaultPostContentType" class="form-control"> <option value="text/plain">{{$t('post_status.content_type.plain_text')}}</option> <option value="text/html">HTML</option> <option value="text/markdown">Markdown</option> @@ -52,19 +46,8 @@ </div> </div> </div> - <div style="position:relative;" v-if="candidates"> - <div class="autocomplete-panel"> - <div v-for="candidate in candidates" @click="replace(candidate.utf || (candidate.screen_name + ' '))"> - <div class="autocomplete" :class="{ highlighted: candidate.highlighted }"> - <span v-if="candidate.img"><img :src="candidate.img"></img></span> - <span v-else>{{candidate.utf}}</span> - <span>{{candidate.screen_name}}<small>{{candidate.name}}</small></span> - </div> - </div> - </div> - </div> <div class='form-bottom'> - <media-upload @uploading="disableSubmit" @uploaded="addMediaFile" @upload-failed="uploadFailed" :drop-files="dropFiles"></media-upload> + <media-upload ref="mediaUpload" @uploading="disableSubmit" @uploaded="addMediaFile" @upload-failed="uploadFailed" :drop-files="dropFiles"></media-upload> <p v-if="isOverLengthLimit" class="error">{{ charactersLeft }}</p> <p class="faint" v-else-if="hasStatusLengthLimit">{{ charactersLeft }}</p> @@ -250,52 +233,5 @@ cursor: pointer; z-index: 4; } - - .autocomplete-panel { - margin: 0 0.5em 0 0.5em; - border-radius: $fallback--tooltipRadius; - border-radius: var(--tooltipRadius, $fallback--tooltipRadius); - position: absolute; - z-index: 1; - box-shadow: 1px 2px 4px rgba(0, 0, 0, 0.5); - // this doesn't match original but i don't care, making it uniform. - box-shadow: var(--popupShadow); - min-width: 75%; - background: $fallback--bg; - background: var(--bg, $fallback--bg); - color: $fallback--lightText; - color: var(--lightText, $fallback--lightText); - } - - .autocomplete { - cursor: pointer; - padding: 0.2em 0.4em 0.2em 0.4em; - border-bottom: 1px solid rgba(0, 0, 0, 0.4); - display: flex; - - img { - width: 24px; - height: 24px; - border-radius: $fallback--avatarRadius; - border-radius: var(--avatarRadius, $fallback--avatarRadius); - object-fit: contain; - } - - span { - line-height: 24px; - margin: 0 0.1em 0 0.2em; - } - - small { - margin-left: .5em; - color: $fallback--faint; - color: var(--faint, $fallback--faint); - } - - &.highlighted { - background-color: $fallback--fg; - background-color: var(--lightBg, $fallback--fg); - } - } } </style> diff --git a/src/components/public_and_external_timeline/public_and_external_timeline.js b/src/components/public_and_external_timeline/public_and_external_timeline.js index 0db6efae..d45677e0 100644 --- a/src/components/public_and_external_timeline/public_and_external_timeline.js +++ b/src/components/public_and_external_timeline/public_and_external_timeline.js @@ -7,7 +7,7 @@ const PublicAndExternalTimeline = { timeline () { return this.$store.state.statuses.timelines.publicAndExternal } }, created () { - this.$store.dispatch('startFetching', 'publicAndExternal') + this.$store.dispatch('startFetching', { timeline: 'publicAndExternal' }) }, destroyed () { this.$store.dispatch('stopFetching', 'publicAndExternal') diff --git a/src/components/public_and_external_timeline/public_and_external_timeline.vue b/src/components/public_and_external_timeline/public_and_external_timeline.vue index aded2ead..6be9f955 100644 --- a/src/components/public_and_external_timeline/public_and_external_timeline.vue +++ b/src/components/public_and_external_timeline/public_and_external_timeline.vue @@ -1,5 +1,5 @@ <template> - <Timeline :title="$t('nav.twkn')"v-bind:timeline="timeline" v-bind:timeline-name="'publicAndExternal'"/> + <Timeline :title="$t('nav.twkn')" v-bind:timeline="timeline" v-bind:timeline-name="'publicAndExternal'"/> </template> <script src="./public_and_external_timeline.js"></script> diff --git a/src/components/public_timeline/public_timeline.js b/src/components/public_timeline/public_timeline.js index 9b866be8..64c951ac 100644 --- a/src/components/public_timeline/public_timeline.js +++ b/src/components/public_timeline/public_timeline.js @@ -7,7 +7,7 @@ const PublicTimeline = { timeline () { return this.$store.state.statuses.timelines.public } }, created () { - this.$store.dispatch('startFetching', 'public') + this.$store.dispatch('startFetching', { timeline: 'public' }) }, destroyed () { this.$store.dispatch('stopFetching', 'public') diff --git a/src/components/settings/settings.js b/src/components/settings/settings.js index 8d138485..534a9839 100644 --- a/src/components/settings/settings.js +++ b/src/components/settings/settings.js @@ -27,6 +27,11 @@ const settings = { : user.hideUserStats, hideUserStatsDefault: this.$t('settings.values.' + instance.hideUserStats), + hideFilteredStatusesLocal: typeof user.hideFilteredStatuses === 'undefined' + ? instance.hideFilteredStatuses + : user.hideFilteredStatuses, + hideFilteredStatusesDefault: this.$t('settings.values.' + instance.hideFilteredStatuses), + notificationVisibilityLocal: user.notificationVisibility, replyVisibilityLocal: user.replyVisibility, loopVideoLocal: user.loopVideo, @@ -46,6 +51,11 @@ const settings = { : user.subjectLineBehavior, subjectLineBehaviorDefault: instance.subjectLineBehavior, + postContentTypeLocal: typeof user.postContentType === 'undefined' + ? instance.postContentType + : user.postContentType, + postContentTypeDefault: instance.postContentType, + alwaysShowSubjectInputLocal: typeof user.alwaysShowSubjectInput === 'undefined' ? instance.alwaysShowSubjectInput : user.alwaysShowSubjectInput, @@ -96,6 +106,9 @@ const settings = { hideUserStatsLocal (value) { this.$store.dispatch('setOption', { name: 'hideUserStats', value }) }, + hideFilteredStatusesLocal (value) { + this.$store.dispatch('setOption', { name: 'hideFilteredStatuses', value }) + }, hideNsfwLocal (value) { this.$store.dispatch('setOption', { name: 'hideNsfw', value }) }, @@ -157,6 +170,9 @@ const settings = { subjectLineBehaviorLocal (value) { this.$store.dispatch('setOption', { name: 'subjectLineBehavior', value }) }, + postContentTypeLocal (value) { + this.$store.dispatch('setOption', { name: 'postContentType', value }) + }, stopGifs (value) { this.$store.dispatch('setOption', { name: 'stopGifs', value }) }, diff --git a/src/components/settings/settings.vue b/src/components/settings/settings.vue index 9953780f..dfb2e49d 100644 --- a/src/components/settings/settings.vue +++ b/src/components/settings/settings.vue @@ -100,6 +100,28 @@ </label> </div> </li> + <li> + <div> + {{$t('settings.post_status_content_type')}} + <label for="postContentType" class="select"> + <select id="postContentType" v-model="postContentTypeLocal"> + <option value="text/plain"> + {{$t('settings.status_content_type_plain')}} + {{postContentTypeDefault == 'text/plain' ? $t('settings.instance_default_simple') : ''}} + </option> + <option value="text/html"> + HTML + {{postContentTypeDefault == 'text/html' ? $t('settings.instance_default_simple') : ''}} + </option> + <option value="text/markdown"> + Markdown + {{postContentTypeDefault == 'text/markdown' ? $t('settings.instance_default_simple') : ''}} + </option> + </select> + <i class="icon-down-open"/> + </label> + </div> + </li> </ul> </div> @@ -205,7 +227,6 @@ </label> </li> </ul> - </label> </div> <div> {{$t('settings.replies_in_timeline')}} @@ -232,11 +253,18 @@ </div> </div> <div class="setting-item"> - <p>{{$t('settings.filtering_explanation')}}</p> - <textarea id="muteWords" v-model="muteWordsString"></textarea> + <div> + <p>{{$t('settings.filtering_explanation')}}</p> + <textarea id="muteWords" v-model="muteWordsString"></textarea> + </div> + <div> + <input type="checkbox" id="hideFilteredStatuses" v-model="hideFilteredStatusesLocal"> + <label for="hideFilteredStatuses"> + {{$t('settings.hide_filtered_statuses')}} {{$t('settings.instance_default', { value: hideFilteredStatusesDefault })}} + </label> + </div> </div> </div> - </tab-switcher> </keep-alive> </div> diff --git a/src/components/side_drawer/side_drawer.js b/src/components/side_drawer/side_drawer.js index 754a57e0..40ffa1dd 100644 --- a/src/components/side_drawer/side_drawer.js +++ b/src/components/side_drawer/side_drawer.js @@ -26,6 +26,12 @@ const SideDrawer = { }, suggestionsEnabled () { return this.$store.state.instance.suggestionsEnabled + }, + logo () { + return this.$store.state.instance.logo + }, + sitename () { + return this.$store.state.instance.name } }, methods: { diff --git a/src/components/side_drawer/side_drawer.vue b/src/components/side_drawer/side_drawer.vue index fc90977b..a6c6f237 100644 --- a/src/components/side_drawer/side_drawer.vue +++ b/src/components/side_drawer/side_drawer.vue @@ -8,8 +8,11 @@ @touchmove="touchMove" > <div class="side-drawer-heading" @click="toggleDrawer"> - <user-card-content :user="currentUser" :switcher="false" :hideBio="true" v-if="currentUser"> - </user-card-content> + <user-card-content :user="currentUser" :switcher="false" :hideBio="true" v-if="currentUser"/> + <div class="side-drawer-logo-wrapper" v-else> + <img :src="logo"/> + <span>{{sitename}}</span> + </div> </div> <ul> <li v-if="currentUser" @click="toggleDrawer"> @@ -141,6 +144,24 @@ background-color: var(--bg, $fallback--bg); } +.side-drawer-logo-wrapper { + display: flex; + align-items: center; + padding: 0.85em; + + img { + flex: none; + height: 50px; + margin-right: 0.85em; + } + + span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } +} + .side-drawer-click-outside-closed { flex: 0 0 0; } @@ -154,7 +175,6 @@ flex-direction: column; align-items: stretch; display: flex; - min-height: 7em; padding: 0; margin: 0; diff --git a/src/components/status/status.js b/src/components/status/status.js index c718fe9f..0273a5be 100644 --- a/src/components/status/status.js +++ b/src/components/status/status.js @@ -4,14 +4,14 @@ import RetweetButton from '../retweet_button/retweet_button.vue' import DeleteButton from '../delete_button/delete_button.vue' import PostStatusForm from '../post_status_form/post_status_form.vue' import UserCardContent from '../user_card_content/user_card_content.vue' -import StillImage from '../still-image/still-image.vue' +import UserAvatar from '../user_avatar/user_avatar.vue' import Gallery from '../gallery/gallery.vue' import LinkPreview from '../link-preview/link-preview.vue' import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator' import fileType from 'src/services/file_type/file_type.service' import { highlightClass, highlightStyle } from '../../services/user_highlighter/user_highlighter.js' -import { mentionMatchesUrl } from 'src/services/mention_matcher/mention_matcher.js' -import { filter, find } from 'lodash' +import { mentionMatchesUrl, extractTagFromUrl } from 'src/services/matcher/matcher.service.js' +import { filter, find, unescape } from 'lodash' const Status = { name: 'Status', @@ -36,6 +36,7 @@ const Status = { preview: null, showPreview: false, showingTall: this.inConversation && this.focused, + showingLongSubject: false, expandingSubject: typeof this.$store.state.config.collapseMessageWithSubject === 'undefined' ? !this.$store.state.instance.collapseMessageWithSubject : !this.$store.state.config.collapseMessageWithSubject, @@ -89,6 +90,7 @@ const Status = { retweet () { return !!this.statusoid.retweeted_status }, retweeter () { return this.statusoid.user.name || this.statusoid.user.screen_name }, retweeterHtml () { return this.statusoid.user.name_html }, + retweeterProfileLink () { return this.generateUserProfileLink(this.statusoid.user.id, this.statusoid.user.screen_name) }, status () { if (this.retweet) { return this.statusoid.retweeted_status @@ -108,6 +110,14 @@ const Status = { return hits }, muted () { return !this.unmuted && (this.status.user.muted || this.muteWordHits.length > 0) }, + hideFilteredStatuses () { + return typeof this.$store.state.config.hideFilteredStatuses === 'undefined' + ? this.$store.state.instance.hideFilteredStatuses + : this.$store.state.config.hideFilteredStatuses + }, + hideStatus () { + return (this.hideReply || this.deleted) || (this.muted && this.hideFilteredStatuses) + }, isFocused () { // retweet or root of an expanded conversation if (this.focused) { @@ -129,6 +139,9 @@ const Status = { const lengthScore = this.status.statusnet_html.split(/<p|<br/).length + this.status.text.length / 80 return lengthScore > 20 }, + longSubject () { + return this.status.summary.length > 900 + }, isReply () { return !!(this.status.in_reply_to_status_id && this.status.in_reply_to_user_id) }, @@ -196,14 +209,15 @@ const Status = { }, replySubject () { if (!this.status.summary) return '' + const decodedSummary = unescape(this.status.summary) const behavior = typeof this.$store.state.config.subjectLineBehavior === 'undefined' ? this.$store.state.instance.subjectLineBehavior : this.$store.state.config.subjectLineBehavior - const startsWithRe = this.status.summary.match(/^re[: ]/i) + const startsWithRe = decodedSummary.match(/^re[: ]/i) if (behavior !== 'noop' && startsWithRe || behavior === 'masto') { - return this.status.summary + return decodedSummary } else if (behavior === 'email') { - return 're: '.concat(this.status.summary) + return 're: '.concat(decodedSummary) } else if (behavior === 'noop') { return '' } @@ -244,7 +258,7 @@ const Status = { DeleteButton, PostStatusForm, UserCardContent, - StillImage, + UserAvatar, Gallery, LinkPreview }, @@ -268,7 +282,7 @@ const Status = { } if (target.tagName === 'A') { if (target.className.match(/mention/)) { - const href = target.getAttribute('href') + const href = target.href const attn = this.status.attentions.find(attn => mentionMatchesUrl(attn, href)) if (attn) { event.stopPropagation() @@ -278,6 +292,15 @@ const Status = { return } } + if (target.className.match(/hashtag/)) { + // Extract tag name from link url + const tag = extractTagFromUrl(target.href) + if (tag) { + const link = this.generateTagLink(tag) + this.$router.push(link) + return + } + } window.open(target.href, '_blank') } }, @@ -334,6 +357,9 @@ const Status = { generateUserProfileLink (id, name) { return generateProfileLink(id, name, this.$store.state.instance.restrictedNicknames) }, + generateTagLink (tag) { + return `/tag/${tag}` + }, setMedia () { const attachments = this.attachmentSize === 'hide' ? this.status.attachments : this.galleryAttachments return () => this.$store.dispatch('setMedia', attachments) diff --git a/src/components/status/status.vue b/src/components/status/status.vue index 3e3e82bf..aae365d1 100644 --- a/src/components/status/status.vue +++ b/src/components/status/status.vue @@ -1,5 +1,5 @@ <template> - <div class="status-el" v-if="!hideReply && !deleted" :class="[{ 'status-el_focused': isFocused }, { 'status-conversation': inlineExpanded }]"> + <div class="status-el" v-if="!hideStatus" :class="[{ 'status-el_focused': isFocused }, { 'status-conversation': inlineExpanded }]"> <template v-if="muted && !noReplyLinks"> <div class="media status container muted"> <small> @@ -13,10 +13,12 @@ </template> <template v-else> <div v-if="retweet && !noHeading" :class="[repeaterClass, { highlighted: repeaterStyle }]" :style="[repeaterStyle]" class="media container retweet-info"> - <StillImage v-if="retweet" class='avatar' :class='{ "better-shadow": betterShadow }' :src="statusoid.user.profile_image_url_original"/> + <UserAvatar v-if="retweet" :betterShadow="betterShadow" :src="statusoid.user.profile_image_url_original"/> <div class="media-body faint"> - <a v-if="retweeterHtml" :href="statusoid.user.statusnet_profile_url" class="user-name" :title="'@'+statusoid.user.screen_name" v-html="retweeterHtml"></a> - <a v-else :href="statusoid.user.statusnet_profile_url" class="user-name" :title="'@'+statusoid.user.screen_name">{{retweeter}}</a> + <span class="user-name"> + <router-link v-if="retweeterHtml" :to="retweeterProfileLink" v-html="retweeterHtml"/> + <router-link v-else :to="retweeterProfileLink">{{retweeter}}</router-link> + </span> <i class='fa icon-retweet retweeted' :title="$t('tool_tip.repeat')"></i> {{$t('timeline.repeated')}} </div> @@ -25,7 +27,7 @@ <div :class="[userClass, { highlighted: userStyle, 'is-retweet': retweet }]" :style="[ userStyle ]" class="media status"> <div v-if="!noHeading" class="media-left"> <router-link :to="userProfileLink" @click.stop.prevent.capture.native="toggleUserExpanded"> - <StillImage class='avatar' :class="{'avatar-compact': compact, 'better-shadow': betterShadow}" :src="status.user.profile_image_url_original"/> + <UserAvatar :compact="compact" :betterShadow="betterShadow" :src="status.user.profile_image_url_original"/> </router-link> </div> <div class="status-body"> @@ -54,7 +56,7 @@ </div> <h4 class="replies" v-if="inConversation && !noReplyLinks"> <small v-if="replies.length">Replies:</small> - <small class="reply-link" v-for="reply in replies"> + <small class="reply-link" v-bind:key="reply.id" v-for="reply in replies"> <a href="#" @click.prevent="gotoOriginal(reply.id)" @mouseenter="replyEnter(reply.id, $event)" @mouseout="replyLeave()">{{reply.name}} </a> </small> </h4> @@ -85,7 +87,12 @@ </div> </div> - <div :class="{'tall-status': hideTallStatus}" class="status-content-wrapper"> + <div class="status-content-wrapper" :class="{ 'tall-status': !showingLongSubject }" v-if="longSubject"> + <a class="tall-status-hider" :class="{ 'tall-status-hider_focused': isFocused }" v-if="!showingLongSubject" href="#" @click.prevent="showingLongSubject=true">Show more</a> + <div @click.prevent="linkClicked" class="status-content media-body" v-html="status.statusnet_html"></div> + <a v-if="showingLongSubject" href="#" class="status-unhider" @click.prevent="showingLongSubject=false">Show less</a> + </div> + <div :class="{'tall-status': hideTallStatus}" class="status-content-wrapper" v-else> <a class="tall-status-hider" :class="{ 'tall-status-hider_focused': isFocused }" v-if="hideTallStatus" href="#" @click.prevent="toggleShowMore">Show more</a> <div @click.prevent="linkClicked" class="status-content media-body" v-html="status.statusnet_html" v-if="!hideSubjectStatus"></div> <div @click.prevent="linkClicked" class="status-content media-body" v-html="status.summary_html" v-else></div> @@ -93,7 +100,7 @@ <a v-if="showingMore" href="#" class="status-unhider" @click.prevent="toggleShowMore">Show less</a> </div> - <div v-if="status.attachments && !hideSubjectStatus" class="attachments media-body"> + <div v-if="status.attachments && (!hideSubjectStatus || showingLongSubject)" class="attachments media-body"> <attachment class="non-gallery" v-for="attachment in nonGalleryAttachments" @@ -413,7 +420,7 @@ padding: 0.4em 0.6em 0 0.6em; margin: 0; - .avatar { + .avatar.still-image { border-radius: $fallback--avatarAltRadius; border-radius: var(--avatarAltRadius, $fallback--avatarAltRadius); margin-left: 28px; @@ -431,6 +438,8 @@ .user-name { font-weight: bold; + overflow: hidden; + text-overflow: ellipsis; img { width: 14px; @@ -497,46 +506,6 @@ color: var(--cBlue, $fallback--cBlue); } -.status .avatar-compact { - width: 32px; - height: 32px; - box-shadow: var(--avatarStatusShadow); - border-radius: $fallback--avatarAltRadius; - border-radius: var(--avatarAltRadius, $fallback--avatarAltRadius); - - &.better-shadow { - box-shadow: var(--avatarStatusShadowInset); - filter: var(--avatarStatusShadowFilter) - } -} - -.avatar.still-image { - width: 48px; - height: 48px; - box-shadow: var(--avatarStatusShadow); - border-radius: $fallback--avatarRadius; - border-radius: var(--avatarRadius, $fallback--avatarRadius); - overflow: hidden; - position: relative; - - &.better-shadow { - box-shadow: var(--avatarStatusShadowInset); - filter: var(--avatarStatusShadowFilter) - } - - img { - width: 100%; - height: 100%; - } - - &.animated::before { - display: none; - } - - &.retweeted { - } -} - .status:hover .animated.avatar { canvas { display: none; @@ -594,7 +563,7 @@ a.unmute { @media all and (max-width: 800px) { .status-el { .retweet-info { - .avatar { + .avatar.still-image { margin-left: 20px; } } @@ -603,14 +572,14 @@ a.unmute { max-width: 100%; } - .status .avatar { + .status .avatar.still-image { width: 40px; height: 40px; - } - .status .avatar-compact { - width: 32px; - height: 32px; + &.avatar-compact { + width: 32px; + height: 32px; + } } } diff --git a/src/components/still-image/still-image.js b/src/components/still-image/still-image.js index 5ad06dc2..02e98f19 100644 --- a/src/components/still-image/still-image.js +++ b/src/components/still-image/still-image.js @@ -2,7 +2,8 @@ const StillImage = { props: [ 'src', 'referrerpolicy', - 'mimetype' + 'mimetype', + 'imageLoadError' ], data () { return { @@ -23,6 +24,9 @@ const StillImage = { canvas.width = width canvas.height = height canvas.getContext('2d').drawImage(this.$refs.src, 0, 0, width, height) + }, + onError () { + this.imageLoadError && this.imageLoadError() } } } diff --git a/src/components/still-image/still-image.vue b/src/components/still-image/still-image.vue index 1dcb7ce6..af824fa2 100644 --- a/src/components/still-image/still-image.vue +++ b/src/components/still-image/still-image.vue @@ -1,7 +1,7 @@ <template> <div class='still-image' :class='{ animated: animated }' > <canvas ref="canvas" v-if="animated"></canvas> - <img ref="src" :src="src" :referrerpolicy="referrerpolicy" v-on:load="onLoad"/> + <img ref="src" :src="src" :referrerpolicy="referrerpolicy" v-on:load="onLoad" @error="onError"/> </div> </template> diff --git a/src/components/tab_switcher/tab_switcher.js b/src/components/tab_switcher/tab_switcher.js index f9c3f927..423df258 100644 --- a/src/components/tab_switcher/tab_switcher.js +++ b/src/components/tab_switcher/tab_switcher.js @@ -37,7 +37,7 @@ export default Vue.component('tab-switcher', { return ( <div class={ classesWrapper.join(' ')}> - <button onClick={this.activateTab(index)} class={ classesTab.join(' ') }>{slot.data.attrs.label}</button> + <button disabled={slot.data.attrs.disabled} onClick={this.activateTab(index)} class={ classesTab.join(' ') }>{slot.data.attrs.label}</button> </div> ) }) diff --git a/src/components/tag_timeline/tag_timeline.js b/src/components/tag_timeline/tag_timeline.js index 43de4f49..41b09706 100644 --- a/src/components/tag_timeline/tag_timeline.js +++ b/src/components/tag_timeline/tag_timeline.js @@ -3,7 +3,7 @@ import Timeline from '../timeline/timeline.vue' const TagTimeline = { created () { this.$store.commit('clearTimeline', { timeline: 'tag' }) - this.$store.dispatch('startFetching', { 'tag': this.tag }) + this.$store.dispatch('startFetching', { timeline: 'tag', tag: this.tag }) }, components: { Timeline @@ -15,7 +15,7 @@ const TagTimeline = { watch: { tag () { this.$store.commit('clearTimeline', { timeline: 'tag' }) - this.$store.dispatch('startFetching', { 'tag': this.tag }) + this.$store.dispatch('startFetching', { timeline: 'tag', tag: this.tag }) } }, destroyed () { diff --git a/src/components/user_avatar/user_avatar.js b/src/components/user_avatar/user_avatar.js new file mode 100644 index 00000000..e513b993 --- /dev/null +++ b/src/components/user_avatar/user_avatar.js @@ -0,0 +1,29 @@ +import StillImage from '../still-image/still-image.vue' + +const UserAvatar = { + props: [ + 'src', + 'betterShadow', + 'compact' + ], + data () { + return { + showPlaceholder: false + } + }, + components: { + StillImage + }, + computed: { + imgSrc () { + return this.showPlaceholder ? '/images/avi.png' : this.src + } + }, + methods: { + imageLoadError () { + this.showPlaceholder = true + } + } +} + +export default UserAvatar diff --git a/src/components/user_avatar/user_avatar.vue b/src/components/user_avatar/user_avatar.vue new file mode 100644 index 00000000..6bf7123d --- /dev/null +++ b/src/components/user_avatar/user_avatar.vue @@ -0,0 +1,42 @@ +<template> + <StillImage + class="avatar" + :class="{ 'avatar-compact': compact, 'better-shadow': betterShadow }" + :src="imgSrc" + :imageLoadError="imageLoadError" + /> +</template> + +<script src="./user_avatar.js"></script> +<style lang="scss"> +@import '../../_variables.scss'; + +.avatar.still-image { + width: 48px; + height: 48px; + box-shadow: var(--avatarStatusShadow); + border-radius: $fallback--avatarRadius; + border-radius: var(--avatarRadius, $fallback--avatarRadius); + + img { + width: 100%; + height: 100%; + } + + &.better-shadow { + box-shadow: var(--avatarStatusShadowInset); + filter: var(--avatarStatusShadowFilter) + } + + &.animated::before { + display: none; + } + + &.avatar-compact { + width: 32px; + height: 32px; + border-radius: $fallback--avatarAltRadius; + border-radius: var(--avatarAltRadius, $fallback--avatarAltRadius); + } +} +</style> diff --git a/src/components/user_card/user_card.js b/src/components/user_card/user_card.js index 615e6487..a4c84716 100644 --- a/src/components/user_card/user_card.js +++ b/src/components/user_card/user_card.js @@ -1,24 +1,33 @@ import UserCardContent from '../user_card_content/user_card_content.vue' -import StillImage from '../still-image/still-image.vue' +import UserAvatar from '../user_avatar/user_avatar.vue' import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator' +import { requestFollow, requestUnfollow } from '../../services/follow_manipulate/follow_manipulate' const UserCard = { props: [ 'user', 'showFollows', - 'showApproval' + 'showApproval', + 'showActions' ], data () { return { - userExpanded: false + userExpanded: false, + followRequestInProgress: false, + followRequestSent: false, + updated: false } }, components: { UserCardContent, - StillImage + UserAvatar }, computed: { - currentUser () { return this.$store.state.users.currentUser } + currentUser () { return this.$store.state.users.currentUser }, + following () { return this.updated ? this.updated.following : this.user.following }, + showFollow () { + return this.showActions && (!this.showFollows && !this.following || this.updated && !this.updated.following) + } }, methods: { toggleUserExpanded () { @@ -34,6 +43,21 @@ const UserCard = { }, userProfileLink (user) { return generateProfileLink(user.id, user.screen_name, this.$store.state.instance.restrictedNicknames) + }, + followUser () { + this.followRequestInProgress = true + requestFollow(this.user, this.$store).then(({ sent, updated }) => { + this.followRequestInProgress = false + this.followRequestSent = sent + this.updated = updated + }) + }, + unfollowUser () { + this.followRequestInProgress = true + requestUnfollow(this.user, this.$store).then(({ updated }) => { + this.followRequestInProgress = false + this.updated = updated + }) } } } diff --git a/src/components/user_card/user_card.vue b/src/components/user_card/user_card.vue index cf69606d..12960c02 100644 --- a/src/components/user_card/user_card.vue +++ b/src/components/user_card/user_card.vue @@ -1,28 +1,49 @@ <template> <div class="card"> - <a href="#"> - <StillImage @click.prevent="toggleUserExpanded" class="avatar" :src="user.profile_image_url"/> - </a> + <router-link :to="userProfileLink(user)"> + <UserAvatar class="avatar" :compact="true" @click.prevent.native="toggleUserExpanded" :src="user.profile_image_url"/> + </router-link> <div class="usercard" v-if="userExpanded"> <user-card-content :user="user" :switcher="false"></user-card-content> </div> <div class="name-and-screen-name" v-else> - <div :title="user.name" v-if="user.name_html" class="user-name"> - <span v-html="user.name_html"></span> + <div :title="user.name" class="user-name"> + <span v-if="user.name_html" v-html="user.name_html"></span> + <span v-else>{{ user.name }}</span> <span class="follows-you" v-if="!userExpanded && showFollows && user.follows_you"> {{ currentUser.id == user.id ? $t('user_card.its_you') : $t('user_card.follows_you') }} </span> </div> - <div :title="user.name" v-else class="user-name"> - {{ user.name }} - <span class="follows-you" v-if="!userExpanded && showFollows && user.follows_you"> - {{ currentUser.id == user.id ? $t('user_card.its_you') : $t('user_card.follows_you') }} - </span> + <div class="user-link-action"> + <router-link class='user-screen-name' :to="userProfileLink(user)"> + @{{user.screen_name}} + </router-link> + <button + v-if="showFollow" + class="btn btn-default" + @click="followUser" + :disabled="followRequestInProgress" + :title="followRequestSent ? $t('user_card.follow_again') : ''" + > + <template v-if="followRequestInProgress"> + {{ $t('user_card.follow_progress') }} + </template> + <template v-else-if="followRequestSent"> + {{ $t('user_card.follow_sent') }} + </template> + <template v-else> + {{ $t('user_card.follow') }} + </template> + </button> + <button v-if="showActions && showFollows && following" class="btn btn-default" @click="unfollowUser" :disabled="followRequestInProgress"> + <template v-if="followRequestInProgress"> + {{ $t('user_card.follow_progress') }} + </template> + <template v-else> + {{ $t('user_card.follow_unfollow') }} + </template> + </button> </div> - - <router-link class='user-screen-name' :to="userProfileLink(user)"> - @{{user.screen_name}} - </router-link> </div> <div class="approval" v-if="showApproval"> <button class="btn btn-default" @click="approveUser">{{ $t('user_card.approve') }}</button> @@ -42,6 +63,9 @@ text-align: left; width: 100%; .user-name { + display: flex; + justify-content: space-between; + img { object-fit: contain; height: 16px; @@ -49,11 +73,20 @@ vertical-align: middle; } } + + .user-link-action { + display: flex; + align-items: flex-start; + justify-content: space-between; + + button { + margin-top: 3px; + } + } } .follows-you { margin-left: 2em; - float: right; } .card { @@ -69,17 +102,13 @@ border-bottom-color: var(--border, $fallback--border); .avatar { - margin-top: 0.2em; - width:32px; - height: 32px; - border-radius: $fallback--avatarAltRadius; - border-radius: var(--avatarAltRadius, $fallback--avatarAltRadius); + padding: 0; } } .usercard { width: fill-available; - margin: 0.2em 0 0.7em 0; + margin: 0.2em 0 0 0.7em; border-radius: $fallback--panelRadius; border-radius: var(--panelRadius, $fallback--panelRadius); border-style: solid; diff --git a/src/components/user_card_content/user_card_content.js b/src/components/user_card_content/user_card_content.js index 541c73b4..7a7b89d4 100644 --- a/src/components/user_card_content/user_card_content.js +++ b/src/components/user_card_content/user_card_content.js @@ -1,5 +1,6 @@ -import StillImage from '../still-image/still-image.vue' +import UserAvatar from '../user_avatar/user_avatar.vue' import { hex2rgb } from '../../services/color_convert/color_convert.js' +import { requestFollow, requestUnfollow } from '../../services/follow_manipulate/follow_manipulate' import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator' export default { @@ -79,76 +80,30 @@ export default { set (color) { this.$store.dispatch('setHighlight', { user: this.user.screen_name, color }) } + }, + visibleRole () { + const validRole = (this.user.role === 'admin' || this.user.role === 'moderator') + const showRole = this.isOtherUser || this.user.show_role + + return validRole && showRole && this.user.role } }, components: { - StillImage + UserAvatar }, methods: { followUser () { - const store = this.$store this.followRequestInProgress = true - store.state.api.backendInteractor.followUser(this.user.id) - .then((followedUser) => store.commit('addNewUsers', [followedUser])) - .then(() => { - // For locked users we just mark it that we sent the follow request - if (this.user.locked) { - this.followRequestInProgress = false - this.followRequestSent = true - return - } - - if (this.user.following) { - // If we get result immediately, just stop. - this.followRequestInProgress = false - return - } - - // But usually we don't get result immediately, so we ask server - // for updated user profile to confirm if we are following them - // Sometimes it takes several tries. Sometimes we end up not following - // user anyway, probably because they locked themselves and we - // don't know that yet. - // Recursive Promise, it will call itself up to 3 times. - const fetchUser = (attempt) => new Promise((resolve, reject) => { - setTimeout(() => { - store.state.api.backendInteractor.fetchUser({ id: this.user.id }) - .then((user) => store.commit('addNewUsers', [user])) - .then(() => resolve([this.user.following, attempt])) - .catch((e) => reject(e)) - }, 500) - }).then(([following, attempt]) => { - if (!following && attempt <= 3) { - // If we BE reports that we still not following that user - retry, - // increment attempts by one - return fetchUser(++attempt) - } else { - // If we run out of attempts, just return whatever status is. - return following - } - }) - - return fetchUser(1) - .then((following) => { - if (following) { - // We confirmed and everything its good. - this.followRequestInProgress = false - } else { - // If after all the tries, just treat it as if user is locked - this.followRequestInProgress = false - this.followRequestSent = true - } - }) - }) + requestFollow(this.user, this.$store).then(({sent}) => { + this.followRequestInProgress = false + this.followRequestSent = sent + }) }, unfollowUser () { - const store = this.$store this.followRequestInProgress = true - store.state.api.backendInteractor.unfollowUser(this.user.id) - .then((unfollowedUser) => store.commit('addNewUsers', [unfollowedUser])) - .then(() => { - this.followRequestInProgress = false - }) + requestUnfollow(this.user, this.$store).then(() => { + this.followRequestInProgress = false + }) }, blockUser () { const store = this.$store diff --git a/src/components/user_card_content/user_card_content.vue b/src/components/user_card_content/user_card_content.vue index d1034d68..7f9909c4 100644 --- a/src/components/user_card_content/user_card_content.vue +++ b/src/components/user_card_content/user_card_content.vue @@ -4,7 +4,7 @@ <div class='user-info'> <div class='container'> <router-link :to="userProfileLink(user)"> - <StillImage class="avatar" :class='{ "better-shadow": betterShadow }' :src="user.profile_image_url_original"/> + <UserAvatar :betterShadow="betterShadow" :src="user.profile_image_url_original"/> </router-link> <div class="name-and-screen-name"> <div class="top-line"> @@ -19,7 +19,9 @@ </div> <router-link class='user-screen-name' :to="userProfileLink(user)"> - <span class="handle">@{{user.screen_name}}</span><span v-if="user.locked"><i class="icon icon-lock"></i></span> + <span class="handle">@{{user.screen_name}} + <span class="alert staff" v-if="!hideBio && !!visibleRole">{{visibleRole}}</span> + </span><span v-if="user.locked"><i class="icon icon-lock"></i></span> <span v-if="!hideUserStatsLocal && !hideBio" class="dailyAvg">{{dailyAvg}} {{ $t('user_card.per_day') }}</span> </router-link> </div> @@ -169,23 +171,12 @@ max-height: 56px; .avatar { - border-radius: $fallback--avatarRadius; - border-radius: var(--avatarRadius, $fallback--avatarRadius); flex: 1 0 100%; width: 56px; height: 56px; box-shadow: 0px 1px 8px rgba(0,0,0,0.75); box-shadow: var(--avatarShadow); object-fit: cover; - - &.better-shadow { - box-shadow: var(--avatarShadowInset); - filter: var(--avatarShadowFilter) - } - - &.animated::before { - display: none; - } } } @@ -258,6 +249,15 @@ text-overflow: ellipsis; overflow: hidden; } + + // TODO use proper colors + .staff { + text-transform: capitalize; + color: $fallback--text; + color: var(--btnText, $fallback--text); + background-color: $fallback--fg; + background-color: var(--btn, $fallback--fg); + } } .user-meta { diff --git a/src/components/user_profile/user_profile.js b/src/components/user_profile/user_profile.js index 27e138b0..a22b8722 100644 --- a/src/components/user_profile/user_profile.js +++ b/src/components/user_profile/user_profile.js @@ -1,23 +1,22 @@ import UserCardContent from '../user_card_content/user_card_content.vue' import UserCard from '../user_card/user_card.vue' import Timeline from '../timeline/timeline.vue' +import FollowList from '../follow_list/follow_list.vue' const UserProfile = { created () { this.$store.commit('clearTimeline', { timeline: 'user' }) this.$store.commit('clearTimeline', { timeline: 'favorites' }) this.$store.commit('clearTimeline', { timeline: 'media' }) - this.$store.dispatch('startFetching', ['user', this.fetchBy]) - this.$store.dispatch('startFetching', ['media', this.fetchBy]) + this.$store.dispatch('startFetching', { timeline: 'user', userId: this.fetchBy }) + this.$store.dispatch('startFetching', { timeline: 'media', userId: this.fetchBy }) this.startFetchFavorites() if (!this.user.id) { this.$store.dispatch('fetchUser', this.fetchBy) } }, destroyed () { - this.$store.dispatch('stopFetching', 'user') - this.$store.dispatch('stopFetching', 'favorites') - this.$store.dispatch('stopFetching', 'media') + this.cleanUp(this.userId) }, computed: { timeline () { @@ -39,12 +38,6 @@ const UserProfile = { return this.userId && this.$store.state.users.currentUser.id && this.userId === this.$store.state.users.currentUser.id }, - friends () { - return this.user.friends - }, - followers () { - return this.user.followers - }, userInStore () { if (this.isExternal) { return this.$store.getters.userById(this.userId) @@ -65,64 +58,56 @@ const UserProfile = { }, isExternal () { return this.$route.name === 'external-user-profile' + }, + followsTabVisible () { + return this.isUs || !this.user.hide_follows + }, + followersTabVisible () { + return this.isUs || !this.user.hide_followers } }, methods: { - fetchFollowers () { - const id = this.userId - this.$store.dispatch('addFollowers', { id }) - }, - fetchFriends () { - const id = this.userId - this.$store.dispatch('addFriends', { id }) - }, startFetchFavorites () { if (this.isUs) { - this.$store.dispatch('startFetching', ['favorites', this.fetchBy]) + this.$store.dispatch('startFetching', { timeline: 'favorites', userId: this.fetchBy }) } + }, + startUp () { + this.$store.dispatch('startFetching', { timeline: 'user', userId: this.fetchBy }) + this.$store.dispatch('startFetching', { timeline: 'media', userId: this.fetchBy }) + + this.startFetchFavorites() + }, + cleanUp () { + this.$store.dispatch('stopFetching', 'user') + this.$store.dispatch('stopFetching', 'favorites') + this.$store.dispatch('stopFetching', 'media') + this.$store.commit('clearTimeline', { timeline: 'user' }) + this.$store.commit('clearTimeline', { timeline: 'favorites' }) + this.$store.commit('clearTimeline', { timeline: 'media' }) } }, watch: { - // TODO get rid of this copypasta userName () { if (this.isExternal) { return } - this.$store.dispatch('stopFetching', 'user') - this.$store.dispatch('stopFetching', 'favorites') - this.$store.dispatch('stopFetching', 'media') - this.$store.commit('clearTimeline', { timeline: 'user' }) - this.$store.commit('clearTimeline', { timeline: 'favorites' }) - this.$store.commit('clearTimeline', { timeline: 'media' }) - this.$store.dispatch('startFetching', ['user', this.fetchBy]) - this.$store.dispatch('startFetching', ['media', this.fetchBy]) - this.startFetchFavorites() + this.cleanUp() + this.startUp() }, userId () { if (!this.isExternal) { return } - this.$store.dispatch('stopFetching', 'user') - this.$store.dispatch('stopFetching', 'favorites') - this.$store.dispatch('stopFetching', 'media') - this.$store.commit('clearTimeline', { timeline: 'user' }) - this.$store.commit('clearTimeline', { timeline: 'favorites' }) - this.$store.commit('clearTimeline', { timeline: 'media' }) - this.$store.dispatch('startFetching', ['user', this.fetchBy]) - this.$store.dispatch('startFetching', ['media', this.fetchBy]) - this.startFetchFavorites() - }, - user () { - if (this.user.id && !this.user.followers) { - this.fetchFollowers() - this.fetchFriends() - } + this.cleanUp() + this.startUp() } }, components: { UserCardContent, UserCard, - Timeline + Timeline, + FollowList } } diff --git a/src/components/user_profile/user_profile.vue b/src/components/user_profile/user_profile.vue index e53ce4cc..79461291 100644 --- a/src/components/user_profile/user_profile.vue +++ b/src/components/user_profile/user_profile.vue @@ -9,40 +9,28 @@ <tab-switcher :renderOnlyFocused="true"> <Timeline :label="$t('user_card.statuses')" + :disabled="!user.statuses_count" :embedded="true" :title="$t('user_profile.timeline_title')" :timeline="timeline" :timeline-name="'user'" :user-id="fetchBy" /> - <div :label="$t('user_card.followees')"> - <div v-if="friends"> - <user-card - v-for="friend in friends" - :key="friend.id" - :user="friend" - :showFollows="true" - /> - </div> + <div :label="$t('user_card.followees')" v-if="followsTabVisible" :disabled="!user.friends_count"> + <FollowList v-if="user.friends_count > 0" :userId="userId" :showFollowers="false" /> <div class="userlist-placeholder" v-else> <i class="icon-spin3 animate-spin"></i> </div> </div> - <div :label="$t('user_card.followers')"> - <div v-if="followers"> - <user-card - v-for="follower in followers" - :key="follower.id" - :user="follower" - :showFollows="false" - /> - </div> + <div :label="$t('user_card.followers')" v-if="followersTabVisible" :disabled="!user.followers_count"> + <FollowList v-if="user.followers_count > 0" :userId="userId" :showFollowers="true" /> <div class="userlist-placeholder" v-else> <i class="icon-spin3 animate-spin"></i> </div> </div> <Timeline :label="$t('user_card.media')" + :disabled="!media.visibleStatuses.length" :embedded="true" :title="$t('user_card.media')" timeline-name="media" :timeline="media" @@ -51,6 +39,7 @@ <Timeline v-if="isUs" :label="$t('user_card.favorites')" + :disabled="!favorites.visibleStatuses.length" :embedded="true" :title="$t('user_card.favorites')" timeline-name="favorites" diff --git a/src/components/user_settings/user_settings.js b/src/components/user_settings/user_settings.js index 9bd8aa00..fa389c3b 100644 --- a/src/components/user_settings/user_settings.js +++ b/src/components/user_settings/user_settings.js @@ -1,16 +1,22 @@ +import { unescape } from 'lodash' + import TabSwitcher from '../tab_switcher/tab_switcher.js' import StyleSwitcher from '../style_switcher/style_switcher.vue' +import AutoCompleteInput from '../autocomplete_input/autocomplete_input.vue' import fileSizeFormatService from '../../services/file_size_format/file_size_format.js' const UserSettings = { data () { return { newName: this.$store.state.users.currentUser.name, - newBio: this.$store.state.users.currentUser.description, + newBio: unescape(this.$store.state.users.currentUser.description), newLocked: this.$store.state.users.currentUser.locked, newNoRichText: this.$store.state.users.currentUser.no_rich_text, newDefaultScope: this.$store.state.users.currentUser.default_scope, - newHideNetwork: this.$store.state.users.currentUser.hide_network, + hideFollows: this.$store.state.users.currentUser.hide_follows, + hideFollowers: this.$store.state.users.currentUser.hide_followers, + showRole: this.$store.state.users.currentUser.show_role, + role: this.$store.state.users.currentUser.role, followList: null, followImportError: false, followsImported: false, @@ -36,7 +42,8 @@ const UserSettings = { }, components: { StyleSwitcher, - TabSwitcher + TabSwitcher, + AutoCompleteInput }, computed: { user () { @@ -66,7 +73,10 @@ const UserSettings = { /* eslint-disable camelcase */ const default_scope = this.newDefaultScope const no_rich_text = this.newNoRichText - const hide_network = this.newHideNetwork + const hide_follows = this.hideFollows + const hide_followers = this.hideFollowers + const show_role = this.showRole + /* eslint-enable camelcase */ this.$store.state.api.backendInteractor .updateProfile({ @@ -78,7 +88,9 @@ const UserSettings = { /* eslint-disable camelcase */ default_scope, no_rich_text, - hide_network + hide_follows, + hide_followers, + show_role /* eslint-enable camelcase */ }}).then((user) => { if (!user.error) { @@ -233,7 +245,9 @@ const UserSettings = { exportFollows () { this.enableFollowsExport = false this.$store.state.api.backendInteractor - .fetchFriends({id: this.$store.state.users.currentUser.id}) + .exportFriends({ + id: this.$store.state.users.currentUser.id + }) .then((friendList) => { this.exportPeople(friendList, 'friends.csv') setTimeout(() => { this.enableFollowsExport = true }, 2000) diff --git a/src/components/user_settings/user_settings.vue b/src/components/user_settings/user_settings.vue index be42cc4a..ad7c17bd 100644 --- a/src/components/user_settings/user_settings.vue +++ b/src/components/user_settings/user_settings.vue @@ -9,9 +9,9 @@ <div class="setting-item" > <h2>{{$t('settings.name_bio')}}</h2> <p>{{$t('settings.name')}}</p> - <input class='name-changer' id='username' v-model="newName"></input> + <auto-complete-input :classObj="{ 'name-changer': true }" :id="'username'" v-model="newName"/> <p>{{$t('settings.bio')}}</p> - <textarea class="bio" v-model="newBio"></textarea> + <auto-complete-input :classObj="{ bio: true }" v-model="newBio" :multiline="true"/> <p> <input type="checkbox" v-model="newLocked" id="account-locked"> <label for="account-locked">{{$t('settings.lock_account_description')}}</label> @@ -30,10 +30,19 @@ <label for="account-no-rich-text">{{$t('settings.no_rich_text_description')}}</label> </p> <p> - <input type="checkbox" v-model="newHideNetwork" id="account-hide-network"> - <label for="account-hide-network">{{$t('settings.hide_network_description')}}</label> + <input type="checkbox" v-model="hideFollows" id="account-hide-follows"> + <label for="account-hide-follows">{{$t('settings.hide_follows_description')}}</label> </p> - <button :disabled='newName.length <= 0' class="btn btn-default" @click="updateProfile">{{$t('general.submit')}}</button> + <p> + <input type="checkbox" v-model="hideFollowers" id="account-hide-followers"> + <label for="account-hide-followers">{{$t('settings.hide_followers_description')}}</label> + </p> + <p> + <input type="checkbox" v-model="showRole" id="account-show-role"> + <label for="account-show-role" v-if="role === 'admin'">{{$t('settings.show_admin_badge')}}</label> + <label for="account-show-role" v-if="role === 'moderator'">{{$t('settings.show_moderator_badge')}}</label> + </p> + <button :disabled='newName && newName.length === 0' class="btn btn-default" @click="updateProfile">{{$t('general.submit')}}</button> </div> <div class="setting-item"> <h2>{{$t('settings.avatar')}}</h2> @@ -169,7 +178,7 @@ } .banner { - max-width: 400px; + max-width: 100%; } .uploading { @@ -180,5 +189,9 @@ .name-changer { width: 100%; } + + .bg { + max-width: 100%; + } } </style> diff --git a/src/i18n/de.json b/src/i18n/de.json index c87371e6..d0bfba38 100644 --- a/src/i18n/de.json +++ b/src/i18n/de.json @@ -132,6 +132,7 @@ "preload_images": "Bilder vorausladen", "hide_post_stats": "Beitragsstatistiken verbergen (z.B. die Anzahl der Favoriten)", "hide_user_stats": "Benutzerstatistiken verbergen (z.B. die Anzahl der Follower)", + "hide_filtered_statuses": "Gefilterte Beiträge verbergen", "import_followers_from_a_csv_file": "Importiere Follower, denen du folgen möchtest, aus einer CSV-Datei", "import_theme": "Farbschema laden", "inputRadius": "Eingabefelder", @@ -155,7 +156,8 @@ "notification_visibility_mentions": "Erwähnungen", "notification_visibility_repeats": "Wiederholungen", "no_rich_text_description": "Rich-Text Formatierungen von allen Beiträgen entfernen", - "hide_network_description": "Zeige nicht, wem ich folge und wer mir folgt", + "hide_follows_description": "Zeige nicht, wem ich folge", + "hide_followers_description": "Zeige nicht, wer mir folgt", "nsfw_clickthrough": "Aktiviere ausblendbares Overlay für Anhänge, die als NSFW markiert sind", "panelRadius": "Panel", "pause_on_unfocused": "Streaming pausieren, wenn das Tab nicht fokussiert ist", diff --git a/src/i18n/en.json b/src/i18n/en.json index 29ac2b9a..c664fbfa 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -17,7 +17,9 @@ }, "general": { "apply": "Apply", - "submit": "Submit" + "submit": "Submit", + "more": "More", + "generic_error": "An error occured" }, "login": { "login": "Log in", @@ -26,7 +28,8 @@ "password": "Password", "placeholder": "e.g. lain", "register": "Register", - "username": "Username" + "username": "Username", + "hint": "Log in to join the discussion" }, "nav": { "about": "About", @@ -137,6 +140,7 @@ "use_one_click_nsfw": "Open NSFW attachments with just one click", "hide_post_stats": "Hide post statistics (e.g. the number of favorites)", "hide_user_stats": "Hide user statistics (e.g. the number of followers)", + "hide_filtered_statuses": "Hide filtered statuses", "import_followers_from_a_csv_file": "Import follows from a csv file", "import_theme": "Load preset", "inputRadius": "Input fields", @@ -162,7 +166,10 @@ "notification_visibility_mentions": "Mentions", "notification_visibility_repeats": "Repeats", "no_rich_text_description": "Strip rich text formatting from all posts", - "hide_network_description": "Don't show who I'm following and who's following me", + "hide_follows_description": "Don't show who I'm following", + "hide_followers_description": "Don't show who's following me", + "show_admin_badge": "Show Admin badge in my profile", + "show_moderator_badge": "Show Moderator badge in my profile", "nsfw_clickthrough": "Enable clickthrough NSFW attachment hiding", "panelRadius": "Panels", "pause_on_unfocused": "Pause streaming when tab is not focused", @@ -189,6 +196,8 @@ "subject_line_email": "Like email: \"re: subject\"", "subject_line_mastodon": "Like mastodon: copy as is", "subject_line_noop": "Do not copy", + "post_status_content_type": "Post status content type", + "status_content_type_plain": "Plain text", "stop_gifs": "Play-on-hover GIFs", "streaming": "Enable automatic streaming of new posts when scrolled to the top", "text": "Text", @@ -364,9 +373,9 @@ }, "upload":{ "error": { - "base": "Upload failed.", - "file_too_big": "File too big [{filesize}{filesizeunit} / {allowedsize}{allowedsizeunit}]", - "default": "Try again later" + "base": "Upload failed.", + "file_too_big": "File too big [{filesize}{filesizeunit} / {allowedsize}{allowedsizeunit}]", + "default": "Try again later" }, "file_size_units": { "B": "B", diff --git a/src/i18n/es.json b/src/i18n/es.json index 3391c6af..29c8aec4 100644 --- a/src/i18n/es.json +++ b/src/i18n/es.json @@ -2,99 +2,386 @@ "chat": { "title": "Chat" }, + "features_panel": { + "chat": "Chat", + "gopher": "Gopher", + "media_proxy": "Media proxy", + "scope_options": "Opciones del alcance de la visibilidad", + "text_limit": "Límite de carácteres", + "title": "Características", + "who_to_follow": "A quién seguir" + }, "finder": { "error_fetching_user": "Error al buscar usuario", "find_user": "Encontrar usuario" }, "general": { "apply": "Aplicar", - "submit": "Enviar" + "submit": "Enviar", + "more": "Más", + "generic_error": "Ha ocurrido un error" }, "login": { "login": "Identificación", + "description": "Identificación con OAuth", "logout": "Salir", "password": "Contraseña", "placeholder": "p.ej. lain", "register": "Registrar", - "username": "Usuario" + "username": "Usuario", + "hint": "Inicia sesión para unirte a la discusión" }, "nav": { + "about": "Sobre", + "back": "Volver", "chat": "Chat Local", + "friend_requests": "Solicitudes de amistad", "mentions": "Menciones", + "dms": "Mensajes Directo", "public_tl": "Línea Temporal Pública", "timeline": "Línea Temporal", - "twkn": "Toda La Red Conocida" + "twkn": "Toda La Red Conocida", + "user_search": "Búsqueda de Usuarios", + "who_to_follow": "A quién seguir", + "preferences": "Preferencias" }, "notifications": { + "broken_favorite": "Estado desconocido, buscándolo...", + "favorited_you": "le gusta tu estado", "followed_you": "empezó a seguirte", + "load_older": "Cargar notificaciones antiguas", "notifications": "Notificaciones", - "read": "¡Leído!" + "read": "¡Leído!", + "repeated_you": "repite tu estado", + "no_more_notifications": "No hay más notificaciones" }, "post_status": { + "new_status": "Publicar un nuevo estado", + "account_not_locked_warning": "Tu cuenta no está {0}. Cualquiera puede seguirte y leer las entradas para Solo-Seguidores.", + "account_not_locked_warning_link": "bloqueada", + "attachments_sensitive": "Contenido sensible", + "content_type": { + "plain_text": "Texto Plano" + }, + "content_warning": "Tema (opcional)", "default": "Acabo de aterrizar en L.A.", - "posting": "Publicando" + "direct_warning": "Esta entrada solo será visible para los usuarios mencionados.", + "posting": "Publicando", + "scope": { + "direct": "Directo - Solo para los usuarios mencionados.", + "private": "Solo-Seguidores - Solo tus seguidores leeran la entrada", + "public": "Público - Entradas visibles en las Líneas Temporales Públicas", + "unlisted": "Sin Listar - Entradas no visibles en las Líneas Temporales Públicas" + } }, "registration": { "bio": "Biografía", "email": "Correo electrónico", "fullname": "Nombre a mostrar", "password_confirm": "Confirmación de contraseña", - "registration": "Registro" + "registration": "Registro", + "token": "Token de invitación", + "captcha": "CAPTCHA", + "new_captcha": "Click en la imagen para obtener un nuevo captca", + "validations": { + "username_required": "no puede estar vacío", + "fullname_required": "no puede estar vacío", + "email_required": "no puede estar vacío", + "password_required": "no puede estar vacío", + "password_confirmation_required": "no puede estar vacío", + "password_confirmation_match": "la contraseña no coincide" + } }, "settings": { + "attachmentRadius": "Adjuntos", "attachments": "Adjuntos", "autoload": "Activar carga automática al llegar al final de la página", "avatar": "Avatar", - "background": "Segundo plano", + "avatarAltRadius": "Avatares (Notificaciones)", + "avatarRadius": "Avatares", + "background": "Fondo", "bio": "Biografía", + "btnRadius": "Botones", + "cBlue": "Azul (Responder, seguir)", + "cGreen": "Verde (Retweet)", + "cOrange": "Naranja (Favorito)", + "cRed": "Rojo (Cancelar)", + "change_password": "Cambiar contraseña", + "change_password_error": "Hubo un problema cambiando la contraseña.", + "changed_password": "Contraseña cambiada correctamente!", + "collapse_subject": "Colapsar entradas con tema", + "composing": "Redactando", + "confirm_new_password": "Confirmar la nueva contraseña", "current_avatar": "Tu avatar actual", - "current_profile_banner": "Cabecera actual", + "current_password": "Contraseña actual", + "current_profile_banner": "Tu cabecera actual", + "data_import_export_tab": "Importar / Exportar Datos", + "default_vis": "Alcance de visibilidad por defecto", + "delete_account": "Eliminar la cuenta", + "delete_account_description": "Eliminar para siempre la cuenta y todos los mensajes.", + "delete_account_error": "Hubo un error al eliminar tu cuenta. Si el fallo persiste, ponte en contacto con el administrador de tu instancia.", + "delete_account_instructions": "Escribe tu contraseña para confirmar la eliminación de tu cuenta.", + "avatar_size_instruction": "El tamaño mínimo recomendado para el avatar es de 150X150 píxeles.", + "export_theme": "Exportar tema", "filtering": "Filtros", "filtering_explanation": "Todos los estados que contengan estas palabras serán silenciados, una por línea", + "follow_export": "Exportar personas que tú sigues", + "follow_export_button": "Exporta tus seguidores a un archivo csv", + "follow_export_processing": "Procesando, en breve se te preguntará para guardar el archivo", "follow_import": "Importar personas que tú sigues", "follow_import_error": "Error al importal el archivo", "follows_imported": "¡Importado! Procesarlos llevará tiempo.", "foreground": "Primer plano", + "general": "General", "hide_attachments_in_convo": "Ocultar adjuntos en las conversaciones", "hide_attachments_in_tl": "Ocultar adjuntos en la línea temporal", - "import_followers_from_a_csv_file": "Importar personas que tú sigues apartir de un archivo csv", - "links": "Links", + "hide_isp": "Ocultar el panel específico de la instancia", + "preload_images": "Precargar las imágenes", + "use_one_click_nsfw": "Abrir los adjuntos NSFW con un solo click.", + "hide_post_stats": "Ocultar las estadísticas de las entradas (p.ej. el número de favoritos)", + "hide_user_stats": "Ocultar las estadísticas del usuario (p.ej. el número de seguidores)", + "import_followers_from_a_csv_file": "Importar personas que tú sigues a partir de un archivo csv", + "import_theme": "Importar tema", + "inputRadius": "Campos de entrada", + "checkboxRadius": "Casillas de verificación", + "instance_default": "(por defecto: {value})", + "instance_default_simple": "(por defecto)", + "interface": "Interfaz", + "interfaceLanguage": "Idioma", + "invalid_theme_imported": "El archivo importado no es un tema válido de Pleroma. No se han realizado cambios.", + "limited_availability": "No disponible en tu navegador", + "links": "Enlaces", + "lock_account_description": "Restringir el acceso a tu cuenta solo a seguidores admitidos", + "loop_video": "Vídeos en bucle", + "loop_video_silent_only": "Bucle solo en vídeos sin sonido (p.ej. \"gifs\" de Mastodon)", + "play_videos_in_modal": "Reproducir los vídeos directamente en el visor de medios", + "use_contain_fit": "No recortar los adjuntos en miniaturas", "name": "Nombre", "name_bio": "Nombre y Biografía", + "new_password": "Nueva contraseña", + "notification_visibility": "Tipos de notificaciones a mostrar", + "notification_visibility_follows": "Nuevos seguidores", + "notification_visibility_likes": "Me gustan (Likes)", + "notification_visibility_mentions": "Menciones", + "notification_visibility_repeats": "Repeticiones (Repeats)", + "no_rich_text_description": "Eliminar el formato de texto enriquecido de todas las entradas", + "hide_follows_description": "No mostrar a quién sigo", + "hide_followers_description": "No mostrar quién me sigue", + "show_admin_badge": "Mostrar la placa de administrador en mi perfil", + "show_moderator_badge": "Mostrar la placa de moderador en mi perfil", "nsfw_clickthrough": "Activar el clic para ocultar los adjuntos NSFW", + "panelRadius": "Paneles", + "pause_on_unfocused": "Parar la transmisión cuando no estés en foco.", "presets": "Por defecto", "profile_background": "Fondo del Perfil", - "profile_banner": "Cabecera del perfil", - "reply_link_preview": "Activar la previsualización del enlace de responder al pasar el ratón por encima", + "profile_banner": "Cabecera del Perfil", + "profile_tab": "Perfil", + "radii_help": "Estable el redondeo de las esquinas del interfaz (en píxeles)", + "replies_in_timeline": "Réplicas en la línea temporal", + "reply_link_preview": "Activar la previsualización del enlace de responder al pasar el ratón por encim", + "reply_visibility_all": "Mostrar todas las réplicas", + "reply_visibility_following": "Solo mostrar réplicas para mí o usuarios a los que sigo", + "reply_visibility_self": "Solo mostrar réplicas para mí", + "saving_err": "Error al guardar los ajustes", + "saving_ok": "Ajustes guardados", + "security_tab": "Seguridad", + "scope_copy": "Copiar la visibilidad cuando contestamos (En los mensajes directos (MDs) siempre se copia)", "set_new_avatar": "Cambiar avatar", "set_new_profile_background": "Cambiar fondo del perfil", - "set_new_profile_banner": "Cambiar cabecera", + "set_new_profile_banner": "Cambiar cabecera del perfil", "settings": "Ajustes", + "subject_input_always_show": "Mostrar siempre el campo del tema", + "subject_line_behavior": "Copiar el tema en las contestaciones", + "subject_line_email": "Tipo email: \"re: tema\"", + "subject_line_mastodon": "Tipo mastodon: copiar como es", + "subject_line_noop": "No copiar", + "post_status_content_type": "Formato de publicación", + "status_content_type_plain": "Texto plano", + "stop_gifs": "Iniciar GIFs al pasar el ratón", "streaming": "Habilite la transmisión automática de nuevas publicaciones cuando se desplaza hacia la parte superior", "text": "Texto", "theme": "Tema", "theme_help": "Use códigos de color hexadecimales (#rrggbb) para personalizar su tema de colores.", - "user_settings": "Ajustes de Usuario" + "theme_help_v2_1": "También puede invalidar los colores y la opacidad de ciertos componentes si activa la casilla de verificación, use el botón \"Borrar todo\" para deshacer los cambios.", + "theme_help_v2_2": "Los iconos debajo de algunas entradas son indicadores de contraste de fondo/texto, desplace el ratón para obtener información detallada. Tenga en cuenta que cuando se utilizan indicadores de contraste de transparencia se muestra el peor caso posible.", + "tooltipRadius": "Información/alertas", + "user_settings": "Ajustes de Usuario", + "values": { + "false": "no", + "true": "sí" + }, + "notifications": "Notificaciones", + "enable_web_push_notifications": "Habilitar las notificiaciones en el navegador", + "style": { + "switcher": { + "keep_color": "Mantener colores", + "keep_shadows": "Mantener sombras", + "keep_opacity": "Mantener opacidad", + "keep_roundness": "Mantener redondeces", + "keep_fonts": "Mantener fuentes", + "save_load_hint": "Las opciones \"Mantener\" conservan las opciones configuradas actualmente al seleccionar o cargar temas, también almacena dichas opciones al exportar un tema. Cuando se desactiven todas las casillas de verificación, el tema de exportación lo guardará todo.", + "reset": "Reiniciar", + "clear_all": "Limpiar todo", + "clear_opacity": "Limpiar opacidad" + }, + "common": { + "color": "Color", + "opacity": "Opacidad", + "contrast": { + "hint": "El ratio de contraste es {ratio}. {level} {context}", + "level": { + "aa": "Cumple con la pauta de nivel AA (mínimo)", + "aaa": "Cumple con la pauta de nivel AAA (recomendado)", + "bad": "No cumple con las pautas de accesibilidad" + }, + "context": { + "18pt": "para textos grandes (+18pt)", + "text": "para textos" + } + } + }, + "common_colors": { + "_tab_label": "Común", + "main": "Colores comunes", + "foreground_hint": "Vea la pestaña \"Avanzado\" para un control más detallado", + "rgbo": "Iconos, acentos, insignias" + }, + "advanced_colors": { + "_tab_label": "Avanzado", + "alert": "Fondo de Alertas", + "alert_error": "Error", + "badge": "Fondo de Insignias", + "badge_notification": "Notificaciones", + "panel_header": "Cabecera del panel", + "top_bar": "Barra superior", + "borders": "Bordes", + "buttons": "Botones", + "inputs": "Campos de entrada", + "faint_text": "Texto desvanecido" + }, + "radii": { + "_tab_label": "Redondez" + }, + "shadows": { + "_tab_label": "Sombra e iluminación", + "component": "Componente", + "override": "Sobreescribir", + "shadow_id": "Sombra #{value}", + "blur": "Difuminar", + "spread": "Cantidad", + "inset": "Insertada", + "hint": "Para las sombras, también puede usar --variable como un valor de color para usar las variables CSS3. Tenga en cuenta que establecer la opacidad no funcionará en este caso.", + "filter_hint": { + "always_drop_shadow": "Advertencia, esta sombra siempre usa {0} cuando el navegador lo soporta.", + "drop_shadow_syntax": "{0} no soporta el parámetro {1} y la palabra clave {2}.", + "avatar_inset": "Tenga en cuenta que la combinación de sombras insertadas como no-insertadas en los avatares, puede dar resultados inesperados con los avatares transparentes.", + "spread_zero": "Sombras con una cantidad > 0 aparecerá como si estuviera puesto a cero", + "inset_classic": "Las sombras insertadas estarán usando {0}" + }, + "components": { + "panel": "Panel", + "panelHeader": "Cabecera del panel", + "topBar": "Barra superior", + "avatar": "Avatar del usuario (en la vista del perfil)", + "avatarStatus": "Avatar del usuario (en la vista de la entrada)", + "popup": "Ventanas y textos emergentes (popups & tooltips)", + "button": "Botones", + "buttonHover": "Botón (encima)", + "buttonPressed": "Botón (presionado)", + "buttonPressedHover": "Botón (presionado+encima)", + "input": "Campo de entrada" + } + }, + "fonts": { + "_tab_label": "Fuentes", + "help": "Seleccione la fuente para utilizar para los elementos de la interfaz de usuario. Para \"personalizado\", debe ingresar el nombre exacto de la fuente tal como aparece en el sistema.", + "components": { + "interface": "Interfaz", + "input": "Campos de entrada", + "post": "Texto de publicaciones", + "postCode": "Texto monoespaciado en publicación (texto enriquecido)" + }, + "family": "Nombre de la fuente", + "size": "Tamaño (en px)", + "weight": "Peso (negrita)", + "custom": "Personalizado" + }, + "preview": { + "header": "Vista previa", + "content": "Contenido", + "error": "Ejemplo de error", + "button": "Botón", + "text": "Un montón de {0} y {1}", + "mono": "contenido", + "input": "Acaba de aterrizar en L.A.", + "faint_link": "manual útil", + "fine_print": "¡Lea nuestro {0} para aprender nada útil!", + "header_faint": "Esto está bien", + "checkbox": "He revisado los términos y condiciones", + "link": "un bonito enlace" + } + } }, "timeline": { + "collapse": "Colapsar", "conversation": "Conversación", "error_fetching": "Error al cargar las actualizaciones", "load_older": "Cargar actualizaciones anteriores", + "no_retweet_hint": "La publicación está marcada como solo para seguidores o directa y no se puede repetir", + "repeated": "repetida", "show_new": "Mostrar lo nuevo", - "up_to_date": "Actualizado" + "up_to_date": "Actualizado", + "no_more_statuses": "No hay más estados" }, "user_card": { + "approve": "Aprovar", "block": "Bloquear", "blocked": "¡Bloqueado!", + "deny": "Denegar", + "favorites": "Favoritos", "follow": "Seguir", + "follow_sent": "¡Solicitud enviada!", + "follow_progress": "Solicitando…", + "follow_again": "¿Enviar solicitud de nuevo?", + "follow_unfollow": "Dejar de seguir", "followees": "Siguiendo", "followers": "Seguidores", "following": "¡Siguiendo!", "follows_you": "¡Te sigue!", + "its_you": "¡Eres tú!", + "media": "Media", "mute": "Silenciar", "muted": "Silenciado", "per_day": "por día", "remote_follow": "Seguir", "statuses": "Estados" + }, + "user_profile": { + "timeline_title": "Linea temporal del usuario" + }, + "who_to_follow": { + "more": "Más", + "who_to_follow": "A quién seguir" + }, + "tool_tip": { + "media_upload": "Subir Medios", + "repeat": "Repetir", + "reply": "Contestar", + "favorite": "Favorito", + "user_settings": "Ajustes de usuario" + }, + "upload":{ + "error": { + "base": "Subida fallida.", + "file_too_big": "Archivo demasiado grande [{filesize}{filesizeunit} / {allowedsize}{allowedsizeunit}]", + "default": "Inténtalo más tarde" + }, + "file_size_units": { + "B": "B", + "KiB": "KiB", + "MiB": "MiB", + "GiB": "GiB", + "TiB": "TiB" + } } } diff --git a/src/i18n/fi.json b/src/i18n/fi.json index 0d62f295..5a0c1ea8 100644 --- a/src/i18n/fi.json +++ b/src/i18n/fi.json @@ -17,7 +17,9 @@ }, "general": { "apply": "Aseta", - "submit": "Lähetä" + "submit": "Lähetä", + "more": "Lisää", + "generic_error": "Virhe tapahtui" }, "login": { "login": "Kirjaudu sisään", diff --git a/src/i18n/ja.json b/src/i18n/ja.json index 161856f0..afce03a4 100644 --- a/src/i18n/ja.json +++ b/src/i18n/ja.json @@ -157,7 +157,8 @@ "notification_visibility_mentions": "メンション", "notification_visibility_repeats": "リピート", "no_rich_text_description": "リッチテキストをつかわない", - "hide_network_description": "わたしがフォローしているひとと、わたしをフォローしているひとを、みせない", + "hide_follows_description": "フォローしている人を表示しない", + "hide_followers_description": "フォローしている人を表示しない", "nsfw_clickthrough": "NSFWなファイルをかくす", "panelRadius": "パネル", "pause_on_unfocused": "タブにフォーカスがないときストリーミングをとめる", diff --git a/src/i18n/ko.json b/src/i18n/ko.json index 4b69df07..f9e4dfa3 100644 --- a/src/i18n/ko.json +++ b/src/i18n/ko.json @@ -156,7 +156,8 @@ "notification_visibility_mentions": "멘션", "notification_visibility_repeats": "반복", "no_rich_text_description": "모든 게시물의 서식을 지우기", - "hide_network_description": "내 팔로우와 팔로워를 숨기기", + "hide_follows_description": "내가 팔로우하는 사람을 표시하지 않음", + "hide_followers_description": "나를 따르는 사람을 보여주지 마라.", "nsfw_clickthrough": "NSFW 이미지 \"클릭해서 보이기\"를 활성화", "panelRadius": "패널", "pause_on_unfocused": "탭이 활성 상태가 아닐 때 스트리밍 멈추기", diff --git a/src/i18n/ru.json b/src/i18n/ru.json index 0887bb59..4b0bd4b4 100644 --- a/src/i18n/ru.json +++ b/src/i18n/ru.json @@ -127,7 +127,10 @@ "notification_visibility_mentions": "Упоминания", "notification_visibility_repeats": "Повторы", "no_rich_text_description": "Убрать форматирование из всех постов", - "hide_network_description": "Не показывать кого я читаю и кто меня читает", + "hide_follows_description": "Не показывать кого я читаю", + "hide_followers_description": "Не показывать кто читает меня", + "show_admin_badge": "Показывать значок администратора в моем профиле", + "show_moderator_badge": "Показывать значок модератора в моем профиле", "nsfw_clickthrough": "Включить скрытие NSFW вложений", "panelRadius": "Панели", "pause_on_unfocused": "Приостановить загрузку когда вкладка не в фокусе", diff --git a/src/lib/persisted_state.js b/src/lib/persisted_state.js index ccd92633..6f7202ce 100644 --- a/src/lib/persisted_state.js +++ b/src/lib/persisted_state.js @@ -48,7 +48,7 @@ export default function createPersistedState ({ return getState(key, storage).then((savedState) => { return store => { try { - if (typeof savedState === 'object') { + if (savedState !== null && typeof savedState === 'object') { // build user cache const usersState = savedState.users || {} usersState.usersObject = {} diff --git a/src/modules/api.js b/src/modules/api.js index 7bda13e7..31cb55c6 100644 --- a/src/modules/api.js +++ b/src/modules/api.js @@ -1,5 +1,4 @@ import backendInteractorService from '../services/backend_interactor_service/backend_interactor_service.js' -import {isArray} from 'lodash' import { Socket } from 'phoenix' const api = { @@ -34,20 +33,12 @@ const api = { } }, actions: { - startFetching (store, timeline) { - let userId = false - - // This is for user timelines - if (isArray(timeline)) { - userId = timeline[1] - timeline = timeline[0] - } - + startFetching (store, {timeline = 'friends', tag = false, userId = false}) { // Don't start fetching if we already are. - if (!store.state.fetchers[timeline]) { - const fetcher = store.state.backendInteractor.startFetching({timeline, store, userId}) - store.commit('addFetcher', {timeline, fetcher}) - } + if (store.state.fetchers[timeline]) return + + const fetcher = store.state.backendInteractor.startFetching({ timeline, store, userId, tag }) + store.commit('addFetcher', { timeline, fetcher }) }, stopFetching (store, timeline) { const fetcher = store.state.fetchers[timeline] diff --git a/src/modules/config.js b/src/modules/config.js index c9528f6f..71f71376 100644 --- a/src/modules/config.js +++ b/src/modules/config.js @@ -30,7 +30,8 @@ const defaultState = { interfaceLanguage: browserLocale, scopeCopy: undefined, // instance default subjectLineBehavior: undefined, // instance default - alwaysShowSubjectInput: undefined // instance default + alwaysShowSubjectInput: undefined, // instance default + postContentType: undefined // instance default } const config = { diff --git a/src/modules/instance.js b/src/modules/instance.js index 4ad41873..59c6b91c 100644 --- a/src/modules/instance.js +++ b/src/modules/instance.js @@ -21,13 +21,16 @@ const defaultState = { collapseMessageWithSubject: false, hidePostStats: false, hideUserStats: false, + hideFilteredStatuses: true, disableChat: false, scopeCopy: true, subjectLineBehavior: 'email', + postContentType: 'text/plain', loginMethod: 'password', nsfwCensorImage: undefined, vapidPublicKey: undefined, noAttachmentLinks: false, + showFeaturesPanel: true, // Nasty stuff pleromaBackend: true, @@ -63,9 +66,11 @@ const instance = { case 'name': dispatch('setPageTitle') break - case 'theme': - setPreset(value, commit) } + }, + setTheme ({ commit }, themeName) { + commit('setInstanceOption', { name: 'theme', value: themeName }) + return setPreset(themeName, commit) } } } diff --git a/src/modules/users.js b/src/modules/users.js index d6ab47ea..4d56ec6f 100644 --- a/src/modules/users.js +++ b/src/modules/users.js @@ -1,5 +1,5 @@ import backendInteractorService from '../services/backend_interactor_service/backend_interactor_service.js' -import { compact, map, each, merge } from 'lodash' +import { compact, map, each, merge, find } from 'lodash' import { set } from 'vue' import { registerPushNotifications, unregisterPushNotifications } from '../services/push/push.js' import oauthApi from '../services/new_api/oauth' @@ -52,13 +52,35 @@ export const mutations = { state.loggingIn = false }, // TODO Clean after ourselves? - addFriends (state, { id, friends }) { + addFriends (state, { id, friends, page }) { const user = state.usersObject[id] - user.friends = friends + each(friends, friend => { + if (!find(user.friends, { id: friend.id })) { + user.friends.push(friend) + } + }) + user.friendsPage = page + 1 }, - addFollowers (state, { id, followers }) { + addFollowers (state, { id, followers, page }) { const user = state.usersObject[id] - user.followers = followers + each(followers, follower => { + if (!find(user.followers, { id: follower.id })) { + user.followers.push(follower) + } + }) + user.followersPage = page + 1 + }, + // Because frontend doesn't have a reason to keep these stuff in memory + // outside of viewing someones user profile. + clearFriendsAndFollowers (state, userKey) { + const user = state.usersObject[userKey] + if (!user) { + return + } + user.friends = [] + user.followers = [] + user.friendsPage = 0 + user.followersPage = 0 }, addNewUsers (state, users) { each(users, (user) => mergeOrAdd(state.users, state.usersObject, user)) @@ -115,13 +137,34 @@ const users = { store.rootState.api.backendInteractor.fetchUser({ id }) .then((user) => store.commit('addNewUsers', [user])) }, - addFriends ({ rootState, commit }, { id }) { - rootState.api.backendInteractor.fetchFriends({ id }) - .then((friends) => commit('addFriends', { id, friends })) + addFriends ({ rootState, commit }, fetchBy) { + return new Promise((resolve, reject) => { + const user = rootState.users.usersObject[fetchBy] + const page = user.friendsPage || 1 + rootState.api.backendInteractor.fetchFriends({ id: user.id, page }) + .then((friends) => { + commit('addFriends', { id: user.id, friends, page }) + resolve(friends) + }).catch(() => { + reject() + }) + }) + }, + addFollowers ({ rootState, commit }, fetchBy) { + return new Promise((resolve, reject) => { + const user = rootState.users.usersObject[fetchBy] + const page = user.followersPage || 1 + rootState.api.backendInteractor.fetchFollowers({ id: user.id, page }) + .then((followers) => { + commit('addFollowers', { id: user.id, followers, page }) + resolve(followers) + }).catch(() => { + reject() + }) + }) }, - addFollowers ({ rootState, commit }, { id }) { - rootState.api.backendInteractor.fetchFollowers({ id }) - .then((followers) => commit('addFollowers', { id, followers })) + clearFriendsAndFollowers ({ commit }, userKey) { + commit('clearFriendsAndFollowers', userKey) }, registerPushNotifications (store) { const token = store.state.currentUser.credentials @@ -228,7 +271,7 @@ const users = { } // Start getting fresh posts. - store.dispatch('startFetching', 'friends') + store.dispatch('startFetching', { timeline: 'friends' }) // Get user mutes and follower info store.rootState.api.backendInteractor.fetchMutes().then((mutedUsers) => { diff --git a/src/services/api/api.service.js b/src/services/api/api.service.js index 5b0d8650..92daa04e 100644 --- a/src/services/api/api.service.js +++ b/src/services/api/api.service.js @@ -130,7 +130,7 @@ const updateBanner = ({credentials, params}) => { // description const updateProfile = ({credentials, params}) => { // Always include these fields, because they might be empty or false - const fields = ['description', 'locked', 'no_rich_text', 'hide_network'] + const fields = ['description', 'locked', 'no_rich_text', 'hide_follows', 'hide_followers', 'show_role'] let url = PROFILE_UPDATE_URL const form = new FormData() @@ -247,15 +247,28 @@ const fetchUser = ({id, credentials}) => { .then((data) => parseUser(data)) } -const fetchFriends = ({id, credentials}) => { +const fetchFriends = ({id, page, credentials}) => { let url = `${FRIENDS_URL}?user_id=${id}` + if (page) { + url = url + `&page=${page}` + } return fetch(url, { headers: authHeaders(credentials) }) .then((data) => data.json()) .then((data) => data.map(parseUser)) } -const fetchFollowers = ({id, credentials}) => { +const exportFriends = ({id, credentials}) => { + let url = `${FRIENDS_URL}?user_id=${id}&export=true` + return fetch(url, { headers: authHeaders(credentials) }) + .then((data) => data.json()) + .then((data) => data.map(parseUser)) +} + +const fetchFollowers = ({id, page, credentials}) => { let url = `${FOLLOWERS_URL}?user_id=${id}` + if (page) { + url = url + `&page=${page}` + } return fetch(url, { headers: authHeaders(credentials) }) .then((data) => data.json()) .then((data) => data.map(parseUser)) @@ -530,6 +543,7 @@ const apiService = { fetchConversation, fetchStatus, fetchFriends, + exportFriends, fetchFollowers, followUser, unfollowUser, diff --git a/src/services/backend_interactor_service/backend_interactor_service.js b/src/services/backend_interactor_service/backend_interactor_service.js index f44f52b6..80c5cc5e 100644 --- a/src/services/backend_interactor_service/backend_interactor_service.js +++ b/src/services/backend_interactor_service/backend_interactor_service.js @@ -10,12 +10,16 @@ const backendInteractorService = (credentials) => { return apiService.fetchConversation({id, credentials}) } - const fetchFriends = ({id}) => { - return apiService.fetchFriends({id, credentials}) + const fetchFriends = ({id, page}) => { + return apiService.fetchFriends({id, page, credentials}) } - const fetchFollowers = ({id}) => { - return apiService.fetchFollowers({id, credentials}) + const exportFriends = ({id}) => { + return apiService.exportFriends({id, credentials}) + } + + const fetchFollowers = ({id, page}) => { + return apiService.fetchFollowers({id, page, credentials}) } const fetchAllFollowing = ({username}) => { @@ -78,6 +82,7 @@ const backendInteractorService = (credentials) => { fetchStatus, fetchConversation, fetchFriends, + exportFriends, fetchFollowers, followUser, unfollowUser, diff --git a/src/services/entity_normalizer/entity_normalizer.service.js b/src/services/entity_normalizer/entity_normalizer.service.js index 74422a49..828c48f9 100644 --- a/src/services/entity_normalizer/entity_normalizer.service.js +++ b/src/services/entity_normalizer/entity_normalizer.service.js @@ -90,6 +90,8 @@ export const parseUser = (data) => { output.statusnet_blocking = data.statusnet_blocking output.is_local = data.is_local + output.role = data.role + output.show_role = data.show_role output.follows_you = data.follows_you @@ -100,7 +102,8 @@ export const parseUser = (data) => { output.rights = data.rights output.no_rich_text = data.no_rich_text output.default_scope = data.default_scope - output.hide_network = data.hide_network + output.hide_follows = data.hide_follows + output.hide_followers = data.hide_followers output.background_image = data.background_image // on mastoapi this info is contained in a "relationship" output.following = data.following @@ -112,6 +115,8 @@ export const parseUser = (data) => { output.locked = data.locked output.followers_count = data.followers_count output.statuses_count = data.statuses_count + output.friends = [] + output.followers = [] return output } diff --git a/src/services/follow_manipulate/follow_manipulate.js b/src/services/follow_manipulate/follow_manipulate.js new file mode 100644 index 00000000..1e9bd679 --- /dev/null +++ b/src/services/follow_manipulate/follow_manipulate.js @@ -0,0 +1,74 @@ +const fetchUser = (attempt, user, store) => new Promise((resolve, reject) => { + setTimeout(() => { + store.state.api.backendInteractor.fetchUser({ id: user.id }) + .then((user) => store.commit('addNewUsers', [user])) + .then(() => resolve([user.following, attempt])) + .catch((e) => reject(e)) + }, 500) +}).then(([following, attempt]) => { + if (!following && attempt <= 3) { + // If we BE reports that we still not following that user - retry, + // increment attempts by one + return fetchUser(++attempt, user, store) + } else { + // If we run out of attempts, just return whatever status is. + return following + } +}) + +export const requestFollow = (user, store) => new Promise((resolve, reject) => { + store.state.api.backendInteractor.followUser(user.id) + .then((updated) => { + store.commit('addNewUsers', [updated]) + + // For locked users we just mark it that we sent the follow request + if (updated.locked) { + resolve({ + sent: true, + updated + }) + } + + if (updated.following) { + // If we get result immediately, just stop. + resolve({ + sent: false, + updated + }) + } + + // But usually we don't get result immediately, so we ask server + // for updated user profile to confirm if we are following them + // Sometimes it takes several tries. Sometimes we end up not following + // user anyway, probably because they locked themselves and we + // don't know that yet. + // Recursive Promise, it will call itself up to 3 times. + + return fetchUser(1, user, store) + .then((following) => { + if (following) { + // We confirmed and everything's good. + resolve({ + sent: false, + updated + }) + } else { + // If after all the tries, just treat it as if user is locked + resolve({ + sent: false, + updated + }) + } + }) + }) +}) + +export const requestUnfollow = (user, store) => new Promise((resolve, reject) => { + store.state.api.backendInteractor.unfollowUser(user.id) + .then((updated) => { + store.commit('addNewUsers', [updated]) + resolve({ + updated + }) + }) +}) diff --git a/src/services/matcher/matcher.service.js b/src/services/matcher/matcher.service.js new file mode 100644 index 00000000..b6c4e909 --- /dev/null +++ b/src/services/matcher/matcher.service.js @@ -0,0 +1,23 @@ +export const mentionMatchesUrl = (attention, url) => { + if (url === attention.statusnet_profile_url) { + return true + } + const [namepart, instancepart] = attention.screen_name.split('@') + const matchstring = new RegExp('://' + instancepart + '/.*' + namepart + '$', 'g') + + return !!url.match(matchstring) +} + +/** + * Extract tag name from pleroma or mastodon url. + * i.e https://bikeshed.party/tag/photo or https://quey.org/tags/sky + * @param {string} url + */ +export const extractTagFromUrl = (url) => { + const regex = /tag[s]*\/(\w+)$/g + const result = regex.exec(url) + if (!result) { + return false + } + return result[1] +} diff --git a/src/services/mention_matcher/mention_matcher.js b/src/services/mention_matcher/mention_matcher.js deleted file mode 100644 index 2c1ed970..00000000 --- a/src/services/mention_matcher/mention_matcher.js +++ /dev/null @@ -1,9 +0,0 @@ - -export const mentionMatchesUrl = (attention, url) => { - if (url === attention.statusnet_profile_url) { - return true - } - const [namepart, instancepart] = attention.screen_name.split('@') - const matchstring = new RegExp('://' + instancepart + '/.*' + namepart + '$', 'g') - return !!url.match(matchstring) -} diff --git a/src/services/style_setter/style_setter.js b/src/services/style_setter/style_setter.js index 10e7ed9b..d0b6ccbf 100644 --- a/src/services/style_setter/style_setter.js +++ b/src/services/style_setter/style_setter.js @@ -480,7 +480,7 @@ const getThemes = () => { } const setPreset = (val, commit) => { - getThemes().then((themes) => { + return getThemes().then((themes) => { const theme = themes[val] ? themes[val] : themes['pleroma-dark'] const isV1 = Array.isArray(theme) const data = isV1 ? {} : theme.theme diff --git a/src/services/user_profile_link_generator/user_profile_link_generator.js b/src/services/user_profile_link_generator/user_profile_link_generator.js index bca2c9cd..a214ca48 100644 --- a/src/services/user_profile_link_generator/user_profile_link_generator.js +++ b/src/services/user_profile_link_generator/user_profile_link_generator.js @@ -8,6 +8,6 @@ const generateProfileLink = (id, screenName, restrictedNicknames) => { } } -const isExternal = screenName => screenName.includes('@') +const isExternal = screenName => screenName && screenName.includes('@') export default generateProfileLink diff --git a/static/config.json b/static/config.json index 24e26696..533a5b08 100644 --- a/static/config.json +++ b/static/config.json @@ -13,11 +13,13 @@ "collapseMessageWithSubject": false, "scopeCopy": true, "subjectLineBehavior": "email", + "postContentType": "text/plain", "alwaysShowSubjectInput": true, "hidePostStats": false, "hideUserStats": false, "loginMethod": "password", "webPushNotifications": false, "noAttachmentLinks": false, - "nsfwCensorImage": "" + "nsfwCensorImage": "", + "showFeaturesPanel": true } diff --git a/static/emoji.json b/static/emoji.json index 0117bac1..ae93d17e 100644 --- a/static/emoji.json +++ b/static/emoji.json @@ -1 +1,969 @@ -{"womans_clothes": "\ud83d\udc5a", "cookie": "\ud83c\udf6a", "woman_with_headscarf": "\ud83e\uddd5", "no_smoking": "\ud83d\udead", "e-mail": "\ud83d\udce7", "regional_indicator_d": "\ud83c\udde9", "oncoming_bus": "\ud83d\ude8d", "knife": "\ud83d\udd2a", "person_getting_haircut": "\ud83d\udc87", "grimacing": "\ud83d\ude2c", "ophiuchus": "\u26ce", "regional_indicator_q": "\ud83c\uddf6", "thinking": "\ud83e\udd14", "signal_strength": "\ud83d\udcf6", "cactus": "\ud83c\udf35", "bullettrain_front": "\ud83d\ude85", "floppy_disk": "\ud83d\udcbe", "doughnut": "\ud83c\udf69", "tv": "\ud83d\udcfa", "1234": "\ud83d\udd22", "anguished": "\ud83d\ude27", "clock1030": "\ud83d\udd65", "u7533": "\ud83c\ude38", "speak_no_evil": "\ud83d\ude4a", "chart_with_upwards_trend": "\ud83d\udcc8", "trophy": "\ud83c\udfc6", "musical_score": "\ud83c\udfbc", "chestnut": "\ud83c\udf30", "clock1130": "\ud83d\udd66", "abcd": "\ud83d\udd21", "syringe": "\ud83d\udc89", "shrimp": "\ud83e\udd90", "pisces": "\u2653", "left_facing_fist": "\ud83e\udd1b", "bar_chart": "\ud83d\udcca", "eagle": "\ud83e\udd85", "woman": "\ud83d\udc69", "keycap_ten": "\ud83d\udd1f", "yellow_heart": "\ud83d\udc9b", "croissant": "\ud83e\udd50", "mosque": "\ud83d\udd4c", "rice_ball": "\ud83c\udf59", "volcano": "\ud83c\udf0b", "baggage_claim": "\ud83d\udec4", "family": "\ud83d\udc6a", "beetle": "\ud83d\udc1e", "older_adult": "\ud83e\uddd3", "clock830": "\ud83d\udd63", "bacon": "\ud83e\udd53", "sound": "\ud83d\udd09", "no_bicycles": "\ud83d\udeb3", "rewind": "\u23ea", "adult": "\ud83e\uddd1", "scream_cat": "\ud83d\ude40", "person_playing_water_polo": "\ud83e\udd3d", "blue_car": "\ud83d\ude99", "smiley": "\ud83d\ude03", "kaaba": "\ud83d\udd4b", "twisted_rightwards_arrows": "\ud83d\udd00", "last_quarter_moon": "\ud83c\udf17", "first_place": "\ud83e\udd47", "joy_cat": "\ud83d\ude39", "sleeping": "\ud83d\ude34", "basketball": "\ud83c\udfc0", "pray": "\ud83d\ude4f", "trumpet": "\ud83c\udfba", "purple_heart": "\ud83d\udc9c", "broken_heart": "\ud83d\udc94", "astonished": "\ud83d\ude32", "soccer": "\u26bd", "princess": "\ud83d\udc78", "ant": "\ud83d\udc1c", "pig": "\ud83d\udc37", "vhs": "\ud83d\udcfc", "scream": "\ud83d\ude31", "mouse": "\ud83d\udc2d", "field_hockey": "\ud83c\udfd1", "ab": "\ud83c\udd8e", "tokyo_tower": "\ud83d\uddfc", "girl": "\ud83d\udc67", "u55b6": "\ud83c\ude3a", "guard": "\ud83d\udc82", "regional_indicator_s": "\ud83c\uddf8", "tulip": "\ud83c\udf37", "capital_abcd": "\ud83d\udd20", "beginner": "\ud83d\udd30", "couplekiss": "\ud83d\udc8f", "u5408": "\ud83c\ude34", "black_medium_small_square": "\u25fe", "paperclip": "\ud83d\udcce", "hedgehog": "\ud83e\udd94", "musical_note": "\ud83c\udfb5", "pill": "\ud83d\udc8a", "blue_heart": "\ud83d\udc99", "mens": "\ud83d\udeb9", "third_place": "\ud83e\udd49", "stew": "\ud83c\udf72", "prince": "\ud83e\udd34", "mortar_board": "\ud83c\udf93", "clock6": "\ud83d\udd55", "beer": "\ud83c\udf7a", "person_tipping_hand": "\ud83d\udc81", "triangular_ruler": "\ud83d\udcd0", "regional_indicator_y": "\ud83c\uddfe", "person_facepalming": "\ud83e\udd26", "steam_locomotive": "\ud83d\ude82", "fire_engine": "\ud83d\ude92", "horse": "\ud83d\udc34", "ribbon": "\ud83c\udf80", "white_large_square": "\u2b1c", "smirk": "\ud83d\ude0f", "genie": "\ud83e\uddde", "tangerine": "\ud83c\udf4a", "cl": "\ud83c\udd91", "japanese_goblin": "\ud83d\udc7a", "regional_indicator_u": "\ud83c\uddfa", "ring": "\ud83d\udc8d", "roller_coaster": "\ud83c\udfa2", "100": "\ud83d\udcaf", "clock12": "\ud83d\udd5b", "two_hearts": "\ud83d\udc95", "anger": "\ud83d\udca2", "black_circle": "\u26ab", "revolving_hearts": "\ud83d\udc9e", "space_invader": "\ud83d\udc7e", "bell": "\ud83d\udd14", "point_up_2": "\ud83d\udc46", "person_mountain_biking": "\ud83d\udeb5", "flags": "\ud83c\udf8f", "pushpin": "\ud83d\udccc", "large_blue_diamond": "\ud83d\udd37", "fairy": "\ud83e\uddda", "european_post_office": "\ud83c\udfe4", "statue_of_liberty": "\ud83d\uddfd", "man": "\ud83d\udc68", "microphone": "\ud83c\udfa4", "inbox_tray": "\ud83d\udce5", "bath": "\ud83d\udec0", "person_gesturing_ok": "\ud83d\ude46", "clap": "\ud83d\udc4f", "confused": "\ud83d\ude15", "fortune_cookie": "\ud83e\udd60", "kissing_closed_eyes": "\ud83d\ude1a", "kissing_heart": "\ud83d\ude18", "tropical_fish": "\ud83d\udc20", "taco": "\ud83c\udf2e", "kimono": "\ud83d\udc58", "u7a7a": "\ud83c\ude33", "rat": "\ud83d\udc00", "taurus": "\u2649", "shopping_cart": "\ud83d\uded2", "womans_hat": "\ud83d\udc52", "blossom": "\ud83c\udf3c", "moyai": "\ud83d\uddff", "clock130": "\ud83d\udd5c", "telescope": "\ud83d\udd2d", "running_shirt_with_sash": "\ud83c\udfbd", "person_running": "\ud83c\udfc3", "dizzy": "\ud83d\udcab", "crescent_moon": "\ud83c\udf19", "boom": "\ud83d\udca5", "restroom": "\ud83d\udebb", "fist": "\u270a", "white_flower": "\ud83d\udcae", "clown": "\ud83e\udd21", "neutral_face": "\ud83d\ude10", "id": "\ud83c\udd94", "carrot": "\ud83e\udd55", "rice_scene": "\ud83c\udf91", "foggy": "\ud83c\udf01", "turtle": "\ud83d\udc22", "mailbox_with_mail": "\ud83d\udcec", "baseball": "\u26be", "grin": "\ud83d\ude01", "bathtub": "\ud83d\udec1", "feet": "\ud83d\udc3e", "small_red_triangle": "\ud83d\udd3a", "camel": "\ud83d\udc2b", "aquarius": "\u2652", "face_with_symbols_over_mouth": "\ud83e\udd2c", "handbag": "\ud83d\udc5c", "date": "\ud83d\udcc5", "nail_care": "\ud83d\udc85", "satellite": "\ud83d\udce1", "candy": "\ud83c\udf6c", "white_medium_small_square": "\u25fd", "clock930": "\ud83d\udd64", "fearful": "\ud83d\ude28", "fork_and_knife": "\ud83c\udf74", "person_wearing_turban": "\ud83d\udc73", "confounded": "\ud83d\ude16", "helicopter": "\ud83d\ude81", "arrow_double_down": "\u23ec", "convenience_store": "\ud83c\udfea", "ghost": "\ud83d\udc7b", "bus": "\ud83d\ude8c", "waning_gibbous_moon": "\ud83c\udf16", "bank": "\ud83c\udfe6", "department_store": "\ud83c\udfec", "hockey": "\ud83c\udfd2", "fingers_crossed": "\ud83e\udd1e", "blond_haired_person": "\ud83d\udc71", "mag": "\ud83d\udd0d", "cut_of_meat": "\ud83e\udd69", "wink": "\ud83d\ude09", "railway_car": "\ud83d\ude83", "face_vomiting": "\ud83e\udd2e", "star_struck": "\ud83e\udd29", "first_quarter_moon_with_face": "\ud83c\udf1b", "octagonal_sign": "\ud83d\uded1", "hospital": "\ud83c\udfe5", "monkey": "\ud83d\udc12", "curly_loop": "\u27b0", "avocado": "\ud83e\udd51", "earth_americas": "\ud83c\udf0e", "flashlight": "\ud83d\udd26", "8ball": "\ud83c\udfb1", "clock630": "\ud83d\udd61", "boar": "\ud83d\udc17", "birthday": "\ud83c\udf82", "crocodile": "\ud83d\udc0a", "confetti_ball": "\ud83c\udf8a", "door": "\ud83d\udeaa", "school_satchel": "\ud83c\udf92", "peanuts": "\ud83e\udd5c", "regional_indicator_m": "\ud83c\uddf2", "bust_in_silhouette": "\ud83d\udc64", "sweat_drops": "\ud83d\udca6", "tongue": "\ud83d\udc45", "mag_right": "\ud83d\udd0e", "t_rex": "\ud83e\udd96", "post_office": "\ud83c\udfe3", "shell": "\ud83d\udc1a", "disappointed_relieved": "\ud83d\ude25", "card_index": "\ud83d\udcc7", "oncoming_automobile": "\ud83d\ude98", "passport_control": "\ud83d\udec2", "cherry_blossom": "\ud83c\udf38", "shallow_pan_of_food": "\ud83e\udd58", "heartbeat": "\ud83d\udc93", "crazy_face": "\ud83e\udd2a", "grapes": "\ud83c\udf47", "symbols": "\ud83d\udd23", "gift": "\ud83c\udf81", "scorpion": "\ud83e\udd82", "wedding": "\ud83d\udc92", "last_quarter_moon_with_face": "\ud83c\udf1c", "love_letter": "\ud83d\udc8c", "postal_horn": "\ud83d\udcef", "stuffed_flatbread": "\ud83e\udd59", "heavy_dollar_sign": "\ud83d\udcb2", "love_hotel": "\ud83c\udfe9", "yen": "\ud83d\udcb4", "person_in_steamy_room": "\ud83e\uddd6", "palm_tree": "\ud83c\udf34", "name_badge": "\ud83d\udcdb", "clock430": "\ud83d\udd5f", "bike": "\ud83d\udeb2", "snail": "\ud83d\udc0c", "bowling": "\ud83c\udfb3", "umbrella": "\u2614", "sleeping_accommodation": "\ud83d\udecc", "fireworks": "\ud83c\udf86", "closed_book": "\ud83d\udcd5", "city_sunset": "\ud83c\udf07", "persevere": "\ud83d\ude23", "bento": "\ud83c\udf71", "nut_and_bolt": "\ud83d\udd29", "page_facing_up": "\ud83d\udcc4", "snowman": "\u26c4", "two_women_holding_hands": "\ud83d\udc6d", "regional_indicator_o": "\ud83c\uddf4", "calling": "\ud83d\udcf2", "person_shrugging": "\ud83e\udd37", "sneezing_face": "\ud83e\udd27", "arrows_clockwise": "\ud83d\udd03", "no_pedestrians": "\ud83d\udeb7", "potato": "\ud83e\udd54", "cheese": "\ud83e\uddc0", "full_moon": "\ud83c\udf15", "mount_fuji": "\ud83d\uddfb", "sob": "\ud83d\ude2d", "construction": "\ud83d\udea7", "head_bandage": "\ud83e\udd15", "sailboat": "\u26f5", "slight_frown": "\ud83d\ude41", "ping_pong": "\ud83c\udfd3", "hatched_chick": "\ud83d\udc25", "sun_with_face": "\ud83c\udf1e", "seedling": "\ud83c\udf31", "repeat_one": "\ud83d\udd02", "muscle": "\ud83d\udcaa", "bridge_at_night": "\ud83c\udf09", "raised_hands": "\ud83d\ude4c", "house": "\ud83c\udfe0", "nerd": "\ud83e\udd13", "penguin": "\ud83d\udc27", "peach": "\ud83c\udf51", "dumpling": "\ud83e\udd5f", "watch": "\u231a", "womens": "\ud83d\udeba", "round_pushpin": "\ud83d\udccd", "alarm_clock": "\u23f0", "relieved": "\ud83d\ude0c", "sagittarius": "\u2650", "busstop": "\ud83d\ude8f", "regional_indicator_a": "\ud83c\udde6", "sandal": "\ud83d\udc61", "whale2": "\ud83d\udc0b", "book": "\ud83d\udcd6", "sweat": "\ud83d\ude13", "movie_camera": "\ud83c\udfa5", "clock230": "\ud83d\udd5d", "tiger": "\ud83d\udc2f", "tractor": "\ud83d\ude9c", "smile": "\ud83d\ude04", "vertical_traffic_light": "\ud83d\udea6", "exploding_head": "\ud83e\udd2f", "raised_hand": "\u270b", "smoking": "\ud83d\udeac", "page_with_curl": "\ud83d\udcc3", "exclamation": "\u2757", "fish": "\ud83d\udc1f", "mans_shoe": "\ud83d\udc5e", "sos": "\ud83c\udd98", "unlock": "\ud83d\udd13", "dolls": "\ud83c\udf8e", "ear_of_rice": "\ud83c\udf3e", "cat2": "\ud83d\udc08", "u7121": "\ud83c\ude1a", "repeat": "\ud83d\udd01", "cool": "\ud83c\udd92", "minibus": "\ud83d\ude90", "aerial_tramway": "\ud83d\udea1", "key": "\ud83d\udd11", "child": "\ud83e\uddd2", "camera": "\ud83d\udcf7", "sunflower": "\ud83c\udf3b", "white_check_mark": "\u2705", "white_square_button": "\ud83d\udd33", "banana": "\ud83c\udf4c", "milky_way": "\ud83c\udf0c", "person_gesturing_no": "\ud83d\ude45", "sushi": "\ud83c\udf63", "heart_eyes_cat": "\ud83d\ude3b", "guitar": "\ud83c\udfb8", "pie": "\ud83e\udd67", "calendar": "\ud83d\udcc6", "bear": "\ud83d\udc3b", "person_in_lotus_position": "\ud83e\uddd8", "clock10": "\ud83d\udd59", "top": "\ud83d\udd1d", "fuelpump": "\u26fd", "rainbow": "\ud83c\udf08", "snowboarder": "\ud83c\udfc2", "drum": "\ud83e\udd41", "leaves": "\ud83c\udf43", "first_quarter_moon": "\ud83c\udf13", "spoon": "\ud83e\udd44", "pouting_cat": "\ud83d\ude3e", "shaved_ice": "\ud83c\udf67", "unamused": "\ud83d\ude12", "train2": "\ud83d\ude86", "clock1230": "\ud83d\udd67", "regional_indicator_r": "\ud83c\uddf7", "fast_forward": "\u23e9", "accept": "\ud83c\ude51", "hammer": "\ud83d\udd28", "panda_face": "\ud83d\udc3c", "briefcase": "\ud83d\udcbc", "package": "\ud83d\udce6", "flag_black": "\ud83c\udff4", "smiling_imp": "\ud83d\ude08", "sunrise_over_mountains": "\ud83c\udf04", "airplane_departure": "\ud83d\udeeb", "tiger2": "\ud83d\udc05", "non-potable_water": "\ud83d\udeb1", "bird": "\ud83d\udc26", "barber": "\ud83d\udc88", "cry": "\ud83d\ude22", "billed_cap": "\ud83e\udde2", "pouch": "\ud83d\udc5d", "link": "\ud83d\udd17", "zebra": "\ud83e\udd93", "kiss": "\ud83d\udc8b", "scorpius": "\u264f", "prayer_beads": "\ud83d\udcff", "high_brightness": "\ud83d\udd06", "kissing_smiling_eyes": "\ud83d\ude19", "rhino": "\ud83e\udd8f", "left_luggage": "\ud83d\udec5", "o": "\u2b55", "crying_cat_face": "\ud83d\ude3f", "clock8": "\ud83d\udd57", "dress": "\ud83d\udc57", "clock7": "\ud83d\udd56", "bowl_with_spoon": "\ud83e\udd63", "rolling_eyes": "\ud83d\ude44", "fax": "\ud83d\udce0", "worried": "\ud83d\ude1f", "grey_question": "\u2754", "saxophone": "\ud83c\udfb7", "burrito": "\ud83c\udf2f", "salad": "\ud83e\udd57", "regional_indicator_z": "\ud83c\uddff", "bikini": "\ud83d\udc59", "milk": "\ud83e\udd5b", "stars": "\ud83c\udf20", "lips": "\ud83d\udc44", "cd": "\ud83d\udcbf", "weary": "\ud83d\ude29", "face_with_raised_eyebrow": "\ud83e\udd28", "lizard": "\ud83e\udd8e", "tone1": "\ud83c\udffb", "bullettrain_side": "\ud83d\ude84", "nose": "\ud83d\udc43", "innocent": "\ud83d\ude07", "wilted_rose": "\ud83e\udd40", "mahjong": "\ud83c\udc04", "factory": "\ud83c\udfed", "people_wrestling": "\ud83e\udd3c", "mailbox": "\ud83d\udceb", "rage": "\ud83d\ude21", "wheelchair": "\u267f", "x": "\u274c", "flower_playing_cards": "\ud83c\udfb4", "nauseated_face": "\ud83e\udd22", "underage": "\ud83d\udd1e", "ideograph_advantage": "\ud83c\ude50", "high_heel": "\ud83d\udc60", "dizzy_face": "\ud83d\ude35", "stuck_out_tongue": "\ud83d\ude1b", "mailbox_with_no_mail": "\ud83d\udced", "orange_heart": "\ud83e\udde1", "raised_back_of_hand": "\ud83e\udd1a", "footprints": "\ud83d\udc63", "notebook_with_decorative_cover": "\ud83d\udcd4", "mask": "\ud83d\ude37", "sunglasses": "\ud83d\ude0e", "pancakes": "\ud83e\udd5e", "regional_indicator_f": "\ud83c\uddeb", "dog": "\ud83d\udc36", "pig2": "\ud83d\udc16", "ng": "\ud83c\udd96", "unicorn": "\ud83e\udd84", "triumph": "\ud83d\ude24", "eggplant": "\ud83c\udf46", "egg": "\ud83e\udd5a", "office": "\ud83c\udfe2", "goat": "\ud83d\udc10", "handshake": "\ud83e\udd1d", "star": "\u2b50", "rugby_football": "\ud83c\udfc9", "call_me": "\ud83e\udd19", "rice_cracker": "\ud83c\udf58", "droplet": "\ud83d\udca7", "badminton": "\ud83c\udff8", "waxing_crescent_moon": "\ud83c\udf12", "ocean": "\ud83c\udf0a", "slot_machine": "\ud83c\udfb0", "wine_glass": "\ud83c\udf77", "elephant": "\ud83d\udc18", "blowfish": "\ud83d\udc21", "ledger": "\ud83d\udcd2", "money_mouth": "\ud83e\udd11", "heart_decoration": "\ud83d\udc9f", "arrow_down_small": "\ud83d\udd3d", "station": "\ud83d\ude89", "man_with_chinese_cap": "\ud83d\udc72", "vampire": "\ud83e\udddb", "pencil": "\ud83d\udcdd", "cyclone": "\ud83c\udf00", "mushroom": "\ud83c\udf44", "sandwich": "\ud83e\udd6a", "champagne": "\ud83c\udf7e", "expressionless": "\ud83d\ude11", "cold_sweat": "\ud83d\ude30", "maple_leaf": "\ud83c\udf41", "dromedary_camel": "\ud83d\udc2a", "vs": "\ud83c\udd9a", "person_fencing": "\ud83e\udd3a", "straight_ruler": "\ud83d\udccf", "baby_bottle": "\ud83c\udf7c", "currency_exchange": "\ud83d\udcb1", "regional_indicator_h": "\ud83c\udded", "stuck_out_tongue_closed_eyes": "\ud83d\ude1d", "closed_lock_with_key": "\ud83d\udd10", "eyes": "\ud83d\udc40", "water_buffalo": "\ud83d\udc03", "lock_with_ink_pen": "\ud83d\udd0f", "heavy_plus_sign": "\u2795", "bookmark": "\ud83d\udd16", "soon": "\ud83d\udd1c", "orange_book": "\ud83d\udcd9", "pineapple": "\ud83c\udf4d", "clock9": "\ud83d\udd58", "small_blue_diamond": "\ud83d\udd39", "black_large_square": "\u2b1b", "person_surfing": "\ud83c\udfc4", "leo": "\u264c", "merperson": "\ud83e\udddc", "canoe": "\ud83d\udef6", "rooster": "\ud83d\udc13", "hear_no_evil": "\ud83d\ude49", "corn": "\ud83c\udf3d", "takeout_box": "\ud83e\udd61", "oncoming_taxi": "\ud83d\ude96", "taxi": "\ud83d\ude95", "chart": "\ud83d\udcb9", "goal": "\ud83e\udd45", "melon": "\ud83c\udf48", "notes": "\ud83c\udfb6", "sparkler": "\ud83c\udf87", "dolphin": "\ud83d\udc2c", "speedboat": "\ud83d\udea4", "cancer": "\u264b", "sled": "\ud83d\udef7", "tanabata_tree": "\ud83c\udf8b", "train": "\ud83d\ude8b", "christmas_tree": "\ud83c\udf84", "two_men_holding_hands": "\ud83d\udc6c", "back": "\ud83d\udd19", "balloon": "\ud83c\udf88", "checkered_flag": "\ud83c\udfc1", "loop": "\u27bf", "wc": "\ud83d\udebe", "jeans": "\ud83d\udc56", "green_apple": "\ud83c\udf4f", "crown": "\ud83d\udc51", "cowboy": "\ud83e\udd20", "postbox": "\ud83d\udcee", "volleyball": "\ud83c\udfd0", "upside_down": "\ud83d\ude43", "cricket": "\ud83e\udd97", "custard": "\ud83c\udf6e", "rose": "\ud83c\udf39", "eyeglasses": "\ud83d\udc53", "oncoming_police_car": "\ud83d\ude94", "atm": "\ud83c\udfe7", "flying_saucer": "\ud83d\udef8", "alien": "\ud83d\udc7d", "hamster": "\ud83d\udc39", "trident": "\ud83d\udd31", "disappointed": "\ud83d\ude1e", "cow": "\ud83d\udc2e", "police_officer": "\ud83d\udc6e", "popcorn": "\ud83c\udf7f", "baby_chick": "\ud83d\udc24", "video_camera": "\ud83d\udcf9", "zzz": "\ud83d\udca4", "person_climbing": "\ud83e\uddd7", "star2": "\ud83c\udf1f", "ok": "\ud83c\udd97", "capricorn": "\u2651", "chicken": "\ud83d\udc14", "arrow_double_up": "\u23eb", "zombie": "\ud83e\udddf", "closed_umbrella": "\ud83c\udf02", "person_walking": "\ud83d\udeb6", "lemon": "\ud83c\udf4b", "heartpulse": "\ud83d\udc97", "regional_indicator_i": "\ud83c\uddee", "sauropod": "\ud83e\udd95", "u7981": "\ud83c\ude32", "regional_indicator_w": "\ud83c\uddfc", "evergreen_tree": "\ud83c\udf32", "mobile_phone_off": "\ud83d\udcf4", "koko": "\ud83c\ude01", "poop": "\ud83d\udca9", "cup_with_straw": "\ud83e\udd64", "leopard": "\ud83d\udc06", "radio_button": "\ud83d\udd18", "mega": "\ud83d\udce3", "metal": "\ud83e\udd18", "shushing_face": "\ud83e\udd2b", "stuck_out_tongue_winking_eye": "\ud83d\ude1c", "octopus": "\ud83d\udc19", "boxing_glove": "\ud83e\udd4a", "person_juggling": "\ud83e\udd39", "money_with_wings": "\ud83d\udcb8", "dollar": "\ud83d\udcb5", "bride_with_veil": "\ud83d\udc70", "second_place": "\ud83e\udd48", "spaghetti": "\ud83c\udf5d", "waning_crescent_moon": "\ud83c\udf18", "football": "\ud83c\udfc8", "white_circle": "\u26aa", "full_moon_with_face": "\ud83c\udf1d", "selfie": "\ud83e\udd33", "tone3": "\ud83c\udffd", "rabbit": "\ud83d\udc30", "computer": "\ud83d\udcbb", "clock11": "\ud83d\udd5a", "heavy_minus_sign": "\u2796", "synagogue": "\ud83d\udd4d", "hourglass": "\u231b", "gem": "\ud83d\udc8e", "person_doing_cartwheel": "\ud83e\udd38", "new_moon_with_face": "\ud83c\udf1a", "sunrise": "\ud83c\udf05", "regional_indicator_x": "\ud83c\uddfd", "open_file_folder": "\ud83d\udcc2", "gift_heart": "\ud83d\udc9d", "tada": "\ud83c\udf89", "green_heart": "\ud83d\udc9a", "battery": "\ud83d\udd0b", "regional_indicator_t": "\ud83c\uddf9", "wrench": "\ud83d\udd27", "aries": "\u2648", "man_in_tuxedo": "\ud83e\udd35", "regional_indicator_e": "\ud83c\uddea", "regional_indicator_l": "\ud83c\uddf1", "cake": "\ud83c\udf70", "clapper": "\ud83c\udfac", "japanese_castle": "\ud83c\udfef", "crystal_ball": "\ud83d\udd2e", "golf": "\u26f3", "no_mobile_phones": "\ud83d\udcf5", "person_biking": "\ud83d\udeb4", "icecream": "\ud83c\udf66", "mage": "\ud83e\uddd9", "bookmark_tabs": "\ud83d\udcd1", "tone4": "\ud83c\udffe", "mountain_cableway": "\ud83d\udea0", "person_playing_handball": "\ud83e\udd3e", "bulb": "\ud83d\udca1", "clock330": "\ud83d\udd5e", "metro": "\ud83d\ude87", "wave": "\ud83d\udc4b", "whale": "\ud83d\udc33", "strawberry": "\ud83c\udf53", "hatching_chick": "\ud83d\udc23", "trolleybus": "\ud83d\ude8e", "lollipop": "\ud83c\udf6d", "clipboard": "\ud83d\udccb", "point_right": "\ud83d\udc49", "u6307": "\ud83c\ude2f", "santa": "\ud83c\udf85", "hibiscus": "\ud83c\udf3a", "green_book": "\ud83d\udcd7", "skull": "\ud83d\udc80", "tumbler_glass": "\ud83e\udd43", "clock2": "\ud83d\udd51", "open_mouth": "\ud83d\ude2e", "bouquet": "\ud83d\udc90", "champagne_glass": "\ud83e\udd42", "poodle": "\ud83d\udc29", "hushed": "\ud83d\ude2f", "earth_asia": "\ud83c\udf0f", "face_with_monocle": "\ud83e\uddd0", "libra": "\u264e", "clock5": "\ud83d\udd54", "ambulance": "\ud83d\ude91", "u5272": "\ud83c\ude39", "lipstick": "\ud83d\udc84", "apple": "\ud83c\udf4e", "headphones": "\ud83c\udfa7", "turkey": "\ud83e\udd83", "pretzel": "\ud83e\udd68", "bug": "\ud83d\udc1b", "school": "\ud83c\udfeb", "speaker": "\ud83d\udd08", "boot": "\ud83d\udc62", "cat": "\ud83d\udc31", "dancer": "\ud83d\udc83", "no_entry": "\u26d4", "kissing_cat": "\ud83d\ude3d", "art": "\ud83c\udfa8", "coat": "\ud83e\udde5", "credit_card": "\ud83d\udcb3", "customs": "\ud83d\udec3", "broccoli": "\ud83e\udd66", "point_left": "\ud83d\udc48", "canned_food": "\ud83e\udd6b", "sheep": "\ud83d\udc11", "person_bowing": "\ud83d\ude47", "scroll": "\ud83d\udcdc", "martial_arts_uniform": "\ud83e\udd4b", "amphora": "\ud83c\udffa", "thought_balloon": "\ud83d\udcad", "no_bell": "\ud83d\udd15", "musical_keyboard": "\ud83c\udfb9", "people_with_bunny_ears_partying": "\ud83d\udc6f", "european_castle": "\ud83c\udff0", "punch": "\ud83d\udc4a", "camera_with_flash": "\ud83d\udcf8", "regional_indicator_p": "\ud83c\uddf5", "red_car": "\ud83d\ude97", "regional_indicator_j": "\ud83c\uddef", "owl": "\ud83e\udd89", "chart_with_downwards_trend": "\ud83d\udcc9", "older_woman": "\ud83d\udc75", "gemini": "\u264a", "incoming_envelope": "\ud83d\udce8", "waxing_gibbous_moon": "\ud83c\udf14", "toilet": "\ud83d\udebd", "dragon_face": "\ud83d\udc32", "koala": "\ud83d\udc28", "tone5": "\ud83c\udfff", "kiwi": "\ud83e\udd5d", "dash": "\ud83d\udca8", "imp": "\ud83d\udc7f", "tent": "\u26fa", "regional_indicator_b": "\ud83c\udde7", "monorail": "\ud83d\ude9d", "ox": "\ud83d\udc02", "giraffe": "\ud83e\udd92", "new": "\ud83c\udd95", "person_raising_hand": "\ud83d\ude4b", "japan": "\ud83d\uddfe", "rice": "\ud83c\udf5a", "ticket": "\ud83c\udfab", "rotating_light": "\ud83d\udea8", "loudspeaker": "\ud83d\udce2", "person_getting_massage": "\ud83d\udc86", "loud_sound": "\ud83d\udd0a", "hugging": "\ud83e\udd17", "herb": "\ud83c\udf3f", "baby": "\ud83d\udc76", "angel": "\ud83d\udc7c", "athletic_shoe": "\ud83d\udc5f", "euro": "\ud83d\udcb6", "ram": "\ud83d\udc0f", "large_orange_diamond": "\ud83d\udd36", "red_circle": "\ud83d\udd34", "ferris_wheel": "\ud83c\udfa1", "drooling_face": "\ud83e\udd24", "microscope": "\ud83d\udd2c", "middle_finger": "\ud83d\udd95", "pager": "\ud83d\udcdf", "pensive": "\ud83d\ude14", "potable_water": "\ud83d\udeb0", "abc": "\ud83d\udd24", "four_leaf_clover": "\ud83c\udf40", "vulcan": "\ud83d\udd96", "french_bread": "\ud83e\udd56", "motor_scooter": "\ud83d\udef5", "moneybag": "\ud83d\udcb0", "sparkles": "\u2728", "gloves": "\ud83e\udde4", "envelope_with_arrow": "\ud83d\udce9", "thumbsdown": "\ud83d\udc4e", "regional_indicator_g": "\ud83c\uddec", "video_game": "\ud83c\udfae", "on": "\ud83d\udd1b", "open_hands": "\ud83d\udc50", "monkey_face": "\ud83d\udc35", "mountain_railway": "\ud83d\ude9e", "bee": "\ud83d\udc1d", "scooter": "\ud83d\udef4", "fishing_pole_and_fish": "\ud83c\udfa3", "smiley_cat": "\ud83d\ude3a", "heart_eyes": "\ud83d\ude0d", "horse_racing": "\ud83c\udfc7", "ear": "\ud83d\udc42", "blue_circle": "\ud83d\udd35", "crossed_flags": "\ud83c\udf8c", "black_joker": "\ud83c\udccf", "six_pointed_star": "\ud83d\udd2f", "fountain": "\u26f2", "free": "\ud83c\udd93", "tennis": "\ud83c\udfbe", "yum": "\ud83d\ude0b", "fried_shrimp": "\ud83c\udf64", "dragon": "\ud83d\udc09", "purse": "\ud83d\udc5b", "clock1": "\ud83d\udd50", "airplane_arriving": "\ud83d\udeec", "cucumber": "\ud83e\udd52", "man_dancing": "\ud83d\udd7a", "clock730": "\ud83d\udd62", "deer": "\ud83e\udd8c", "meat_on_bone": "\ud83c\udf56", "bomb": "\ud83d\udca3", "night_with_stars": "\ud83c\udf03", "snake": "\ud83d\udc0d", "ramen": "\ud83c\udf5c", "end": "\ud83d\udd1a", "do_not_litter": "\ud83d\udeaf", "joy": "\ud83d\ude02", "light_rail": "\ud83d\ude88", "game_die": "\ud83c\udfb2", "violin": "\ud83c\udfbb", "tone2": "\ud83c\udffc", "tropical_drink": "\ud83c\udf79", "love_you_gesture": "\ud83e\udd1f", "cherries": "\ud83c\udf52", "traffic_light": "\ud83d\udea5", "iphone": "\ud83d\udcf1", "socks": "\ud83e\udde6", "wind_chime": "\ud83c\udf90", "no_entry_sign": "\ud83d\udeab", "elf": "\ud83e\udddd", "squid": "\ud83e\udd91", "person_pouting": "\ud83d\ude4e", "smile_cat": "\ud83d\ude38", "beers": "\ud83c\udf7b", "minidisc": "\ud83d\udcbd", "clock4": "\ud83d\udd53", "ice_cream": "\ud83c\udf68", "cocktail": "\ud83c\udf78", "clock3": "\ud83d\udd52", "frowning": "\ud83d\ude26", "hamburger": "\ud83c\udf54", "brain": "\ud83e\udde0", "heavy_division_sign": "\u2797", "tophat": "\ud83c\udfa9", "no_mouth": "\ud83d\ude36", "ski": "\ud83c\udfbf", "right_facing_fist": "\ud83e\udd1c", "mailbox_closed": "\ud83d\udcea", "chocolate_bar": "\ud83c\udf6b", "rabbit2": "\ud83d\udc07", "honey_pot": "\ud83c\udf6f", "izakaya_lantern": "\ud83c\udfee", "articulated_lorry": "\ud83d\ude9b", "face_with_hand_over_mouth": "\ud83e\udd2d", "japanese_ogre": "\ud83d\udc79", "zap": "\u26a1", "rocket": "\ud83d\ude80", "pizza": "\ud83c\udf55", "pound": "\ud83d\udcb7", "person_swimming": "\ud83c\udfca", "anchor": "\u2693", "coconut": "\ud83e\udd65", "sparkling_heart": "\ud83d\udc96", "older_man": "\ud83d\udc74", "mouse2": "\ud83d\udc01", "angry": "\ud83d\ude20", "up": "\ud83c\udd99", "gorilla": "\ud83e\udd8d", "children_crossing": "\ud83d\udeb8", "smirk_cat": "\ud83d\ude3c", "pregnant_woman": "\ud83e\udd30", "electric_plug": "\ud83d\udd0c", "dog2": "\ud83d\udc15", "question": "\u2753", "carousel_horse": "\ud83c\udfa0", "church": "\u26ea", "outbox_tray": "\ud83d\udce4", "cinema": "\ud83c\udfa6", "flushed": "\ud83d\ude33", "blush": "\ud83d\ude0a", "medal": "\ud83c\udfc5", "coffee": "\u2615", "gun": "\ud83d\udd2b", "city_dusk": "\ud83c\udf06", "watermelon": "\ud83c\udf49", "cricket_game": "\ud83c\udfcf", "shower": "\ud83d\udebf", "mute": "\ud83d\udd07", "breast_feeding": "\ud83e\udd31", "sweat_smile": "\ud83d\ude05", "construction_worker": "\ud83d\udc77", "cow2": "\ud83d\udc04", "arrows_counterclockwise": "\ud83d\udd04", "u6e80": "\ud83c\ude35", "grinning": "\ud83d\ude00", "globe_with_meridians": "\ud83c\udf10", "diamond_shape_with_a_dot_inside": "\ud83d\udca0", "deciduous_tree": "\ud83c\udf33", "shark": "\ud83e\udd88", "tram": "\ud83d\ude8a", "person_rowing_boat": "\ud83d\udea3", "chopsticks": "\ud83e\udd62", "black_heart": "\ud83d\udda4", "seat": "\ud83d\udcba", "kissing": "\ud83d\ude17", "laughing": "\ud83d\ude06", "slight_smile": "\ud83d\ude42", "radio": "\ud83d\udcfb", "arrow_up_small": "\ud83d\udd3c", "dango": "\ud83c\udf61", "rofl": "\ud83e\udd23", "see_no_evil": "\ud83d\ude48", "thermometer_face": "\ud83e\udd12", "hotdog": "\ud83c\udf2d", "virgo": "\u264d", "poultry_leg": "\ud83c\udf57", "hotel": "\ud83c\udfe8", "wolf": "\ud83d\udc3a", "curry": "\ud83c\udf5b", "regional_indicator_v": "\ud83c\uddfb", "crab": "\ud83e\udd80", "tired_face": "\ud83d\ude2b", "place_of_worship": "\ud83d\uded0", "ok_hand": "\ud83d\udc4c", "speech_balloon": "\ud83d\udcac", "sleepy": "\ud83d\ude2a", "earth_africa": "\ud83c\udf0d", "police_car": "\ud83d\ude93", "small_red_triangle_down": "\ud83d\udd3b", "bearded_person": "\ud83e\uddd4", "curling_stone": "\ud83e\udd4c", "scarf": "\ud83e\udde3", "fire": "\ud83d\udd25", "file_folder": "\ud83d\udcc1", "zipper_mouth": "\ud83e\udd10", "new_moon": "\ud83c\udf11", "regional_indicator_n": "\ud83c\uddf3", "negative_squared_cross_mark": "\u274e", "newspaper": "\ud83d\udcf0", "dvd": "\ud83d\udcc0", "pear": "\ud83c\udf50", "partly_sunny": "\u26c5", "black_square_button": "\ud83d\udd32", "low_brightness": "\ud83d\udd05", "sake": "\ud83c\udf76", "bow_and_arrow": "\ud83c\udff9", "cooking": "\ud83c\udf73", "fish_cake": "\ud83c\udf65", "tomato": "\ud83c\udf45", "couple_with_heart": "\ud83d\udc91", "telephone_receiver": "\ud83d\udcde", "triangular_flag_on_post": "\ud83d\udea9", "jack_o_lantern": "\ud83c\udf83", "blue_book": "\ud83d\udcd8", "clock530": "\ud83d\udd60", "u6709": "\ud83c\ude36", "palms_up_together": "\ud83e\udd32", "lion_face": "\ud83e\udd81", "lock": "\ud83d\udd12", "duck": "\ud83e\udd86", "truck": "\ud83d\ude9a", "oden": "\ud83c\udf62", "busts_in_silhouette": "\ud83d\udc65", "hourglass_flowing_sand": "\u23f3", "frog": "\ud83d\udc38", "fox": "\ud83e\udd8a", "bread": "\ud83c\udf5e", "put_litter_in_its_place": "\ud83d\udeae", "couple": "\ud83d\udc6b", "bamboo": "\ud83c\udf8d", "regional_indicator_c": "\ud83c\udde8", "menorah": "\ud83d\udd4e", "circus_tent": "\ud83c\udfaa", "lying_face": "\ud83e\udd25", "small_orange_diamond": "\ud83d\udd38", "ship": "\ud83d\udea2", "person_frowning": "\ud83d\ude4d", "racehorse": "\ud83d\udc0e", "thumbsup": "\ud83d\udc4d", "cupid": "\ud83d\udc98", "robot": "\ud83e\udd16", "fallen_leaf": "\ud83c\udf42", "pig_nose": "\ud83d\udc3d", "vibration_mode": "\ud83d\udcf3", "necktie": "\ud83d\udc54", "boy": "\ud83d\udc66", "house_with_garden": "\ud83c\udfe1", "point_down": "\ud83d\udc47", "grey_exclamation": "\u2755", "books": "\ud83d\udcda", "regional_indicator_k": "\ud83c\uddf0", "shirt": "\ud83d\udc55", "fries": "\ud83c\udf5f", "dart": "\ud83c\udfaf", "tea": "\ud83c\udf75", "mrs_claus": "\ud83e\udd36", "suspension_railway": "\ud83d\ude9f", "baby_symbol": "\ud83d\udebc", "sweet_potato": "\ud83c\udf60", "butterfly": "\ud83e\udd8b", "performing_arts": "\ud83c\udfad", "notebook": "\ud83d\udcd3", "bat": "\ud83e\udd87"}
\ No newline at end of file +{ + "womans_clothes": "\ud83d\udc5a", + "cookie": "\ud83c\udf6a", + "woman_with_headscarf": "\ud83e\uddd5", + "no_smoking": "\ud83d\udead", + "e-mail": "\ud83d\udce7", + "regional_indicator_d": "\ud83c\udde9", + "oncoming_bus": "\ud83d\ude8d", + "knife": "\ud83d\udd2a", + "person_getting_haircut": "\ud83d\udc87", + "grimacing": "\ud83d\ude2c", + "ophiuchus": "\u26ce", + "regional_indicator_q": "\ud83c\uddf6", + "thinking": "\ud83e\udd14", + "signal_strength": "\ud83d\udcf6", + "cactus": "\ud83c\udf35", + "bullettrain_front": "\ud83d\ude85", + "floppy_disk": "\ud83d\udcbe", + "doughnut": "\ud83c\udf69", + "tv": "\ud83d\udcfa", + "1234": "\ud83d\udd22", + "anguished": "\ud83d\ude27", + "clock1030": "\ud83d\udd65", + "u7533": "\ud83c\ude38", + "speak_no_evil": "\ud83d\ude4a", + "chart_with_upwards_trend": "\ud83d\udcc8", + "trophy": "\ud83c\udfc6", + "musical_score": "\ud83c\udfbc", + "chestnut": "\ud83c\udf30", + "clock1130": "\ud83d\udd66", + "abcd": "\ud83d\udd21", + "syringe": "\ud83d\udc89", + "shrimp": "\ud83e\udd90", + "pisces": "\u2653", + "left_facing_fist": "\ud83e\udd1b", + "bar_chart": "\ud83d\udcca", + "eagle": "\ud83e\udd85", + "woman": "\ud83d\udc69", + "keycap_ten": "\ud83d\udd1f", + "yellow_heart": "\ud83d\udc9b", + "croissant": "\ud83e\udd50", + "mosque": "\ud83d\udd4c", + "rice_ball": "\ud83c\udf59", + "volcano": "\ud83c\udf0b", + "baggage_claim": "\ud83d\udec4", + "family": "\ud83d\udc6a", + "beetle": "\ud83d\udc1e", + "older_adult": "\ud83e\uddd3", + "clock830": "\ud83d\udd63", + "bacon": "\ud83e\udd53", + "sound": "\ud83d\udd09", + "no_bicycles": "\ud83d\udeb3", + "rewind": "\u23ea", + "adult": "\ud83e\uddd1", + "scream_cat": "\ud83d\ude40", + "person_playing_water_polo": "\ud83e\udd3d", + "blue_car": "\ud83d\ude99", + "smiley": "\ud83d\ude03", + "kaaba": "\ud83d\udd4b", + "twisted_rightwards_arrows": "\ud83d\udd00", + "last_quarter_moon": "\ud83c\udf17", + "first_place": "\ud83e\udd47", + "joy_cat": "\ud83d\ude39", + "sleeping": "\ud83d\ude34", + "basketball": "\ud83c\udfc0", + "pray": "\ud83d\ude4f", + "trumpet": "\ud83c\udfba", + "purple_heart": "\ud83d\udc9c", + "broken_heart": "\ud83d\udc94", + "astonished": "\ud83d\ude32", + "soccer": "\u26bd", + "princess": "\ud83d\udc78", + "ant": "\ud83d\udc1c", + "pig": "\ud83d\udc37", + "vhs": "\ud83d\udcfc", + "scream": "\ud83d\ude31", + "mouse": "\ud83d\udc2d", + "field_hockey": "\ud83c\udfd1", + "ab": "\ud83c\udd8e", + "tokyo_tower": "\ud83d\uddfc", + "girl": "\ud83d\udc67", + "u55b6": "\ud83c\ude3a", + "guard": "\ud83d\udc82", + "regional_indicator_s": "\ud83c\uddf8", + "tulip": "\ud83c\udf37", + "capital_abcd": "\ud83d\udd20", + "beginner": "\ud83d\udd30", + "couplekiss": "\ud83d\udc8f", + "u5408": "\ud83c\ude34", + "black_medium_small_square": "\u25fe", + "paperclip": "\ud83d\udcce", + "hedgehog": "\ud83e\udd94", + "musical_note": "\ud83c\udfb5", + "pill": "\ud83d\udc8a", + "blue_heart": "\ud83d\udc99", + "mens": "\ud83d\udeb9", + "third_place": "\ud83e\udd49", + "stew": "\ud83c\udf72", + "prince": "\ud83e\udd34", + "mortar_board": "\ud83c\udf93", + "clock6": "\ud83d\udd55", + "beer": "\ud83c\udf7a", + "person_tipping_hand": "\ud83d\udc81", + "triangular_ruler": "\ud83d\udcd0", + "regional_indicator_y": "\ud83c\uddfe", + "person_facepalming": "\ud83e\udd26", + "steam_locomotive": "\ud83d\ude82", + "fire_engine": "\ud83d\ude92", + "horse": "\ud83d\udc34", + "ribbon": "\ud83c\udf80", + "white_large_square": "\u2b1c", + "smirk": "\ud83d\ude0f", + "genie": "\ud83e\uddde", + "tangerine": "\ud83c\udf4a", + "cl": "\ud83c\udd91", + "japanese_goblin": "\ud83d\udc7a", + "regional_indicator_u": "\ud83c\uddfa", + "ring": "\ud83d\udc8d", + "roller_coaster": "\ud83c\udfa2", + "100": "\ud83d\udcaf", + "clock12": "\ud83d\udd5b", + "two_hearts": "\ud83d\udc95", + "anger": "\ud83d\udca2", + "black_circle": "\u26ab", + "revolving_hearts": "\ud83d\udc9e", + "space_invader": "\ud83d\udc7e", + "bell": "\ud83d\udd14", + "point_up_2": "\ud83d\udc46", + "person_mountain_biking": "\ud83d\udeb5", + "flags": "\ud83c\udf8f", + "pushpin": "\ud83d\udccc", + "large_blue_diamond": "\ud83d\udd37", + "fairy": "\ud83e\uddda", + "european_post_office": "\ud83c\udfe4", + "statue_of_liberty": "\ud83d\uddfd", + "man": "\ud83d\udc68", + "microphone": "\ud83c\udfa4", + "inbox_tray": "\ud83d\udce5", + "bath": "\ud83d\udec0", + "person_gesturing_ok": "\ud83d\ude46", + "clap": "\ud83d\udc4f", + "confused": "\ud83d\ude15", + "fortune_cookie": "\ud83e\udd60", + "kissing_closed_eyes": "\ud83d\ude1a", + "kissing_heart": "\ud83d\ude18", + "tropical_fish": "\ud83d\udc20", + "taco": "\ud83c\udf2e", + "kimono": "\ud83d\udc58", + "u7a7a": "\ud83c\ude33", + "rat": "\ud83d\udc00", + "taurus": "\u2649", + "shopping_cart": "\ud83d\uded2", + "womans_hat": "\ud83d\udc52", + "blossom": "\ud83c\udf3c", + "moyai": "\ud83d\uddff", + "clock130": "\ud83d\udd5c", + "telescope": "\ud83d\udd2d", + "running_shirt_with_sash": "\ud83c\udfbd", + "person_running": "\ud83c\udfc3", + "dizzy": "\ud83d\udcab", + "crescent_moon": "\ud83c\udf19", + "boom": "\ud83d\udca5", + "restroom": "\ud83d\udebb", + "fist": "\u270a", + "white_flower": "\ud83d\udcae", + "clown": "\ud83e\udd21", + "neutral_face": "\ud83d\ude10", + "id": "\ud83c\udd94", + "carrot": "\ud83e\udd55", + "rice_scene": "\ud83c\udf91", + "foggy": "\ud83c\udf01", + "turtle": "\ud83d\udc22", + "mailbox_with_mail": "\ud83d\udcec", + "baseball": "\u26be", + "grin": "\ud83d\ude01", + "bathtub": "\ud83d\udec1", + "feet": "\ud83d\udc3e", + "small_red_triangle": "\ud83d\udd3a", + "camel": "\ud83d\udc2b", + "aquarius": "\u2652", + "face_with_symbols_over_mouth": "\ud83e\udd2c", + "handbag": "\ud83d\udc5c", + "date": "\ud83d\udcc5", + "nail_care": "\ud83d\udc85", + "satellite": "\ud83d\udce1", + "candy": "\ud83c\udf6c", + "white_medium_small_square": "\u25fd", + "clock930": "\ud83d\udd64", + "fearful": "\ud83d\ude28", + "fork_and_knife": "\ud83c\udf74", + "person_wearing_turban": "\ud83d\udc73", + "confounded": "\ud83d\ude16", + "helicopter": "\ud83d\ude81", + "arrow_double_down": "\u23ec", + "convenience_store": "\ud83c\udfea", + "ghost": "\ud83d\udc7b", + "bus": "\ud83d\ude8c", + "waning_gibbous_moon": "\ud83c\udf16", + "bank": "\ud83c\udfe6", + "department_store": "\ud83c\udfec", + "hockey": "\ud83c\udfd2", + "fingers_crossed": "\ud83e\udd1e", + "blond_haired_person": "\ud83d\udc71", + "mag": "\ud83d\udd0d", + "cut_of_meat": "\ud83e\udd69", + "wink": "\ud83d\ude09", + "railway_car": "\ud83d\ude83", + "face_vomiting": "\ud83e\udd2e", + "star_struck": "\ud83e\udd29", + "first_quarter_moon_with_face": "\ud83c\udf1b", + "octagonal_sign": "\ud83d\uded1", + "hospital": "\ud83c\udfe5", + "monkey": "\ud83d\udc12", + "curly_loop": "\u27b0", + "avocado": "\ud83e\udd51", + "earth_americas": "\ud83c\udf0e", + "flashlight": "\ud83d\udd26", + "8ball": "\ud83c\udfb1", + "clock630": "\ud83d\udd61", + "boar": "\ud83d\udc17", + "birthday": "\ud83c\udf82", + "crocodile": "\ud83d\udc0a", + "confetti_ball": "\ud83c\udf8a", + "door": "\ud83d\udeaa", + "school_satchel": "\ud83c\udf92", + "peanuts": "\ud83e\udd5c", + "regional_indicator_m": "\ud83c\uddf2", + "bust_in_silhouette": "\ud83d\udc64", + "sweat_drops": "\ud83d\udca6", + "tongue": "\ud83d\udc45", + "mag_right": "\ud83d\udd0e", + "t_rex": "\ud83e\udd96", + "post_office": "\ud83c\udfe3", + "shell": "\ud83d\udc1a", + "disappointed_relieved": "\ud83d\ude25", + "card_index": "\ud83d\udcc7", + "oncoming_automobile": "\ud83d\ude98", + "passport_control": "\ud83d\udec2", + "cherry_blossom": "\ud83c\udf38", + "shallow_pan_of_food": "\ud83e\udd58", + "heart": "\u2764\ufe0f", + "heartbeat": "\ud83d\udc93", + "crazy_face": "\ud83e\udd2a", + "grapes": "\ud83c\udf47", + "symbols": "\ud83d\udd23", + "gift": "\ud83c\udf81", + "scorpion": "\ud83e\udd82", + "wedding": "\ud83d\udc92", + "last_quarter_moon_with_face": "\ud83c\udf1c", + "love_letter": "\ud83d\udc8c", + "postal_horn": "\ud83d\udcef", + "stuffed_flatbread": "\ud83e\udd59", + "heavy_dollar_sign": "\ud83d\udcb2", + "love_hotel": "\ud83c\udfe9", + "yen": "\ud83d\udcb4", + "person_in_steamy_room": "\ud83e\uddd6", + "palm_tree": "\ud83c\udf34", + "name_badge": "\ud83d\udcdb", + "clock430": "\ud83d\udd5f", + "bike": "\ud83d\udeb2", + "snail": "\ud83d\udc0c", + "bowling": "\ud83c\udfb3", + "umbrella": "\u2614", + "sleeping_accommodation": "\ud83d\udecc", + "fireworks": "\ud83c\udf86", + "closed_book": "\ud83d\udcd5", + "city_sunset": "\ud83c\udf07", + "persevere": "\ud83d\ude23", + "bento": "\ud83c\udf71", + "nut_and_bolt": "\ud83d\udd29", + "page_facing_up": "\ud83d\udcc4", + "snowman": "\u26c4", + "two_women_holding_hands": "\ud83d\udc6d", + "regional_indicator_o": "\ud83c\uddf4", + "calling": "\ud83d\udcf2", + "person_shrugging": "\ud83e\udd37", + "sneezing_face": "\ud83e\udd27", + "arrows_clockwise": "\ud83d\udd03", + "no_pedestrians": "\ud83d\udeb7", + "potato": "\ud83e\udd54", + "cheese": "\ud83e\uddc0", + "full_moon": "\ud83c\udf15", + "mount_fuji": "\ud83d\uddfb", + "sob": "\ud83d\ude2d", + "construction": "\ud83d\udea7", + "head_bandage": "\ud83e\udd15", + "sailboat": "\u26f5", + "slight_frown": "\ud83d\ude41", + "ping_pong": "\ud83c\udfd3", + "hatched_chick": "\ud83d\udc25", + "sun_with_face": "\ud83c\udf1e", + "seedling": "\ud83c\udf31", + "repeat_one": "\ud83d\udd02", + "muscle": "\ud83d\udcaa", + "bridge_at_night": "\ud83c\udf09", + "raised_hands": "\ud83d\ude4c", + "house": "\ud83c\udfe0", + "nerd": "\ud83e\udd13", + "penguin": "\ud83d\udc27", + "peach": "\ud83c\udf51", + "dumpling": "\ud83e\udd5f", + "watch": "\u231a", + "womens": "\ud83d\udeba", + "round_pushpin": "\ud83d\udccd", + "alarm_clock": "\u23f0", + "relieved": "\ud83d\ude0c", + "sagittarius": "\u2650", + "busstop": "\ud83d\ude8f", + "regional_indicator_a": "\ud83c\udde6", + "sandal": "\ud83d\udc61", + "whale2": "\ud83d\udc0b", + "book": "\ud83d\udcd6", + "sweat": "\ud83d\ude13", + "movie_camera": "\ud83c\udfa5", + "clock230": "\ud83d\udd5d", + "tiger": "\ud83d\udc2f", + "tractor": "\ud83d\ude9c", + "smile": "\ud83d\ude04", + "vertical_traffic_light": "\ud83d\udea6", + "exploding_head": "\ud83e\udd2f", + "raised_hand": "\u270b", + "smoking": "\ud83d\udeac", + "page_with_curl": "\ud83d\udcc3", + "exclamation": "\u2757", + "fish": "\ud83d\udc1f", + "mans_shoe": "\ud83d\udc5e", + "sos": "\ud83c\udd98", + "unlock": "\ud83d\udd13", + "dolls": "\ud83c\udf8e", + "ear_of_rice": "\ud83c\udf3e", + "cat2": "\ud83d\udc08", + "u7121": "\ud83c\ude1a", + "repeat": "\ud83d\udd01", + "cool": "\ud83c\udd92", + "minibus": "\ud83d\ude90", + "aerial_tramway": "\ud83d\udea1", + "key": "\ud83d\udd11", + "child": "\ud83e\uddd2", + "camera": "\ud83d\udcf7", + "sunflower": "\ud83c\udf3b", + "white_check_mark": "\u2705", + "white_square_button": "\ud83d\udd33", + "banana": "\ud83c\udf4c", + "milky_way": "\ud83c\udf0c", + "person_gesturing_no": "\ud83d\ude45", + "sushi": "\ud83c\udf63", + "heart_eyes_cat": "\ud83d\ude3b", + "guitar": "\ud83c\udfb8", + "pie": "\ud83e\udd67", + "calendar": "\ud83d\udcc6", + "bear": "\ud83d\udc3b", + "person_in_lotus_position": "\ud83e\uddd8", + "clock10": "\ud83d\udd59", + "top": "\ud83d\udd1d", + "fuelpump": "\u26fd", + "rainbow": "\ud83c\udf08", + "snowboarder": "\ud83c\udfc2", + "drum": "\ud83e\udd41", + "leaves": "\ud83c\udf43", + "first_quarter_moon": "\ud83c\udf13", + "spoon": "\ud83e\udd44", + "pouting_cat": "\ud83d\ude3e", + "shaved_ice": "\ud83c\udf67", + "unamused": "\ud83d\ude12", + "train2": "\ud83d\ude86", + "clock1230": "\ud83d\udd67", + "regional_indicator_r": "\ud83c\uddf7", + "fast_forward": "\u23e9", + "accept": "\ud83c\ude51", + "hammer": "\ud83d\udd28", + "panda_face": "\ud83d\udc3c", + "briefcase": "\ud83d\udcbc", + "package": "\ud83d\udce6", + "flag_black": "\ud83c\udff4", + "smiling_imp": "\ud83d\ude08", + "sunrise_over_mountains": "\ud83c\udf04", + "airplane_departure": "\ud83d\udeeb", + "tiger2": "\ud83d\udc05", + "non-potable_water": "\ud83d\udeb1", + "bird": "\ud83d\udc26", + "barber": "\ud83d\udc88", + "cry": "\ud83d\ude22", + "billed_cap": "\ud83e\udde2", + "pouch": "\ud83d\udc5d", + "link": "\ud83d\udd17", + "zebra": "\ud83e\udd93", + "kiss": "\ud83d\udc8b", + "scorpius": "\u264f", + "prayer_beads": "\ud83d\udcff", + "high_brightness": "\ud83d\udd06", + "kissing_smiling_eyes": "\ud83d\ude19", + "rhino": "\ud83e\udd8f", + "left_luggage": "\ud83d\udec5", + "o": "\u2b55", + "crying_cat_face": "\ud83d\ude3f", + "clock8": "\ud83d\udd57", + "dress": "\ud83d\udc57", + "clock7": "\ud83d\udd56", + "bowl_with_spoon": "\ud83e\udd63", + "rolling_eyes": "\ud83d\ude44", + "fax": "\ud83d\udce0", + "worried": "\ud83d\ude1f", + "grey_question": "\u2754", + "saxophone": "\ud83c\udfb7", + "burrito": "\ud83c\udf2f", + "salad": "\ud83e\udd57", + "regional_indicator_z": "\ud83c\uddff", + "bikini": "\ud83d\udc59", + "milk": "\ud83e\udd5b", + "stars": "\ud83c\udf20", + "lips": "\ud83d\udc44", + "cd": "\ud83d\udcbf", + "weary": "\ud83d\ude29", + "face_with_raised_eyebrow": "\ud83e\udd28", + "lizard": "\ud83e\udd8e", + "tone1": "\ud83c\udffb", + "bullettrain_side": "\ud83d\ude84", + "nose": "\ud83d\udc43", + "innocent": "\ud83d\ude07", + "wilted_rose": "\ud83e\udd40", + "mahjong": "\ud83c\udc04", + "factory": "\ud83c\udfed", + "people_wrestling": "\ud83e\udd3c", + "mailbox": "\ud83d\udceb", + "rage": "\ud83d\ude21", + "wheelchair": "\u267f", + "x": "\u274c", + "flower_playing_cards": "\ud83c\udfb4", + "nauseated_face": "\ud83e\udd22", + "underage": "\ud83d\udd1e", + "ideograph_advantage": "\ud83c\ude50", + "high_heel": "\ud83d\udc60", + "dizzy_face": "\ud83d\ude35", + "stuck_out_tongue": "\ud83d\ude1b", + "mailbox_with_no_mail": "\ud83d\udced", + "orange_heart": "\ud83e\udde1", + "raised_back_of_hand": "\ud83e\udd1a", + "footprints": "\ud83d\udc63", + "notebook_with_decorative_cover": "\ud83d\udcd4", + "mask": "\ud83d\ude37", + "sunglasses": "\ud83d\ude0e", + "pancakes": "\ud83e\udd5e", + "regional_indicator_f": "\ud83c\uddeb", + "dog": "\ud83d\udc36", + "pig2": "\ud83d\udc16", + "ng": "\ud83c\udd96", + "unicorn": "\ud83e\udd84", + "triumph": "\ud83d\ude24", + "eggplant": "\ud83c\udf46", + "egg": "\ud83e\udd5a", + "office": "\ud83c\udfe2", + "goat": "\ud83d\udc10", + "handshake": "\ud83e\udd1d", + "star": "\u2b50", + "rugby_football": "\ud83c\udfc9", + "call_me": "\ud83e\udd19", + "rice_cracker": "\ud83c\udf58", + "droplet": "\ud83d\udca7", + "badminton": "\ud83c\udff8", + "waxing_crescent_moon": "\ud83c\udf12", + "ocean": "\ud83c\udf0a", + "slot_machine": "\ud83c\udfb0", + "wine_glass": "\ud83c\udf77", + "elephant": "\ud83d\udc18", + "blowfish": "\ud83d\udc21", + "ledger": "\ud83d\udcd2", + "money_mouth": "\ud83e\udd11", + "heart_decoration": "\ud83d\udc9f", + "arrow_down_small": "\ud83d\udd3d", + "station": "\ud83d\ude89", + "man_with_chinese_cap": "\ud83d\udc72", + "vampire": "\ud83e\udddb", + "pencil": "\ud83d\udcdd", + "cyclone": "\ud83c\udf00", + "mushroom": "\ud83c\udf44", + "sandwich": "\ud83e\udd6a", + "champagne": "\ud83c\udf7e", + "expressionless": "\ud83d\ude11", + "cold_sweat": "\ud83d\ude30", + "maple_leaf": "\ud83c\udf41", + "dromedary_camel": "\ud83d\udc2a", + "vs": "\ud83c\udd9a", + "person_fencing": "\ud83e\udd3a", + "straight_ruler": "\ud83d\udccf", + "baby_bottle": "\ud83c\udf7c", + "currency_exchange": "\ud83d\udcb1", + "regional_indicator_h": "\ud83c\udded", + "stuck_out_tongue_closed_eyes": "\ud83d\ude1d", + "closed_lock_with_key": "\ud83d\udd10", + "eyes": "\ud83d\udc40", + "water_buffalo": "\ud83d\udc03", + "lock_with_ink_pen": "\ud83d\udd0f", + "heavy_plus_sign": "\u2795", + "bookmark": "\ud83d\udd16", + "soon": "\ud83d\udd1c", + "orange_book": "\ud83d\udcd9", + "pineapple": "\ud83c\udf4d", + "clock9": "\ud83d\udd58", + "small_blue_diamond": "\ud83d\udd39", + "black_large_square": "\u2b1b", + "person_surfing": "\ud83c\udfc4", + "leo": "\u264c", + "merperson": "\ud83e\udddc", + "canoe": "\ud83d\udef6", + "rooster": "\ud83d\udc13", + "hear_no_evil": "\ud83d\ude49", + "corn": "\ud83c\udf3d", + "takeout_box": "\ud83e\udd61", + "oncoming_taxi": "\ud83d\ude96", + "taxi": "\ud83d\ude95", + "chart": "\ud83d\udcb9", + "goal": "\ud83e\udd45", + "melon": "\ud83c\udf48", + "notes": "\ud83c\udfb6", + "sparkler": "\ud83c\udf87", + "dolphin": "\ud83d\udc2c", + "speedboat": "\ud83d\udea4", + "cancer": "\u264b", + "sled": "\ud83d\udef7", + "tanabata_tree": "\ud83c\udf8b", + "train": "\ud83d\ude8b", + "christmas_tree": "\ud83c\udf84", + "two_men_holding_hands": "\ud83d\udc6c", + "back": "\ud83d\udd19", + "balloon": "\ud83c\udf88", + "checkered_flag": "\ud83c\udfc1", + "loop": "\u27bf", + "wc": "\ud83d\udebe", + "jeans": "\ud83d\udc56", + "green_apple": "\ud83c\udf4f", + "crown": "\ud83d\udc51", + "cowboy": "\ud83e\udd20", + "postbox": "\ud83d\udcee", + "volleyball": "\ud83c\udfd0", + "upside_down": "\ud83d\ude43", + "cricket": "\ud83e\udd97", + "custard": "\ud83c\udf6e", + "rose": "\ud83c\udf39", + "eyeglasses": "\ud83d\udc53", + "oncoming_police_car": "\ud83d\ude94", + "atm": "\ud83c\udfe7", + "flying_saucer": "\ud83d\udef8", + "alien": "\ud83d\udc7d", + "hamster": "\ud83d\udc39", + "trident": "\ud83d\udd31", + "disappointed": "\ud83d\ude1e", + "cow": "\ud83d\udc2e", + "police_officer": "\ud83d\udc6e", + "popcorn": "\ud83c\udf7f", + "baby_chick": "\ud83d\udc24", + "video_camera": "\ud83d\udcf9", + "zzz": "\ud83d\udca4", + "person_climbing": "\ud83e\uddd7", + "star2": "\ud83c\udf1f", + "ok": "\ud83c\udd97", + "capricorn": "\u2651", + "chicken": "\ud83d\udc14", + "arrow_double_up": "\u23eb", + "zombie": "\ud83e\udddf", + "closed_umbrella": "\ud83c\udf02", + "person_walking": "\ud83d\udeb6", + "lemon": "\ud83c\udf4b", + "heartpulse": "\ud83d\udc97", + "regional_indicator_i": "\ud83c\uddee", + "sauropod": "\ud83e\udd95", + "u7981": "\ud83c\ude32", + "regional_indicator_w": "\ud83c\uddfc", + "evergreen_tree": "\ud83c\udf32", + "mobile_phone_off": "\ud83d\udcf4", + "koko": "\ud83c\ude01", + "poop": "\ud83d\udca9", + "cup_with_straw": "\ud83e\udd64", + "leopard": "\ud83d\udc06", + "radio_button": "\ud83d\udd18", + "mega": "\ud83d\udce3", + "metal": "\ud83e\udd18", + "shushing_face": "\ud83e\udd2b", + "stuck_out_tongue_winking_eye": "\ud83d\ude1c", + "octopus": "\ud83d\udc19", + "boxing_glove": "\ud83e\udd4a", + "person_juggling": "\ud83e\udd39", + "money_with_wings": "\ud83d\udcb8", + "dollar": "\ud83d\udcb5", + "bride_with_veil": "\ud83d\udc70", + "second_place": "\ud83e\udd48", + "spaghetti": "\ud83c\udf5d", + "waning_crescent_moon": "\ud83c\udf18", + "football": "\ud83c\udfc8", + "white_circle": "\u26aa", + "full_moon_with_face": "\ud83c\udf1d", + "selfie": "\ud83e\udd33", + "tone3": "\ud83c\udffd", + "rabbit": "\ud83d\udc30", + "computer": "\ud83d\udcbb", + "clock11": "\ud83d\udd5a", + "heavy_minus_sign": "\u2796", + "synagogue": "\ud83d\udd4d", + "hourglass": "\u231b", + "gem": "\ud83d\udc8e", + "person_doing_cartwheel": "\ud83e\udd38", + "new_moon_with_face": "\ud83c\udf1a", + "sunrise": "\ud83c\udf05", + "regional_indicator_x": "\ud83c\uddfd", + "open_file_folder": "\ud83d\udcc2", + "gift_heart": "\ud83d\udc9d", + "tada": "\ud83c\udf89", + "green_heart": "\ud83d\udc9a", + "battery": "\ud83d\udd0b", + "regional_indicator_t": "\ud83c\uddf9", + "wrench": "\ud83d\udd27", + "aries": "\u2648", + "man_in_tuxedo": "\ud83e\udd35", + "regional_indicator_e": "\ud83c\uddea", + "regional_indicator_l": "\ud83c\uddf1", + "cake": "\ud83c\udf70", + "clapper": "\ud83c\udfac", + "japanese_castle": "\ud83c\udfef", + "crystal_ball": "\ud83d\udd2e", + "golf": "\u26f3", + "no_mobile_phones": "\ud83d\udcf5", + "person_biking": "\ud83d\udeb4", + "icecream": "\ud83c\udf66", + "mage": "\ud83e\uddd9", + "bookmark_tabs": "\ud83d\udcd1", + "tone4": "\ud83c\udffe", + "mountain_cableway": "\ud83d\udea0", + "person_playing_handball": "\ud83e\udd3e", + "bulb": "\ud83d\udca1", + "clock330": "\ud83d\udd5e", + "metro": "\ud83d\ude87", + "wave": "\ud83d\udc4b", + "whale": "\ud83d\udc33", + "strawberry": "\ud83c\udf53", + "hatching_chick": "\ud83d\udc23", + "trolleybus": "\ud83d\ude8e", + "lollipop": "\ud83c\udf6d", + "clipboard": "\ud83d\udccb", + "point_right": "\ud83d\udc49", + "u6307": "\ud83c\ude2f", + "santa": "\ud83c\udf85", + "hibiscus": "\ud83c\udf3a", + "green_book": "\ud83d\udcd7", + "skull": "\ud83d\udc80", + "tumbler_glass": "\ud83e\udd43", + "clock2": "\ud83d\udd51", + "open_mouth": "\ud83d\ude2e", + "bouquet": "\ud83d\udc90", + "champagne_glass": "\ud83e\udd42", + "poodle": "\ud83d\udc29", + "hushed": "\ud83d\ude2f", + "earth_asia": "\ud83c\udf0f", + "face_with_monocle": "\ud83e\uddd0", + "libra": "\u264e", + "clock5": "\ud83d\udd54", + "ambulance": "\ud83d\ude91", + "u5272": "\ud83c\ude39", + "lipstick": "\ud83d\udc84", + "apple": "\ud83c\udf4e", + "headphones": "\ud83c\udfa7", + "turkey": "\ud83e\udd83", + "pretzel": "\ud83e\udd68", + "bug": "\ud83d\udc1b", + "school": "\ud83c\udfeb", + "speaker": "\ud83d\udd08", + "boot": "\ud83d\udc62", + "cat": "\ud83d\udc31", + "dancer": "\ud83d\udc83", + "no_entry": "\u26d4", + "kissing_cat": "\ud83d\ude3d", + "art": "\ud83c\udfa8", + "coat": "\ud83e\udde5", + "credit_card": "\ud83d\udcb3", + "customs": "\ud83d\udec3", + "broccoli": "\ud83e\udd66", + "point_left": "\ud83d\udc48", + "canned_food": "\ud83e\udd6b", + "sheep": "\ud83d\udc11", + "person_bowing": "\ud83d\ude47", + "scroll": "\ud83d\udcdc", + "martial_arts_uniform": "\ud83e\udd4b", + "amphora": "\ud83c\udffa", + "thought_balloon": "\ud83d\udcad", + "no_bell": "\ud83d\udd15", + "musical_keyboard": "\ud83c\udfb9", + "people_with_bunny_ears_partying": "\ud83d\udc6f", + "european_castle": "\ud83c\udff0", + "punch": "\ud83d\udc4a", + "camera_with_flash": "\ud83d\udcf8", + "regional_indicator_p": "\ud83c\uddf5", + "red_car": "\ud83d\ude97", + "regional_indicator_j": "\ud83c\uddef", + "owl": "\ud83e\udd89", + "chart_with_downwards_trend": "\ud83d\udcc9", + "older_woman": "\ud83d\udc75", + "gemini": "\u264a", + "incoming_envelope": "\ud83d\udce8", + "waxing_gibbous_moon": "\ud83c\udf14", + "toilet": "\ud83d\udebd", + "dragon_face": "\ud83d\udc32", + "koala": "\ud83d\udc28", + "tone5": "\ud83c\udfff", + "kiwi": "\ud83e\udd5d", + "dash": "\ud83d\udca8", + "imp": "\ud83d\udc7f", + "tent": "\u26fa", + "regional_indicator_b": "\ud83c\udde7", + "monorail": "\ud83d\ude9d", + "ox": "\ud83d\udc02", + "giraffe": "\ud83e\udd92", + "new": "\ud83c\udd95", + "person_raising_hand": "\ud83d\ude4b", + "japan": "\ud83d\uddfe", + "rice": "\ud83c\udf5a", + "ticket": "\ud83c\udfab", + "rotating_light": "\ud83d\udea8", + "loudspeaker": "\ud83d\udce2", + "person_getting_massage": "\ud83d\udc86", + "loud_sound": "\ud83d\udd0a", + "hugging": "\ud83e\udd17", + "herb": "\ud83c\udf3f", + "baby": "\ud83d\udc76", + "angel": "\ud83d\udc7c", + "athletic_shoe": "\ud83d\udc5f", + "euro": "\ud83d\udcb6", + "ram": "\ud83d\udc0f", + "large_orange_diamond": "\ud83d\udd36", + "red_circle": "\ud83d\udd34", + "ferris_wheel": "\ud83c\udfa1", + "drooling_face": "\ud83e\udd24", + "microscope": "\ud83d\udd2c", + "middle_finger": "\ud83d\udd95", + "pager": "\ud83d\udcdf", + "pensive": "\ud83d\ude14", + "potable_water": "\ud83d\udeb0", + "abc": "\ud83d\udd24", + "four_leaf_clover": "\ud83c\udf40", + "vulcan": "\ud83d\udd96", + "french_bread": "\ud83e\udd56", + "motor_scooter": "\ud83d\udef5", + "moneybag": "\ud83d\udcb0", + "sparkles": "\u2728", + "gloves": "\ud83e\udde4", + "envelope_with_arrow": "\ud83d\udce9", + "thumbsdown": "\ud83d\udc4e", + "regional_indicator_g": "\ud83c\uddec", + "video_game": "\ud83c\udfae", + "on": "\ud83d\udd1b", + "open_hands": "\ud83d\udc50", + "monkey_face": "\ud83d\udc35", + "mountain_railway": "\ud83d\ude9e", + "bee": "\ud83d\udc1d", + "scooter": "\ud83d\udef4", + "fishing_pole_and_fish": "\ud83c\udfa3", + "smiley_cat": "\ud83d\ude3a", + "heart_eyes": "\ud83d\ude0d", + "horse_racing": "\ud83c\udfc7", + "ear": "\ud83d\udc42", + "blue_circle": "\ud83d\udd35", + "crossed_flags": "\ud83c\udf8c", + "black_joker": "\ud83c\udccf", + "six_pointed_star": "\ud83d\udd2f", + "fountain": "\u26f2", + "free": "\ud83c\udd93", + "tennis": "\ud83c\udfbe", + "yum": "\ud83d\ude0b", + "fried_shrimp": "\ud83c\udf64", + "dragon": "\ud83d\udc09", + "purse": "\ud83d\udc5b", + "clock1": "\ud83d\udd50", + "airplane_arriving": "\ud83d\udeec", + "cucumber": "\ud83e\udd52", + "man_dancing": "\ud83d\udd7a", + "clock730": "\ud83d\udd62", + "deer": "\ud83e\udd8c", + "meat_on_bone": "\ud83c\udf56", + "bomb": "\ud83d\udca3", + "night_with_stars": "\ud83c\udf03", + "snake": "\ud83d\udc0d", + "ramen": "\ud83c\udf5c", + "end": "\ud83d\udd1a", + "do_not_litter": "\ud83d\udeaf", + "joy": "\ud83d\ude02", + "light_rail": "\ud83d\ude88", + "game_die": "\ud83c\udfb2", + "violin": "\ud83c\udfbb", + "tone2": "\ud83c\udffc", + "tropical_drink": "\ud83c\udf79", + "love_you_gesture": "\ud83e\udd1f", + "cherries": "\ud83c\udf52", + "traffic_light": "\ud83d\udea5", + "iphone": "\ud83d\udcf1", + "socks": "\ud83e\udde6", + "wind_chime": "\ud83c\udf90", + "no_entry_sign": "\ud83d\udeab", + "elf": "\ud83e\udddd", + "squid": "\ud83e\udd91", + "person_pouting": "\ud83d\ude4e", + "smile_cat": "\ud83d\ude38", + "beers": "\ud83c\udf7b", + "minidisc": "\ud83d\udcbd", + "clock4": "\ud83d\udd53", + "ice_cream": "\ud83c\udf68", + "cocktail": "\ud83c\udf78", + "clock3": "\ud83d\udd52", + "frowning": "\ud83d\ude26", + "hamburger": "\ud83c\udf54", + "brain": "\ud83e\udde0", + "heavy_division_sign": "\u2797", + "tophat": "\ud83c\udfa9", + "no_mouth": "\ud83d\ude36", + "ski": "\ud83c\udfbf", + "right_facing_fist": "\ud83e\udd1c", + "mailbox_closed": "\ud83d\udcea", + "chocolate_bar": "\ud83c\udf6b", + "rabbit2": "\ud83d\udc07", + "honey_pot": "\ud83c\udf6f", + "izakaya_lantern": "\ud83c\udfee", + "articulated_lorry": "\ud83d\ude9b", + "face_with_hand_over_mouth": "\ud83e\udd2d", + "japanese_ogre": "\ud83d\udc79", + "zap": "\u26a1", + "rocket": "\ud83d\ude80", + "pizza": "\ud83c\udf55", + "pound": "\ud83d\udcb7", + "person_swimming": "\ud83c\udfca", + "anchor": "\u2693", + "coconut": "\ud83e\udd65", + "sparkling_heart": "\ud83d\udc96", + "older_man": "\ud83d\udc74", + "mouse2": "\ud83d\udc01", + "angry": "\ud83d\ude20", + "up": "\ud83c\udd99", + "gorilla": "\ud83e\udd8d", + "children_crossing": "\ud83d\udeb8", + "smirk_cat": "\ud83d\ude3c", + "pregnant_woman": "\ud83e\udd30", + "electric_plug": "\ud83d\udd0c", + "dog2": "\ud83d\udc15", + "question": "\u2753", + "carousel_horse": "\ud83c\udfa0", + "church": "\u26ea", + "outbox_tray": "\ud83d\udce4", + "cinema": "\ud83c\udfa6", + "flushed": "\ud83d\ude33", + "blush": "\ud83d\ude0a", + "medal": "\ud83c\udfc5", + "coffee": "\u2615", + "gun": "\ud83d\udd2b", + "city_dusk": "\ud83c\udf06", + "watermelon": "\ud83c\udf49", + "cricket_game": "\ud83c\udfcf", + "shower": "\ud83d\udebf", + "mute": "\ud83d\udd07", + "breast_feeding": "\ud83e\udd31", + "sweat_smile": "\ud83d\ude05", + "construction_worker": "\ud83d\udc77", + "cow2": "\ud83d\udc04", + "arrows_counterclockwise": "\ud83d\udd04", + "u6e80": "\ud83c\ude35", + "grinning": "\ud83d\ude00", + "globe_with_meridians": "\ud83c\udf10", + "diamond_shape_with_a_dot_inside": "\ud83d\udca0", + "deciduous_tree": "\ud83c\udf33", + "shark": "\ud83e\udd88", + "tram": "\ud83d\ude8a", + "person_rowing_boat": "\ud83d\udea3", + "chopsticks": "\ud83e\udd62", + "black_heart": "\ud83d\udda4", + "seat": "\ud83d\udcba", + "kissing": "\ud83d\ude17", + "laughing": "\ud83d\ude06", + "slight_smile": "\ud83d\ude42", + "radio": "\ud83d\udcfb", + "arrow_up_small": "\ud83d\udd3c", + "dango": "\ud83c\udf61", + "rofl": "\ud83e\udd23", + "see_no_evil": "\ud83d\ude48", + "thermometer_face": "\ud83e\udd12", + "hotdog": "\ud83c\udf2d", + "virgo": "\u264d", + "poultry_leg": "\ud83c\udf57", + "hotel": "\ud83c\udfe8", + "wolf": "\ud83d\udc3a", + "curry": "\ud83c\udf5b", + "regional_indicator_v": "\ud83c\uddfb", + "crab": "\ud83e\udd80", + "tired_face": "\ud83d\ude2b", + "place_of_worship": "\ud83d\uded0", + "ok_hand": "\ud83d\udc4c", + "speech_balloon": "\ud83d\udcac", + "sleepy": "\ud83d\ude2a", + "earth_africa": "\ud83c\udf0d", + "police_car": "\ud83d\ude93", + "small_red_triangle_down": "\ud83d\udd3b", + "bearded_person": "\ud83e\uddd4", + "curling_stone": "\ud83e\udd4c", + "scarf": "\ud83e\udde3", + "fire": "\ud83d\udd25", + "file_folder": "\ud83d\udcc1", + "zipper_mouth": "\ud83e\udd10", + "new_moon": "\ud83c\udf11", + "regional_indicator_n": "\ud83c\uddf3", + "negative_squared_cross_mark": "\u274e", + "newspaper": "\ud83d\udcf0", + "dvd": "\ud83d\udcc0", + "pear": "\ud83c\udf50", + "partly_sunny": "\u26c5", + "black_square_button": "\ud83d\udd32", + "low_brightness": "\ud83d\udd05", + "sake": "\ud83c\udf76", + "bow_and_arrow": "\ud83c\udff9", + "cooking": "\ud83c\udf73", + "fish_cake": "\ud83c\udf65", + "tomato": "\ud83c\udf45", + "couple_with_heart": "\ud83d\udc91", + "telephone_receiver": "\ud83d\udcde", + "triangular_flag_on_post": "\ud83d\udea9", + "jack_o_lantern": "\ud83c\udf83", + "blue_book": "\ud83d\udcd8", + "clock530": "\ud83d\udd60", + "u6709": "\ud83c\ude36", + "palms_up_together": "\ud83e\udd32", + "lion_face": "\ud83e\udd81", + "lock": "\ud83d\udd12", + "duck": "\ud83e\udd86", + "truck": "\ud83d\ude9a", + "oden": "\ud83c\udf62", + "busts_in_silhouette": "\ud83d\udc65", + "hourglass_flowing_sand": "\u23f3", + "frog": "\ud83d\udc38", + "fox": "\ud83e\udd8a", + "bread": "\ud83c\udf5e", + "put_litter_in_its_place": "\ud83d\udeae", + "couple": "\ud83d\udc6b", + "bamboo": "\ud83c\udf8d", + "regional_indicator_c": "\ud83c\udde8", + "menorah": "\ud83d\udd4e", + "circus_tent": "\ud83c\udfaa", + "lying_face": "\ud83e\udd25", + "small_orange_diamond": "\ud83d\udd38", + "ship": "\ud83d\udea2", + "person_frowning": "\ud83d\ude4d", + "racehorse": "\ud83d\udc0e", + "thumbsup": "\ud83d\udc4d", + "cupid": "\ud83d\udc98", + "robot": "\ud83e\udd16", + "fallen_leaf": "\ud83c\udf42", + "pig_nose": "\ud83d\udc3d", + "vibration_mode": "\ud83d\udcf3", + "necktie": "\ud83d\udc54", + "boy": "\ud83d\udc66", + "house_with_garden": "\ud83c\udfe1", + "point_down": "\ud83d\udc47", + "grey_exclamation": "\u2755", + "books": "\ud83d\udcda", + "regional_indicator_k": "\ud83c\uddf0", + "shirt": "\ud83d\udc55", + "fries": "\ud83c\udf5f", + "dart": "\ud83c\udfaf", + "tea": "\ud83c\udf75", + "mrs_claus": "\ud83e\udd36", + "suspension_railway": "\ud83d\ude9f", + "baby_symbol": "\ud83d\udebc", + "sweet_potato": "\ud83c\udf60", + "butterfly": "\ud83e\udd8b", + "performing_arts": "\ud83c\udfad", + "notebook": "\ud83d\udcd3", + "bat": "\ud83e\udd87" +} diff --git a/test/unit/specs/services/entity_normalizer/entity_normalizer.spec.js b/test/unit/specs/services/entity_normalizer/entity_normalizer.spec.js index 703fecf1..6245361c 100644 --- a/test/unit/specs/services/entity_normalizer/entity_normalizer.spec.js +++ b/test/unit/specs/services/entity_normalizer/entity_normalizer.spec.js @@ -241,7 +241,7 @@ describe('API Entities normalizer', () => { notice: makeMockStatusQvitter({ id: 444 }), from_profile: makeMockUserQvitter({ id: 'spurdo' }) }) - expect(parseNotification(notif)).to.have.property('id', '123') + expect(parseNotification(notif)).to.have.property('id', 123) expect(parseNotification(notif)).to.have.property('seen', false) expect(parseNotification(notif)).to.have.deep.property('status.id', '444') expect(parseNotification(notif)).to.have.deep.property('action.id', '444') @@ -259,7 +259,7 @@ describe('API Entities normalizer', () => { is_seen: 1, from_profile: makeMockUserQvitter({ id: 'spurdo' }) }) - expect(parseNotification(notif)).to.have.property('id', '123') + expect(parseNotification(notif)).to.have.property('id', 123) expect(parseNotification(notif)).to.have.property('type', 'like') expect(parseNotification(notif)).to.have.property('seen', true) expect(parseNotification(notif)).to.have.deep.property('status.id', '4412') diff --git a/test/unit/specs/services/mention_matcher/mention_matcher.spec.js b/test/unit/specs/services/matcher/matcher.spec.js index 4f6f58ff..7a2494f0 100644 --- a/test/unit/specs/services/mention_matcher/mention_matcher.spec.js +++ b/test/unit/specs/services/matcher/matcher.spec.js @@ -1,4 +1,4 @@ -import * as MentionMatcher from 'src/services/mention_matcher/mention_matcher.js' +import * as MatcherService from 'src/services/matcher/matcher.service.js' const localAttn = () => ({ id: 123, @@ -16,48 +16,67 @@ const externalAttn = () => ({ statusnet_profile_url: 'https://instance.com/users/person' }) -describe('MentionMatcher', () => { - describe.only('mentionMatchesUrl', () => { +describe('MatcherService', () => { + describe('mentionMatchesUrl', () => { it('should match local mention', () => { const attention = localAttn() const url = 'https://instance.com/users/person' - expect(MentionMatcher.mentionMatchesUrl(attention, url)).to.eql(true) + expect(MatcherService.mentionMatchesUrl(attention, url)).to.eql(true) }) it('should not match a local mention with same name but different instance', () => { const attention = localAttn() const url = 'https://website.com/users/person' - expect(MentionMatcher.mentionMatchesUrl(attention, url)).to.eql(false) + expect(MatcherService.mentionMatchesUrl(attention, url)).to.eql(false) }) it('should match external pleroma mention', () => { const attention = externalAttn() const url = 'https://instance.com/users/person' - expect(MentionMatcher.mentionMatchesUrl(attention, url)).to.eql(true) + expect(MatcherService.mentionMatchesUrl(attention, url)).to.eql(true) }) it('should not match external pleroma mention with same name but different instance', () => { const attention = externalAttn() const url = 'https://website.com/users/person' - expect(MentionMatcher.mentionMatchesUrl(attention, url)).to.eql(false) + expect(MatcherService.mentionMatchesUrl(attention, url)).to.eql(false) }) it('should match external mastodon mention', () => { const attention = externalAttn() const url = 'https://instance.com/@person' - expect(MentionMatcher.mentionMatchesUrl(attention, url)).to.eql(true) + expect(MatcherService.mentionMatchesUrl(attention, url)).to.eql(true) }) it('should not match external mastodon mention with same name but different instance', () => { const attention = externalAttn() const url = 'https://website.com/@person' - expect(MentionMatcher.mentionMatchesUrl(attention, url)).to.eql(false) + expect(MatcherService.mentionMatchesUrl(attention, url)).to.eql(false) + }) + }) + describe('extractTagFromUrl', () => { + it('should return tag name from valid pleroma url', () => { + const url = 'https://website.com/tag/photo' + + expect(MatcherService.extractTagFromUrl(url)).to.eql('photo') + }) + + it('should return tag name from valid mastodon url', () => { + const url = 'https://website.com/tags/sky' + + expect(MatcherService.extractTagFromUrl(url)).to.eql('sky') + }) + + it('should not return string but false if invalid url', () => { + const url = 'https://website.com/users/sky' + + expect(MatcherService.extractTagFromUrl(url)).to.eql(false) }) }) }) |
