logo

pleroma-fe

My custom branche(s) on git.pleroma.social/pleroma/pleroma-fe git clone https://hacktivis.me/git/pleroma-fe.git
commit: 5fa533fbb7b3d507c0136044fd51db033c25db05
parent 0331e42ee8576893dab2c6319ee5c0d62895b8d8
Author: Henry Jameson <me@hjkos.com>
Date:   Sun,  9 Oct 2022 18:51:42 +0300

Merge remote-tracking branch 'origin/develop' into scrolltotop

* origin/develop: (89 commits)
  Update dependency @vuelidate/validators to v2.0.0
  Remove lolex package
  Remove diff package
  Pin dependencies
  Update dependency sass to v1.55.0
  Make suggestor suggest according to cldr annotations
  Make chunks named
  Use import() for emoji.json
  Add regional indicators
  Support filtering by keywords from cldr
  Display localized unicode emoji names
  Load unicode emoji annotations
  Extract language list to its own file
  using the half-shit approach since proper approach is full-shit
  Make unicode emoji phrases match with _
  Use console.info
  Fix non-square emojis being truncated
  Fix emoji picker lint
  Fix emoji picker lint
  Tweak efficiency when changing filter keywords in emoji picker
  ...

Diffstat:

M.babelrc2+-
M.gitignore1+
MCONTRIBUTORS.md2++
Mbuild/build.js3+++
Mbuild/dev-server.js3+++
Abuild/update-emoji.js27+++++++++++++++++++++++++++
Mbuild/webpack.base.conf.js3++-
Mpackage.json20++++++++++----------
Msrc/App.js5+++++
Msrc/App.vue2++
Asrc/assets/pleromatan_apology_fox_mask.png0
Asrc/assets/pleromatan_apology_mask.png0
Msrc/boot/after_store.js1+
Msrc/components/account_actions/account_actions.js3+++
Msrc/components/account_actions/account_actions.vue7+++++++
Msrc/components/attachment/attachment.js3+++
Msrc/components/conversation/conversation.js16+++++++++++++++-
Asrc/components/edit_status_modal/edit_status_modal.js75+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/components/edit_status_modal/edit_status_modal.vue48++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/components/emoji_input/emoji_input.js50+++++++++++++++++++++++++++++++++++++++++++++++---
Msrc/components/emoji_input/emoji_input.vue3++-
Msrc/components/emoji_input/suggestor.js34+++++++++++++++++-----------------
Msrc/components/emoji_picker/emoji_picker.js302+++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------------
Msrc/components/emoji_picker/emoji_picker.scss52++++++++++++++++++++++++++++++++++++++++++++--------
Msrc/components/emoji_picker/emoji_picker.vue52++++++++++++++++++++++++++++++++++++++--------------
Msrc/components/extra_buttons/extra_buttons.js27++++++++++++++++++++++++++-
Msrc/components/extra_buttons/extra_buttons.vue22++++++++++++++++++++++
Msrc/components/follow_card/follow_card.js4+++-
Msrc/components/follow_card/follow_card.vue11+++++++++++
Msrc/components/nav_panel/nav_panel.vue7-------
Msrc/components/navigation/navigation_entry.js4++++
Msrc/components/navigation/navigation_entry.vue133+++++++++++++++++++++++++++++++++++++++++++------------------------------------
Asrc/components/optional_router_link/optional_router_link.vue23+++++++++++++++++++++++
Msrc/components/post_status_form/post_status_form.js54+++++++++++++++++++++++++++++++++++++++++-------------
Msrc/components/post_status_form/post_status_form.vue18++++++++++++++++++
Msrc/components/react_button/react_button.js4++--
Asrc/components/remove_follower_button/remove_follower_button.js25+++++++++++++++++++++++++
Asrc/components/remove_follower_button/remove_follower_button.vue13+++++++++++++
Msrc/components/settings_modal/tabs/profile_tab.js4++--
Msrc/components/status/status.js6++++++
Msrc/components/status/status.scss3++-
Msrc/components/status/status.vue18++++++++++++++++++
Asrc/components/status_history_modal/status_history_modal.js60++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/components/status_history_modal/status_history_modal.vue46++++++++++++++++++++++++++++++++++++++++++++++
Msrc/components/still-image/still-image.js27+++++++++++++++++++++++++--
Msrc/components/still-image/still-image.vue5+++--
Msrc/components/timeago/timeago.vue21+++++++++++++++++++--
Msrc/components/update_notification/update_notification.js25++++++++++++++-----------
Msrc/components/update_notification/update_notification.scss12+++++++++---
Msrc/components/update_notification/update_notification.vue7+++++--
Msrc/i18n/en.json23+++++++++++++++++++++--
Asrc/i18n/languages.js53+++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/i18n/messages.js46+++++++++++++---------------------------------
Msrc/main.js5+++++
Msrc/modules/api.js9++++++++-
Msrc/modules/config.js1+
Asrc/modules/editStatus.js25+++++++++++++++++++++++++
Msrc/modules/instance.js149++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
Asrc/modules/statusHistory.js25+++++++++++++++++++++++++
Msrc/modules/statuses.js9+++++++++
Msrc/modules/users.js8++++++++
Msrc/services/api/api.service.js94+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Msrc/services/entity_normalizer/entity_normalizer.service.js16++++++++++++++++
Msrc/services/status_poster/status_poster.service.js42++++++++++++++++++++++++++++++++++++++++++
Dstatic/emoji.json1432-------------------------------------------------------------------------------
Myarn.lock320+++++++++++++++++++++++++++++++++++++++++++++----------------------------------
66 files changed, 1670 insertions(+), 1880 deletions(-)

diff --git a/.babelrc b/.babelrc @@ -1,5 +1,5 @@ { "presets": ["@babel/preset-env"], "plugins": ["@babel/plugin-transform-runtime", "lodash", "@vue/babel-plugin-jsx"], - "comments": false + "comments": true } diff --git a/.gitignore b/.gitignore @@ -7,3 +7,4 @@ test/e2e/reports selenium-debug.log .idea/ config/local.json +static/emoji.json diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md @@ -10,3 +10,5 @@ Contributors of this project. - shpuld (shpuld@shitposter.club): CSS and styling - Vincent Guth (https://unsplash.com/photos/XrwVIFy6rTw): Background images. - hj (hj@shigusegubu.club): Code +- Sean King (seanking@freespeechextremist.com): Code +- Tusooa Zhu (tusooa@kazv.moe): Code diff --git a/build/build.js b/build/build.js @@ -18,6 +18,9 @@ console.log( var spinner = ora('building for production...') spinner.start() +var updateEmoji = require('./update-emoji').updateEmoji +updateEmoji() + var assetsPath = path.join(config.build.assetsRoot, config.build.assetsSubDirectory) rm('-rf', assetsPath) mkdir('-p', assetsPath) diff --git a/build/dev-server.js b/build/dev-server.js @@ -10,6 +10,9 @@ var webpackConfig = process.env.NODE_ENV === 'testing' ? require('./webpack.prod.conf') : require('./webpack.dev.conf') +var updateEmoji = require('./update-emoji').updateEmoji +updateEmoji() + // default port where dev server listens for incoming traffic var port = process.env.PORT || config.dev.port // Define HTTP proxies to your custom API backend diff --git a/build/update-emoji.js b/build/update-emoji.js @@ -0,0 +1,27 @@ + +module.exports = { + updateEmoji () { + const emojis = require('@kazvmoe-infra/unicode-emoji-json/data-by-group') + const fs = require('fs') + + Object.keys(emojis) + .map(k => { + emojis[k].map(e => { + delete e.unicode_version + delete e.emoji_version + delete e.skin_tone_support_unicode_version + }) + }) + + const res = {} + Object.keys(emojis) + .map(k => { + const groupId = k.replace('&', 'and').replace(/ /g, '-').toLowerCase() + res[groupId] = emojis[k] + }) + + console.info('Updating emojis...') + fs.writeFileSync('static/emoji.json', JSON.stringify(res)) + console.info('Done.') + } +} diff --git a/build/webpack.base.conf.js b/build/webpack.base.conf.js @@ -24,7 +24,8 @@ module.exports = { output: { path: config.build.assetsRoot, publicPath: process.env.NODE_ENV === 'production' ? config.build.assetsPublicPath : config.dev.assetsPublicPath, - filename: '[name].js' + filename: '[name].js', + chunkFilename: '[name].js' }, optimization: { splitChunks: { diff --git a/package.json b/package.json @@ -18,22 +18,23 @@ "dependencies": { "@babel/runtime": "7.18.9", "@chenfengyuan/vue-qrcode": "2.0.0", - "@fortawesome/fontawesome-svg-core": "6.1.2", - "@fortawesome/free-regular-svg-icons": "6.1.2", - "@fortawesome/free-solid-svg-icons": "6.1.2", + "@fortawesome/fontawesome-svg-core": "6.2.0", + "@fortawesome/free-regular-svg-icons": "6.2.0", + "@fortawesome/free-solid-svg-icons": "6.2.0", "@fortawesome/vue-fontawesome": "3.0.1", "@kazvmoe-infra/pinch-zoom-element": "1.2.0", + "@kazvmoe-infra/unicode-emoji-json": "0.4.0", "@ruffle-rs/ruffle": "0.1.0-nightly.2022.7.12", "@vuelidate/core": "2.0.0-alpha.44", - "@vuelidate/validators": "2.0.0-alpha.31", + "@vuelidate/validators": "2.0.0", "body-scroll-lock": "3.1.5", "chromatism": "3.0.0", "click-outside-vue3": "4.0.1", "cropperjs": "1.5.12", - "diff": "3.5.0", "escape-html": "1.0.3", "js-cookie": "3.0.1", "localforage": "1.10.0", + "lozad": "1.16.0", "parse-link-header": "2.0.0", "phoenix": "1.6.2", "punycode.js": "2.1.0", @@ -41,7 +42,7 @@ "querystring-es3": "0.2.1", "url": "0.11.0", "utf8": "3.0.0", - "vue": "3.2.37", + "vue": "3.2.38", "vue-i18n": "9.2.2", "vue-router": "4.1.5", "vue-template-compiler": "2.7.10", @@ -57,7 +58,7 @@ "@ungap/event-target": "0.2.3", "@vue/babel-helper-vue-jsx-merge-props": "1.4.0", "@vue/babel-plugin-jsx": "1.1.1", - "@vue/compiler-sfc": "3.2.37", + "@vue/compiler-sfc": "3.2.38", "@vue/test-utils": "2.0.2", "autoprefixer": "10.4.8", "babel-loader": "8.2.5", @@ -96,7 +97,6 @@ "karma-spec-reporter": "0.0.34", "karma-webpack": "5.0.0", "lodash": "4.17.21", - "lolex": "1.6.0", "mini-css-extract-plugin": "2.6.1", "mocha": "10.0.0", "nightwatch": "2.3.3", @@ -104,13 +104,13 @@ "ora": "0.4.1", "postcss": "8.4.16", "postcss-loader": "7.0.1", - "sass": "1.54.5", + "sass": "1.55.0", "sass-loader": "13.0.2", "selenium-server": "2.53.1", "semver": "7.3.7", "serviceworker-webpack5-plugin": "2.0.0", "shelljs": "0.8.5", - "sinon": "2.4.1", + "sinon": "14.0.0", "sinon-chai": "3.7.0", "stylelint": "13.13.1", "stylelint-config-standard": "20.0.0", diff --git a/src/App.js b/src/App.js @@ -10,7 +10,9 @@ import MobilePostStatusButton from './components/mobile_post_status_button/mobil import MobileNav from './components/mobile_nav/mobile_nav.vue' import DesktopNav from './components/desktop_nav/desktop_nav.vue' import UserReportingModal from './components/user_reporting_modal/user_reporting_modal.vue' +import EditStatusModal from './components/edit_status_modal/edit_status_modal.vue' import PostStatusModal from './components/post_status_modal/post_status_modal.vue' +import StatusHistoryModal from './components/status_history_modal/status_history_modal.vue' import GlobalNoticeList from './components/global_notice_list/global_notice_list.vue' import { windowWidth, windowHeight } from './services/window_utils/window_utils' import { mapGetters } from 'vuex' @@ -35,6 +37,8 @@ export default { UpdateNotification: defineAsyncComponent(() => import('./components/update_notification/update_notification.vue')), UserReportingModal, PostStatusModal, + EditStatusModal, + StatusHistoryModal, GlobalNoticeList }, data: () => ({ @@ -101,6 +105,7 @@ export default { return this.$store.getters.mergedConfig.alwaysShowNewPostButton || this.layoutType === 'mobile' }, showFeaturesPanel () { return this.$store.state.instance.showFeaturesPanel }, + editingAvailable () { return this.$store.state.instance.editingAvailable }, shoutboxPosition () { return this.$store.getters.mergedConfig.alwaysShowNewPostButton || false }, diff --git a/src/App.vue b/src/App.vue @@ -67,6 +67,8 @@ <MobilePostStatusButton /> <UserReportingModal /> <PostStatusModal /> + <EditStatusModal v-if="editingAvailable" /> + <StatusHistoryModal v-if="editingAvailable" /> <SettingsModal /> <UpdateNotification /> <div id="modal" /> diff --git a/src/assets/pleromatan_apology_fox_mask.png b/src/assets/pleromatan_apology_fox_mask.png Binary files differ. diff --git a/src/assets/pleromatan_apology_mask.png b/src/assets/pleromatan_apology_mask.png Binary files differ. diff --git a/src/boot/after_store.js b/src/boot/after_store.js @@ -251,6 +251,7 @@ const getNodeInfo = async ({ store }) => { store.dispatch('setInstanceOption', { name: 'pleromaChatMessagesAvailable', value: features.includes('pleroma_chat_messages') }) store.dispatch('setInstanceOption', { name: 'gopherAvailable', value: features.includes('gopher') }) store.dispatch('setInstanceOption', { name: 'pollsAvailable', value: features.includes('polls') }) + store.dispatch('setInstanceOption', { name: 'editingAvailable', value: features.includes('editing') }) store.dispatch('setInstanceOption', { name: 'pollLimits', value: metadata.pollLimits }) store.dispatch('setInstanceOption', { name: 'mailerEnabled', value: metadata.mailerEnabled }) diff --git a/src/components/account_actions/account_actions.js b/src/components/account_actions/account_actions.js @@ -36,6 +36,9 @@ const AccountActions = { unblockUser () { this.$store.dispatch('unblockUser', this.user.id) }, + removeUserFromFollowers () { + this.$store.dispatch('removeUserFromFollowers', this.user.id) + }, reportUser () { this.$store.dispatch('openUserReportingModal', { userId: this.user.id }) }, diff --git a/src/components/account_actions/account_actions.vue b/src/components/account_actions/account_actions.vue @@ -30,6 +30,13 @@ </template> <UserListMenu :user="user" /> <button + v-if="relationship.followed_by" + class="btn button-default btn-block dropdown-item" + @click="removeUserFromFollowers" + > + {{ $t('user_card.remove_follower') }} + </button> + <button v-if="relationship.blocking" class="btn button-default btn-block dropdown-item" @click="unblockUser" diff --git a/src/components/attachment/attachment.js b/src/components/attachment/attachment.js @@ -129,6 +129,9 @@ const Attachment = { ...mapGetters(['mergedConfig']) }, watch: { + 'attachment.description' (newVal) { + this.localDescription = newVal + }, localDescription (newVal) { this.onEdit(newVal) } diff --git a/src/components/conversation/conversation.js b/src/components/conversation/conversation.js @@ -1,6 +1,8 @@ import { reduce, filter, findIndex, clone, get } from 'lodash' import Status from '../status/status.vue' import ThreadTree from '../thread_tree/thread_tree.vue' +import { WSConnectionStatus } from '../../services/api/api.service.js' +import { mapGetters, mapState } from 'vuex' import QuickFilterSettings from '../quick_filter_settings/quick_filter_settings.vue' import QuickViewSettings from '../quick_view_settings/quick_view_settings.vue' @@ -79,6 +81,9 @@ const conversation = { const maxDepth = this.$store.getters.mergedConfig.maxDepthInThread - 2 return maxDepth >= 1 ? maxDepth : 1 }, + streamingEnabled () { + return this.mergedConfig.useStreamingApi && this.mastoUserSocketStatus === WSConnectionStatus.JOINED + }, displayStyle () { return this.$store.getters.mergedConfig.conversationDisplay }, @@ -341,7 +346,11 @@ const conversation = { }, maybeHighlight () { return this.isExpanded ? this.highlight : null - } + }, + ...mapGetters(['mergedConfig']), + ...mapState({ + mastoUserSocketStatus: state => state.api.mastoUserSocketStatus + }) }, components: { Status, @@ -399,6 +408,11 @@ const conversation = { setHighlight (id) { if (!id) return this.highlight = id + + if (!this.streamingEnabled) { + this.$store.dispatch('fetchStatus', id) + } + this.$store.dispatch('fetchFavsAndRepeats', id) this.$store.dispatch('fetchEmojiReactionsBy', id) }, diff --git a/src/components/edit_status_modal/edit_status_modal.js b/src/components/edit_status_modal/edit_status_modal.js @@ -0,0 +1,75 @@ +import PostStatusForm from '../post_status_form/post_status_form.vue' +import Modal from '../modal/modal.vue' +import statusPosterService from '../../services/status_poster/status_poster.service.js' +import get from 'lodash/get' + +const EditStatusModal = { + components: { + PostStatusForm, + Modal + }, + data () { + return { + resettingForm: false + } + }, + computed: { + isLoggedIn () { + return !!this.$store.state.users.currentUser + }, + modalActivated () { + return this.$store.state.editStatus.modalActivated + }, + isFormVisible () { + return this.isLoggedIn && !this.resettingForm && this.modalActivated + }, + params () { + return this.$store.state.editStatus.params || {} + } + }, + watch: { + params (newVal, oldVal) { + if (get(newVal, 'statusId') !== get(oldVal, 'statusId')) { + this.resettingForm = true + this.$nextTick(() => { + this.resettingForm = false + }) + } + }, + isFormVisible (val) { + if (val) { + this.$nextTick(() => this.$el && this.$el.querySelector('textarea').focus()) + } + } + }, + methods: { + doEditStatus ({ status, spoilerText, sensitive, media, contentType, poll }) { + const params = { + store: this.$store, + statusId: this.$store.state.editStatus.params.statusId, + status, + spoilerText, + sensitive, + poll, + media, + contentType + } + + return statusPosterService.editStatus(params) + .then((data) => { + return data + }) + .catch((err) => { + console.error('Error editing status', err) + return { + error: err.message + } + }) + }, + closeModal () { + this.$store.dispatch('closeEditStatusModal') + } + } +} + +export default EditStatusModal diff --git a/src/components/edit_status_modal/edit_status_modal.vue b/src/components/edit_status_modal/edit_status_modal.vue @@ -0,0 +1,48 @@ +<template> + <Modal + v-if="isFormVisible" + class="edit-form-modal-view" + @backdropClicked="closeModal" + > + <div class="edit-form-modal-panel panel"> + <div class="panel-heading"> + {{ $t('post_status.edit_status') }} + </div> + <PostStatusForm + class="panel-body" + v-bind="params" + :post-handler="doEditStatus" + :disable-polls="true" + :disable-visibility-selector="true" + @posted="closeModal" + /> + </div> + </Modal> +</template> + +<script src="./edit_status_modal.js"></script> + +<style lang="scss"> +.modal-view.edit-form-modal-view { + align-items: flex-start; +} +.edit-form-modal-panel { + flex-shrink: 0; + margin-top: 25%; + margin-bottom: 2em; + width: 100%; + max-width: 700px; + + @media (orientation: landscape) { + margin-top: 8%; + } + + .form-bottom-left { + max-width: 6.5em; + + .emoji-icon { + justify-content: right; + } + } +} +</style> diff --git a/src/components/emoji_input/emoji_input.js b/src/components/emoji_input/emoji_input.js @@ -3,7 +3,7 @@ import EmojiPicker from '../emoji_picker/emoji_picker.vue' import UnicodeDomainIndicator from '../unicode_domain_indicator/unicode_domain_indicator.vue' import { take } from 'lodash' import { findOffset } from '../../services/offset_finder/offset_finder.service.js' - +import { ensureFinalFallback } from '../../i18n/languages.js' import { library } from '@fortawesome/fontawesome-svg-core' import { faSmileBeam @@ -143,6 +143,51 @@ const EmojiInput = { const word = Completion.wordAtPosition(this.modelValue, this.caret - 1) || {} return word } + }, + languages () { + return ensureFinalFallback(this.$store.getters.mergedConfig.interfaceLanguage) + }, + maybeLocalizedEmojiNamesAndKeywords () { + return emoji => { + const names = [emoji.displayText] + const keywords = [] + + if (emoji.displayTextI18n) { + names.push(this.$t(emoji.displayTextI18n.key, emoji.displayTextI18n.args)) + } + + if (emoji.annotations) { + this.languages.forEach(lang => { + names.push(emoji.annotations[lang]?.name) + + keywords.push(...(emoji.annotations[lang]?.keywords || [])) + }) + } + + return { + names: names.filter(k => k), + keywords: keywords.filter(k => k) + } + } + }, + maybeLocalizedEmojiName () { + return emoji => { + if (!emoji.annotations) { + return emoji.displayText + } + + if (emoji.displayTextI18n) { + return this.$t(emoji.displayTextI18n.key, emoji.displayTextI18n.args) + } + + for (const lang of this.languages) { + if (emoji.annotations[lang]?.name) { + return emoji.annotations[lang].name + } + } + + return emoji.displayText + } } }, mounted () { @@ -181,7 +226,7 @@ const EmojiInput = { const firstchar = newWord.charAt(0) this.suggestions = [] if (newWord === firstchar) return - const matchedSuggestions = await this.suggest(newWord) + const matchedSuggestions = await this.suggest(newWord, this.maybeLocalizedEmojiNamesAndKeywords) // Async: cancel if textAtCaret has changed during wait if (this.textAtCaret !== newWord) return if (matchedSuggestions.length <= 0) return @@ -207,7 +252,6 @@ const EmojiInput = { }, triggerShowPicker () { this.showPicker = true - this.$refs.picker.startEmojiLoad() this.$nextTick(() => { this.scrollIntoView() this.focusPickerInput() diff --git a/src/components/emoji_input/emoji_input.vue b/src/components/emoji_input/emoji_input.vue @@ -19,6 +19,7 @@ v-if="enableEmojiPicker" ref="picker" :class="{ hide: !showPicker }" + :showing="showPicker" :enable-sticker-picker="enableStickerPicker" class="emoji-picker-panel" @emoji="insert" @@ -63,7 +64,7 @@ v-if="!suggestion.user" class="displayText" > - {{ suggestion.displayText }} + {{ maybeLocalizedEmojiName(suggestion) }} </span> <span class="detailText">{{ suggestion.detailText }}</span> </div> diff --git a/src/components/emoji_input/suggestor.js b/src/components/emoji_input/suggestor.js @@ -2,7 +2,7 @@ * suggest - generates a suggestor function to be used by emoji-input * data: object providing source information for specific types of suggestions: * data.emoji - optional, an array of all emoji available i.e. - * (state.instance.emoji + state.instance.customEmoji) + * (getters.standardEmojiList + state.instance.customEmoji) * data.users - optional, an array of all known users * updateUsersList - optional, a function to search and append to users * @@ -13,10 +13,10 @@ export default data => { const emojiCurry = suggestEmoji(data.emoji) const usersCurry = data.store && suggestUsers(data.store) - return input => { + return (input, nameKeywordLocalizer) => { const firstChar = input[0] if (firstChar === ':' && data.emoji) { - return emojiCurry(input) + return emojiCurry(input, nameKeywordLocalizer) } if (firstChar === '@' && usersCurry) { return usersCurry(input) @@ -25,34 +25,34 @@ export default data => { } } -export const suggestEmoji = emojis => input => { +export const suggestEmoji = emojis => (input, nameKeywordLocalizer) => { const noPrefix = input.toLowerCase().substr(1) return emojis - .filter(({ displayText }) => displayText.toLowerCase().match(noPrefix)) - .sort((a, b) => { - let aScore = 0 - let bScore = 0 + .map(emoji => ({ ...emoji, ...nameKeywordLocalizer(emoji) })) + .filter((emoji) => (emoji.names.concat(emoji.keywords)).filter(kw => kw.toLowerCase().match(noPrefix)).length) + .map(k => { + let score = 0 // An exact match always wins - aScore += a.displayText.toLowerCase() === noPrefix ? 200 : 0 - bScore += b.displayText.toLowerCase() === noPrefix ? 200 : 0 + score += Math.max(...k.names.map(name => name.toLowerCase() === noPrefix ? 200 : 0), 0) // Prioritize custom emoji a lot - aScore += a.imageUrl ? 100 : 0 - bScore += b.imageUrl ? 100 : 0 + score += k.imageUrl ? 100 : 0 // Prioritize prefix matches somewhat - aScore += a.displayText.toLowerCase().startsWith(noPrefix) ? 10 : 0 - bScore += b.displayText.toLowerCase().startsWith(noPrefix) ? 10 : 0 + score += Math.max(...k.names.map(kw => kw.toLowerCase().startsWith(noPrefix) ? 10 : 0), 0) // Sort by length - aScore -= a.displayText.length - bScore -= b.displayText.length + score -= k.displayText.length + k.score = score + return k + }) + .sort((a, b) => { // Break ties alphabetically const alphabetically = a.displayText > b.displayText ? 0.5 : -0.5 - return bScore - aScore + alphabetically + return b.score - a.score + alphabetically }) } diff --git a/src/components/emoji_picker/emoji_picker.js b/src/components/emoji_picker/emoji_picker.js @@ -1,33 +1,76 @@ import { defineAsyncComponent } from 'vue' import Checkbox from '../checkbox/checkbox.vue' +import StillImage from '../still-image/still-image.vue' +import { ensureFinalFallback } from '../../i18n/languages.js' +import lozad from 'lozad' import { library } from '@fortawesome/fontawesome-svg-core' import { faBoxOpen, faStickyNote, - faSmileBeam + faSmileBeam, + faSmile, + faUser, + faPaw, + faIceCream, + faBus, + faBasketballBall, + faLightbulb, + faCode, + faFlag } from '@fortawesome/free-solid-svg-icons' -import { trim } from 'lodash' +import { debounce, trim } from 'lodash' library.add( faBoxOpen, faStickyNote, - faSmileBeam + faSmileBeam, + faSmile, + faUser, + faPaw, + faIceCream, + faBus, + faBasketballBall, + faLightbulb, + faCode, + faFlag ) -// At widest, approximately 20 emoji are visible in a row, -// loading 3 rows, could be overkill for narrow picker -const LOAD_EMOJI_BY = 60 +const UNICODE_EMOJI_GROUP_ICON = { + 'smileys-and-emotion': 'smile', + 'people-and-body': 'user', + 'animals-and-nature': 'paw', + 'food-and-drink': 'ice-cream', + 'travel-and-places': 'bus', + activities: 'basketball-ball', + objects: 'lightbulb', + symbols: 'code', + flags: 'flag' +} -// When to start loading new batch emoji, in pixels -const LOAD_EMOJI_MARGIN = 64 +const maybeLocalizedKeywords = (emoji, languages, nameLocalizer) => { + const res = [emoji.displayText, nameLocalizer(emoji)] + if (emoji.annotations) { + languages.forEach(lang => { + const keywords = emoji.annotations[lang]?.keywords || [] + const name = emoji.annotations[lang]?.name + res.push(...(keywords.concat([name]).filter(k => k))) + }) + } + return res +} -const filterByKeyword = (list, keyword = '') => { +const filterByKeyword = (list, keyword = '', languages, nameLocalizer) => { if (keyword === '') return list const keywordLowercase = keyword.toLowerCase() const orderedEmojiList = [] for (const emoji of list) { - const indexOfKeyword = emoji.displayText.toLowerCase().indexOf(keywordLowercase) + const indices = maybeLocalizedKeywords(emoji, languages, nameLocalizer) + .map(k => k.toLowerCase().indexOf(keywordLowercase)) + .filter(k => k > -1) + + const indexOfKeyword = indices.length ? Math.min(...indices) : -1 + if (indexOfKeyword > -1) { if (!Array.isArray(orderedEmojiList[indexOfKeyword])) { orderedEmojiList[indexOfKeyword] = [] @@ -44,6 +87,10 @@ const EmojiPicker = { required: false, type: Boolean, default: false + }, + showing: { + required: true, + type: Boolean } }, data () { @@ -53,16 +100,26 @@ const EmojiPicker = { showingStickers: false, groupsScrolledClass: 'scrolled-top', keepOpen: false, - customEmojiBufferSlice: LOAD_EMOJI_BY, customEmojiTimeout: null, - customEmojiLoadAllConfirmed: false + // Lazy-load only after the first time `showing` becomes true. + contentLoaded: false, + groupRefs: {}, + emojiRefs: {}, + filteredEmojiGroups: [] } }, components: { StickerPicker: defineAsyncComponent(() => import('../sticker_picker/sticker_picker.vue')), - Checkbox + Checkbox, + StillImage }, methods: { + setGroupRef (name) { + return el => { this.groupRefs[name] = el } + }, + setEmojiRef (name) { + return el => { this.emojiRefs[name] = el } + }, onStickerUploaded (e) { this.$emit('sticker-uploaded', e) }, @@ -77,10 +134,38 @@ const EmojiPicker = { const target = (e && e.target) || this.$refs['emoji-groups'] this.updateScrolledClass(target) this.scrolledGroup(target) - this.triggerLoadMore(target) + }, + scrolledGroup (target) { + const top = target.scrollTop + 5 + this.$nextTick(() => { + this.allEmojiGroups.forEach(group => { + const ref = this.groupRefs['group-' + group.id] + if (ref && ref.offsetTop <= top) { + this.activeGroup = group.id + } + }) + this.scrollHeader() + }) + }, + scrollHeader () { + // Scroll the active tab's header into view + const headerRef = this.groupRefs['group-header-' + this.activeGroup] + const left = headerRef.offsetLeft + const right = left + headerRef.offsetWidth + const headerCont = this.$refs.header + const currentScroll = headerCont.scrollLeft + const currentScrollRight = currentScroll + headerCont.clientWidth + const setScroll = s => { headerCont.scrollLeft = s } + + const margin = 7 // .emoji-tabs-item: padding + if (left - margin < currentScroll) { + setScroll(left - margin) + } else if (right + margin > currentScrollRight) { + setScroll(right + margin - headerCont.clientWidth) + } }, highlight (key) { - const ref = this.$refs['group-' + key] + const ref = this.groupRefs['group-' + key] const top = ref.offsetTop this.setShowStickers(false) this.activeGroup = key @@ -97,73 +182,90 @@ const EmojiPicker = { this.groupsScrolledClass = 'scrolled-middle' } }, - triggerLoadMore (target) { - const ref = this.$refs['group-end-custom'] - if (!ref) return - const bottom = ref.offsetTop + ref.offsetHeight - - const scrollerBottom = target.scrollTop + target.clientHeight - const scrollerTop = target.scrollTop - const scrollerMax = target.scrollHeight - - // Loads more emoji when they come into view - const approachingBottom = bottom - scrollerBottom < LOAD_EMOJI_MARGIN - // Always load when at the very top in case there's no scroll space yet - const atTop = scrollerTop < 5 - // Don't load when looking at unicode category or at the very bottom - const bottomAboveViewport = bottom < scrollerTop || scrollerBottom === scrollerMax - if (!bottomAboveViewport && (approachingBottom || atTop)) { - this.loadEmoji() - } + toggleStickers () { + this.showingStickers = !this.showingStickers }, - scrolledGroup (target) { - const top = target.scrollTop + 5 + setShowStickers (value) { + this.showingStickers = value + }, + filterByKeyword (list, keyword) { + return filterByKeyword(list, keyword, this.languages, this.maybeLocalizedEmojiName) + }, + initializeLazyLoad () { + this.destroyLazyLoad() this.$nextTick(() => { - this.emojisView.forEach(group => { - const ref = this.$refs['group-' + group.id] - if (ref.offsetTop <= top) { - this.activeGroup = group.id + this.$lozad = lozad('.still-image.emoji-picker-emoji', { + load: el => { + const name = el.getAttribute('data-emoji-name') + const vn = this.emojiRefs[name] + if (!vn) { + return + } + + vn.loadLazy() } }) + this.$lozad.observe() }) }, - loadEmoji () { - const allLoaded = this.customEmojiBuffer.length === this.filteredEmoji.length - - if (allLoaded) { - return - } - - this.customEmojiBufferSlice += LOAD_EMOJI_BY + waitForDomAndInitializeLazyLoad () { + this.$nextTick(() => this.initializeLazyLoad()) }, - startEmojiLoad (forceUpdate = false) { - if (!forceUpdate) { - this.keyword = '' - } - this.$nextTick(() => { - this.$refs['emoji-groups'].scrollTop = 0 - }) - const bufferSize = this.customEmojiBuffer.length - const bufferPrefilledAll = bufferSize === this.filteredEmoji.length - if (bufferPrefilledAll && !forceUpdate) { - return + destroyLazyLoad () { + if (this.$lozad) { + if (this.$lozad.observer) { + this.$lozad.observer.disconnect() + } + if (this.$lozad.mutationObserver) { + this.$lozad.mutationObserver.disconnect() + } } - this.customEmojiBufferSlice = LOAD_EMOJI_BY }, - toggleStickers () { - this.showingStickers = !this.showingStickers + onShowing () { + const oldContentLoaded = this.contentLoaded + this.contentLoaded = true + this.waitForDomAndInitializeLazyLoad() + this.filteredEmojiGroups = this.getFilteredEmojiGroups() + if (!oldContentLoaded) { + this.$nextTick(() => { + if (this.defaultGroup) { + this.highlight(this.defaultGroup) + } + }) + } }, - setShowStickers (value) { - this.showingStickers = value + getFilteredEmojiGroups () { + return this.allEmojiGroups + .map(group => ({ + ...group, + emojis: this.filterByKeyword(group.emojis, trim(this.keyword)) + })) + .filter(group => group.emojis.length > 0) } }, watch: { keyword () { - this.customEmojiLoadAllConfirmed = false this.onScroll() - this.startEmojiLoad(true) + this.debouncedHandleKeywordChange() + }, + allCustomGroups () { + this.waitForDomAndInitializeLazyLoad() + this.filteredEmojiGroups = this.getFilteredEmojiGroups() + }, + showing (val) { + if (val) { + this.onShowing() + } } }, + mounted () { + if (this.showing) { + this.onShowing() + } + }, + destroyed () { + this.destroyLazyLoad() + }, computed: { activeGroupView () { return this.showingStickers ? '' : this.activeGroup @@ -174,39 +276,55 @@ const EmojiPicker = { } return 0 }, - filteredEmoji () { - return filterByKeyword( - this.$store.state.instance.customEmoji || [], - trim(this.keyword) - ) + allCustomGroups () { + return this.$store.getters.groupedCustomEmojis }, - customEmojiBuffer () { - return this.filteredEmoji.slice(0, this.customEmojiBufferSlice) + defaultGroup () { + return Object.keys(this.allCustomGroups)[0] }, - emojis () { - const standardEmojis = this.$store.state.instance.emoji || [] - const customEmojis = this.customEmojiBuffer - - return [ - { - id: 'custom', - text: this.$t('emoji.custom'), - icon: 'smile-beam', - emojis: customEmojis - }, - { - id: 'standard', - text: this.$t('emoji.unicode'), - icon: 'box-open', - emojis: filterByKeyword(standardEmojis, trim(this.keyword)) - } - ] + unicodeEmojiGroups () { + return this.$store.getters.standardEmojiGroupList.map(group => ({ + id: `standard-${group.id}`, + text: this.$t(`emoji.unicode_groups.${group.id}`), + icon: UNICODE_EMOJI_GROUP_ICON[group.id], + emojis: group.emojis + })) }, - emojisView () { - return this.emojis.filter(value => value.emojis.length > 0) + allEmojiGroups () { + return Object.entries(this.allCustomGroups) + .map(([_, v]) => v) + .concat(this.unicodeEmojiGroups) }, stickerPickerEnabled () { return (this.$store.state.instance.stickers || []).length !== 0 + }, + debouncedHandleKeywordChange () { + return debounce(() => { + this.waitForDomAndInitializeLazyLoad() + this.filteredEmojiGroups = this.getFilteredEmojiGroups() + }, 500) + }, + languages () { + return ensureFinalFallback(this.$store.getters.mergedConfig.interfaceLanguage) + }, + maybeLocalizedEmojiName () { + return emoji => { + if (!emoji.annotations) { + return emoji.displayText + } + + if (emoji.displayTextI18n) { + return this.$t(emoji.displayTextI18n.key, emoji.displayTextI18n.args) + } + + for (const lang of this.languages) { + if (emoji.annotations[lang]?.name) { + return emoji.annotations[lang].name + } + } + + return emoji.displayText + } } } } diff --git a/src/components/emoji_picker/emoji_picker.scss b/src/components/emoji_picker/emoji_picker.scss @@ -1,5 +1,10 @@ @import '../../_variables.scss'; +$emoji-picker-header-height: 36px; +$emoji-picker-header-picture-width: 32px; +$emoji-picker-header-picture-height: 32px; +$emoji-picker-emoji-size: 32px; + .emoji-picker { display: flex; flex-direction: column; @@ -19,6 +24,23 @@ --lightText: var(--popoverLightText, $fallback--lightText); --icon: var(--popoverIcon, $fallback--icon); + &-header-image { + display: inline-flex; + justify-content: center; + align-items: center; + width: $emoji-picker-header-picture-width; + max-width: $emoji-picker-header-picture-width; + height: $emoji-picker-header-picture-height; + max-height: $emoji-picker-header-picture-height; + .still-image { + max-width: 100%; + max-height: 100%; + height: 100%; + width: 100%; + object-fit: contain; + } + } + .keep-open, .too-many-emoji { padding: 7px; @@ -37,7 +59,6 @@ .heading { display: flex; - height: 32px; padding: 10px 7px 5px; } @@ -50,6 +71,10 @@ .emoji-tabs { flex-grow: 1; + display: flex; + flex-direction: row; + flex-wrap: nowrap; + overflow-x: auto; } .emoji-groups { @@ -57,6 +82,8 @@ } .additional-tabs { + display: flex; + flex: 1; border-left: 1px solid; border-left-color: $fallback--icon; border-left-color: var(--icon, $fallback--icon); @@ -66,15 +93,20 @@ .additional-tabs, .emoji-tabs { - display: block; - min-width: 0; flex-basis: auto; - flex-shrink: 1; + display: flex; + align-content: center; &-item { padding: 0 7px; cursor: pointer; font-size: 1.85em; + width: $emoji-picker-header-picture-width; + max-width: $emoji-picker-header-picture-width; + height: $emoji-picker-header-picture-height; + max-height: $emoji-picker-header-picture-height; + display: flex; + align-items: center; &.disabled { opacity: 0.5; @@ -164,22 +196,26 @@ } &-item { - width: 32px; - height: 32px; + width: $emoji-picker-emoji-size; + height: $emoji-picker-emoji-size; box-sizing: border-box; display: flex; - font-size: 32px; + line-height: $emoji-picker-emoji-size; align-items: center; justify-content: center; margin: 4px; cursor: pointer; - img { + .emoji-picker-emoji.-custom { object-fit: contain; max-width: 100%; max-height: 100%; } + .emoji-picker-emoji.-unicode { + font-size: 24px; + overflow: hidden; + } } } diff --git a/src/components/emoji_picker/emoji_picker.vue b/src/components/emoji_picker/emoji_picker.vue @@ -1,19 +1,34 @@ <template> - <div class="emoji-picker panel panel-default panel-body"> + <div + class="emoji-picker panel panel-default panel-body" + > <div class="heading"> - <span class="emoji-tabs"> + <span + ref="header" + class="emoji-tabs" + > <span - v-for="group in emojis" + v-for="group in filteredEmojiGroups" + :ref="setGroupRef('group-header-' + group.id)" :key="group.id" class="emoji-tabs-item" :class="{ - active: activeGroupView === group.id, - disabled: group.emojis.length === 0 + active: activeGroupView === group.id }" :title="group.text" @click.prevent="highlight(group.id)" > + <span + v-if="group.image" + class="emoji-picker-header-image" + > + <still-image + :alt="group.text" + :src="group.image" + /> + </span> <FAIcon + v-else :icon="group.icon" fixed-width /> @@ -36,7 +51,10 @@ </span> </span> </div> - <div class="content"> + <div + v-if="contentLoaded" + class="content" + > <div class="emoji-content" :class="{hidden: showingStickers}" @@ -57,12 +75,12 @@ @scroll="onScroll" > <div - v-for="group in emojisView" + v-for="group in filteredEmojiGroups" :key="group.id" class="emoji-group" > <h6 - :ref="'group-' + group.id" + :ref="setGroupRef('group-' + group.id)" class="emoji-group-title" > {{ group.text }} @@ -70,17 +88,23 @@ <span v-for="emoji in group.emojis" :key="group.id + emoji.displayText" - :title="emoji.displayText" + :title="maybeLocalizedEmojiName(emoji)" class="emoji-item" @click.stop.prevent="onEmoji(emoji)" > - <span v-if="!emoji.imageUrl">{{ emoji.replacement }}</span> - <img + <span + v-if="!emoji.imageUrl" + class="emoji-picker-emoji -unicode" + >{{ emoji.replacement }}</span> + <still-image v-else - :src="emoji.imageUrl" - > + :ref="setEmojiRef(group.id + emoji.displayText)" + class="emoji-picker-emoji -custom" + :data-src="emoji.imageUrl" + :data-emoji-name="group.id + emoji.displayText" + /> </span> - <span :ref="'group-end-' + group.id" /> + <span :ref="setGroupRef('group-end-' + group.id)" /> </div> </div> <div class="keep-open"> diff --git a/src/components/extra_buttons/extra_buttons.js b/src/components/extra_buttons/extra_buttons.js @@ -7,6 +7,7 @@ import { faThumbtack, faShareAlt, faExternalLinkAlt, + faHistory, faPlus, faTimes } from '@fortawesome/free-solid-svg-icons' @@ -24,6 +25,7 @@ library.add( faShareAlt, faExternalLinkAlt, faFlag, + faHistory, faPlus, faTimes ) @@ -86,6 +88,25 @@ const ExtraButtons = { }, reportStatus () { this.$store.dispatch('openUserReportingModal', { userId: this.status.user.id, statusIds: [this.status.id] }) + }, + editStatus () { + this.$store.dispatch('fetchStatusSource', { id: this.status.id }) + .then(data => this.$store.dispatch('openEditStatusModal', { + statusId: this.status.id, + subject: data.spoiler_text, + statusText: data.text, + statusIsSensitive: this.status.nsfw, + statusPoll: this.status.poll, + statusFiles: [...this.status.attachments], + visibility: this.status.visibility, + statusContentType: data.content_type + })) + }, + showStatusHistory () { + const originalStatus = { ...this.status } + const stripFieldsList = ['attachments', 'created_at', 'emojis', 'text', 'raw_html', 'nsfw', 'poll', 'summary', 'summary_raw_html'] + stripFieldsList.forEach(p => delete originalStatus[p]) + this.$store.dispatch('openStatusHistoryModal', originalStatus) } }, computed: { @@ -109,7 +130,11 @@ const ExtraButtons = { }, statusLink () { return `${this.$store.state.instance.server}${this.$router.resolve({ name: 'conversation', params: { id: this.status.id } }).href}` - } + }, + isEdited () { + return this.status.edited_at !== null + }, + editingAvailable () { return this.$store.state.instance.editingAvailable } } } diff --git a/src/components/extra_buttons/extra_buttons.vue b/src/components/extra_buttons/extra_buttons.vue @@ -78,6 +78,28 @@ </button> </template> <button + v-if="ownStatus && editingAvailable" + class="button-default dropdown-item dropdown-item-icon" + @click.prevent="editStatus" + @click="close" + > + <FAIcon + fixed-width + icon="pen" + /><span>{{ $t("status.edit") }}</span> + </button> + <button + v-if="isEdited && editingAvailable" + class="button-default dropdown-item dropdown-item-icon" + @click.prevent="showStatusHistory" + @click="close" + > + <FAIcon + fixed-width + icon="history" + /><span>{{ $t("status.status_history") }}</span> + </button> + <button v-if="canDelete" class="button-default dropdown-item dropdown-item-icon" @click.prevent="deleteStatus" diff --git a/src/components/follow_card/follow_card.js b/src/components/follow_card/follow_card.js @@ -1,6 +1,7 @@ import BasicUserCard from '../basic_user_card/basic_user_card.vue' import RemoteFollow from '../remote_follow/remote_follow.vue' import FollowButton from '../follow_button/follow_button.vue' +import RemoveFollowerButton from '../remove_follower_button/remove_follower_button.vue' const FollowCard = { props: [ @@ -10,7 +11,8 @@ const FollowCard = { components: { BasicUserCard, RemoteFollow, - FollowButton + FollowButton, + RemoveFollowerButton }, computed: { isMe () { diff --git a/src/components/follow_card/follow_card.vue b/src/components/follow_card/follow_card.vue @@ -22,6 +22,11 @@ class="follow-card-follow-button" :user="user" /> + <RemoveFollowerButton + v-if="noFollowsYou && relationship.followed_by" + :relationship="relationship" + class="follow-card-button" + /> </template> </div> </basic-user-card> @@ -40,6 +45,12 @@ line-height: 1.5em; } + &-button { + margin-top: 0.5em; + padding: 0 1.5em; + margin-left: 1em; + } + &-follow-button { margin-top: 0.5em; margin-left: auto; diff --git a/src/components/nav_panel/nav_panel.vue b/src/components/nav_panel/nav_panel.vue @@ -121,7 +121,6 @@ border-bottom: 1px solid; border-color: $fallback--border; border-color: var(--border, $fallback--border); - padding: 0; } > li { @@ -150,12 +149,6 @@ font-size: 1.1em; } - .menu-item { - .timelines-chevron { - margin-right: 0; - } - } - .timelines-background { padding: 0 0 0 0.6em; background-color: $fallback--lightBg; diff --git a/src/components/navigation/navigation_entry.js b/src/components/navigation/navigation_entry.js @@ -1,5 +1,6 @@ import { mapState } from 'vuex' import { USERNAME_ROUTES } from 'src/components/navigation/navigation.js' +import OptionalRouterLink from 'src/components/optional_router_link/optional_router_link.vue' import { library } from '@fortawesome/fontawesome-svg-core' import { faThumbtack } from '@fortawesome/free-solid-svg-icons' @@ -7,6 +8,9 @@ library.add(faThumbtack) const NavigationEntry = { props: ['item', 'showPin'], + components: { + OptionalRouterLink + }, methods: { isPinned (value) { return this.pinnedItems.has(value) diff --git a/src/components/navigation/navigation_entry.vue b/src/components/navigation/navigation_entry.vue @@ -1,26 +1,37 @@ <template> - <li class="NavigationEntry"> - <component - :is="routeTo ? 'router-link' : 'button'" - class="menu-item button-unstyled" - :to="routeTo" + <OptionalRouterLink + v-slot="{ isActive, href, navigate } = {}" + ass="ass" + :to="routeTo" + > + <li + class="NavigationEntry menu-item" + :class="{ '-active': isActive }" + v-bind="$attrs" > - <span> - <FAIcon - v-if="item.icon" - fixed-width - class="fa-scale-110 menu-icon" - :icon="item.icon" - /> - </span> - <span - v-if="item.iconLetter" - class="icon iconLetter fa-scale-110 menu-icon" - >{{ item.iconLetter }} - </span> - <span class="label"> - {{ item.labelRaw || $t(item.label) }} - </span> + <component + :is="routeTo ? 'a' : 'button'" + class="main-link button-unstyled" + :href="href" + @click="navigate" + > + <span> + <FAIcon + v-if="item.icon" + fixed-width + class="fa-scale-110 menu-icon" + :icon="item.icon" + /> + </span> + <span + v-if="item.iconLetter" + class="icon iconLetter fa-scale-110 menu-icon" + >{{ item.iconLetter }} + </span> + <span class="label"> + {{ item.labelRaw || $t(item.label) }} + </span> + </component> <slot /> <div v-if="item.badgeGetter && getters[item.badgeGetter]" @@ -45,8 +56,8 @@ icon="thumbtack" /> </button> - </component> - </li> + </li> + </OptionalRouterLink> </template> <script src="./navigation_entry.js"></script> @@ -55,7 +66,21 @@ @import '../../_variables.scss'; .NavigationEntry { - .label { + display: flex; + box-sizing: border-box; + align-items: baseline; + height: 3.5em; + line-height: 3.5em; + padding: 0 1em; + width: 100%; + color: $fallback--link; + color: var(--link, $fallback--link); + + .timelines-chevron { + margin-right: 0; + } + + .main-link { flex: 1; } @@ -72,48 +97,36 @@ } } - .menu-item { - display: flex; - box-sizing: border-box; - align-items: baseline; - height: 3.5em; - line-height: 3.5em; - padding: 0 1em; - width: 100%; + &:hover { + background-color: $fallback--lightBg; + background-color: var(--selectedMenu, $fallback--lightBg); color: $fallback--link; - color: var(--link, $fallback--link); - - &:hover { - background-color: $fallback--lightBg; - background-color: var(--selectedMenu, $fallback--lightBg); - color: $fallback--link; - color: var(--selectedMenuText, $fallback--link); - --faint: var(--selectedMenuFaintText, $fallback--faint); - --faintLink: var(--selectedMenuFaintLink, $fallback--faint); - --lightText: var(--selectedMenuLightText, $fallback--lightText); + color: var(--selectedMenuText, $fallback--link); + --faint: var(--selectedMenuFaintText, $fallback--faint); + --faintLink: var(--selectedMenuFaintLink, $fallback--faint); + --lightText: var(--selectedMenuLightText, $fallback--lightText); - .menu-icon { - --icon: var(--text, $fallback--icon); - } + .menu-icon { + --icon: var(--text, $fallback--icon); } + } - &.router-link-active { - font-weight: bolder; - background-color: $fallback--lightBg; - background-color: var(--selectedMenu, $fallback--lightBg); - color: $fallback--text; - color: var(--selectedMenuText, $fallback--text); - --faint: var(--selectedMenuFaintText, $fallback--faint); - --faintLink: var(--selectedMenuFaintLink, $fallback--faint); - --lightText: var(--selectedMenuLightText, $fallback--lightText); + &.-active { + font-weight: bolder; + background-color: $fallback--lightBg; + background-color: var(--selectedMenu, $fallback--lightBg); + color: $fallback--text; + color: var(--selectedMenuText, $fallback--text); + --faint: var(--selectedMenuFaintText, $fallback--faint); + --faintLink: var(--selectedMenuFaintLink, $fallback--faint); + --lightText: var(--selectedMenuLightText, $fallback--lightText); - .menu-icon { - --icon: var(--text, $fallback--icon); - } + .menu-icon { + --icon: var(--text, $fallback--icon); + } - &:hover { - text-decoration: underline; - } + &:hover { + text-decoration: underline; } } } diff --git a/src/components/optional_router_link/optional_router_link.vue b/src/components/optional_router_link/optional_router_link.vue @@ -0,0 +1,23 @@ +<template> + <!-- eslint-disable vue/no-multiple-template-root --> + <router-link + v-if="to" + v-slot="props" + :to="to" + custom + > + <slot + v-bind="props" + /> + </router-link> + <slot + v-else + v-bind="{}" + /> +</template> + +<script> +export default { + props: ['to'] +} +</script> diff --git a/src/components/post_status_form/post_status_form.js b/src/components/post_status_form/post_status_form.js @@ -55,6 +55,14 @@ const pxStringToNumber = (str) => { const PostStatusForm = { props: [ + 'statusId', + 'statusText', + 'statusIsSensitive', + 'statusPoll', + 'statusFiles', + 'statusMediaDescriptions', + 'statusScope', + 'statusContentType', 'replyTo', 'repliedUser', 'attentions', @@ -62,6 +70,7 @@ const PostStatusForm = { 'subject', 'disableSubject', 'disableScopeSelector', + 'disableVisibilitySelector', 'disableNotice', 'disableLockWarning', 'disablePolls', @@ -125,22 +134,38 @@ const PostStatusForm = { const { postContentType: contentType, sensitiveByDefault } = this.$store.getters.mergedConfig + let statusParams = { + spoilerText: this.subject || '', + status: statusText, + nsfw: !!sensitiveByDefault, + files: [], + poll: {}, + mediaDescriptions: {}, + visibility: scope, + contentType + } + + if (this.statusId) { + const statusContentType = this.statusContentType || contentType + statusParams = { + spoilerText: this.subject || '', + status: this.statusText || '', + nsfw: this.statusIsSensitive || !!sensitiveByDefault, + files: this.statusFiles || [], + poll: this.statusPoll || {}, + mediaDescriptions: this.statusMediaDescriptions || {}, + visibility: this.statusScope || scope, + contentType: statusContentType + } + } + return { dropFiles: [], uploadingFiles: false, error: null, posting: false, highlighted: 0, - newStatus: { - spoilerText: this.subject || '', - status: statusText, - nsfw: !!sensitiveByDefault, - files: [], - poll: {}, - mediaDescriptions: {}, - visibility: scope, - contentType - }, + newStatus: statusParams, caret: 0, pollFormVisible: false, showDropIcon: 'hide', @@ -164,7 +189,7 @@ const PostStatusForm = { emojiUserSuggestor () { return suggestor({ emoji: [ - ...this.$store.state.instance.emoji, + ...this.$store.getters.standardEmojiList, ...this.$store.state.instance.customEmoji ], store: this.$store @@ -173,13 +198,13 @@ const PostStatusForm = { emojiSuggestor () { return suggestor({ emoji: [ - ...this.$store.state.instance.emoji, + ...this.$store.getters.standardEmojiList, ...this.$store.state.instance.customEmoji ] }) }, emoji () { - return this.$store.state.instance.emoji || [] + return this.$store.getters.standardEmojiList || [] }, customEmoji () { return this.$store.state.instance.customEmoji || [] @@ -236,6 +261,9 @@ const PostStatusForm = { uploadFileLimitReached () { return this.newStatus.files.length >= this.fileLimit }, + isEdit () { + return typeof this.statusId !== 'undefined' && this.statusId.trim() !== '' + }, ...mapGetters(['mergedConfig']), ...mapState({ mobileLayout: state => state.interface.mobileLayout diff --git a/src/components/post_status_form/post_status_form.vue b/src/components/post_status_form/post_status_form.vue @@ -67,6 +67,13 @@ <span v-else>{{ $t('post_status.direct_warning_to_all') }}</span> </p> <div + v-if="isEdit" + class="visibility-notice edit-warning" + > + <p>{{ $t('post_status.edit_remote_warning') }}</p> + <p>{{ $t('post_status.edit_unsupported_warning') }}</p> + </div> + <div v-if="!disablePreview" class="preview-heading faint" > @@ -170,6 +177,7 @@ class="visibility-tray" > <scope-selector + v-if="!disableVisibilitySelector" :show-all="showAllScopes" :user-default="userDefaultScope" :original-scope="copyMessageScope" @@ -410,6 +418,16 @@ align-items: baseline; } + .visibility-notice.edit-warning { + > :first-child { + margin-top: 0; + } + + > :last-child { + margin-bottom: 0; + } + } + .media-upload-icon, .poll-icon, .emoji-icon { font-size: 1.85em; line-height: 1.1; diff --git a/src/components/react_button/react_button.js b/src/components/react_button/react_button.js @@ -59,7 +59,7 @@ const ReactButton = { if (this.filterWord !== '') { const filterWordLowercase = trim(this.filterWord.toLowerCase()) const orderedEmojiList = [] - for (const emoji of this.$store.state.instance.emoji) { + for (const emoji of this.$store.getters.standardEmojiList) { if (emoji.replacement === this.filterWord) return [emoji] const indexOfFilterWord = emoji.displayText.toLowerCase().indexOf(filterWordLowercase) @@ -72,7 +72,7 @@ const ReactButton = { } return orderedEmojiList.flat() } - return this.$store.state.instance.emoji || [] + return this.$store.getters.standardEmojiList || [] }, mergedConfig () { return this.$store.getters.mergedConfig diff --git a/src/components/remove_follower_button/remove_follower_button.js b/src/components/remove_follower_button/remove_follower_button.js @@ -0,0 +1,25 @@ +export default { + props: ['relationship'], + data () { + return { + inProgress: false + } + }, + computed: { + label () { + if (this.inProgress) { + return this.$t('user_card.follow_progress') + } else { + return this.$t('user_card.remove_follower') + } + } + }, + methods: { + onClick () { + this.inProgress = true + this.$store.dispatch('removeUserFromFollowers', this.relationship.id).then(() => { + this.inProgress = false + }) + } + } +} diff --git a/src/components/remove_follower_button/remove_follower_button.vue b/src/components/remove_follower_button/remove_follower_button.vue @@ -0,0 +1,13 @@ +<template> + <button + class="btn button-default follow-button" + :class="{ toggled: inProgress }" + :disabled="inProgress" + :title="$t('user_card.remove_follower')" + @click="onClick" + > + {{ label }} + </button> +</template> + +<script src="./remove_follower_button.js"></script> diff --git a/src/components/settings_modal/tabs/profile_tab.js b/src/components/settings_modal/tabs/profile_tab.js @@ -64,7 +64,7 @@ const ProfileTab = { emojiUserSuggestor () { return suggestor({ emoji: [ - ...this.$store.state.instance.emoji, + ...this.$store.getters.standardEmojiList, ...this.$store.state.instance.customEmoji ], store: this.$store @@ -73,7 +73,7 @@ const ProfileTab = { emojiSuggestor () { return suggestor({ emoji: [ - ...this.$store.state.instance.emoji, + ...this.$store.getters.standardEmojiList, ...this.$store.state.instance.customEmoji ] }) diff --git a/src/components/status/status.js b/src/components/status/status.js @@ -395,6 +395,12 @@ const Status = { }, visibilityLocalized () { return this.$i18n.t('general.scope_in_timeline.' + this.status.visibility) + }, + isEdited () { + return this.status.edited_at !== null + }, + editingAvailable () { + return this.$store.state.instance.editingAvailable } }, methods: { diff --git a/src/components/status/status.scss b/src/components/status/status.scss @@ -156,7 +156,8 @@ margin-right: 0.2em; } - & .heading-reply-row { + & .heading-reply-row, + & .heading-edited-row { position: relative; align-content: baseline; font-size: 0.85em; diff --git a/src/components/status/status.vue b/src/components/status/status.vue @@ -327,6 +327,24 @@ class="mentions-line" /> </div> + <div + v-if="isEdited && editingAvailable && !isPreview" + class="heading-edited-row" + > + <i18n-t + keypath="status.edited_at" + tag="span" + > + <template #time> + <Timeago + template-key="time.in_past" + :time="status.edited_at" + :auto-update="60" + :long-format="true" + /> + </template> + </i18n-t> + </div> </div> <StatusContent diff --git a/src/components/status_history_modal/status_history_modal.js b/src/components/status_history_modal/status_history_modal.js @@ -0,0 +1,60 @@ +import { get } from 'lodash' +import Modal from '../modal/modal.vue' +import Status from '../status/status.vue' + +const StatusHistoryModal = { + components: { + Modal, + Status + }, + data () { + return { + statuses: [] + } + }, + computed: { + modalActivated () { + return this.$store.state.statusHistory.modalActivated + }, + params () { + return this.$store.state.statusHistory.params + }, + statusId () { + return this.params.id + }, + historyCount () { + return this.statuses.length + }, + history () { + return this.statuses + } + }, + watch: { + params (newVal, oldVal) { + const newStatusId = get(newVal, 'id') !== get(oldVal, 'id') + if (newStatusId) { + this.resetHistory() + } + + if (newStatusId || get(newVal, 'edited_at') !== get(oldVal, 'edited_at')) { + this.fetchStatusHistory() + } + } + }, + methods: { + resetHistory () { + this.statuses = [] + }, + fetchStatusHistory () { + this.$store.dispatch('fetchStatusHistory', this.params) + .then(data => { + this.statuses = data + }) + }, + closeModal () { + this.$store.dispatch('closeStatusHistoryModal') + } + } +} + +export default StatusHistoryModal diff --git a/src/components/status_history_modal/status_history_modal.vue b/src/components/status_history_modal/status_history_modal.vue @@ -0,0 +1,46 @@ +<template> + <Modal + v-if="modalActivated" + class="status-history-modal-view" + @backdropClicked="closeModal" + > + <div class="status-history-modal-panel panel"> + <div class="panel-heading"> + {{ $t('status.status_history') }} ({{ historyCount }}) + </div> + <div class="panel-body"> + <div + v-if="historyCount > 0" + class="history-body" + > + <status + v-for="status in history" + :key="status.id" + :statusoid="status" + :is-preview="true" + class="conversation-status status-fadein panel-body" + /> + </div> + </div> + </div> + </Modal> +</template> + +<script src="./status_history_modal.js"></script> + +<style lang="scss"> +.modal-view.status-history-modal-view { + align-items: flex-start; +} +.status-history-modal-panel { + flex-shrink: 0; + margin-top: 25%; + margin-bottom: 2em; + width: 100%; + max-width: 700px; + + @media (orientation: landscape) { + margin-top: 8%; + } +} +</style> diff --git a/src/components/still-image/still-image.js b/src/components/still-image/still-image.js @@ -7,16 +7,23 @@ const StillImage = { 'imageLoadHandler', 'alt', 'height', - 'width' + 'width', + 'dataSrc' ], data () { return { + // for lazy loading, see loadLazy() + realSrc: this.src, stopGifs: this.$store.getters.mergedConfig.stopGifs } }, computed: { animated () { - return this.stopGifs && (this.mimetype === 'image/gif' || this.src.endsWith('.gif')) + if (!this.realSrc) { + return false + } + + return this.stopGifs && (this.mimetype === 'image/gif' || this.realSrc.endsWith('.gif')) }, style () { const appendPx = (str) => /\d$/.test(str) ? str + 'px' : str @@ -27,7 +34,15 @@ const StillImage = { } }, methods: { + loadLazy () { + if (this.dataSrc) { + this.realSrc = this.dataSrc + } + }, onLoad () { + if (!this.realSrc) { + return + } const image = this.$refs.src if (!image) return this.imageLoadHandler && this.imageLoadHandler(image) @@ -42,6 +57,14 @@ const StillImage = { onError () { this.imageLoadError && this.imageLoadError() } + }, + watch: { + src () { + this.realSrc = this.src + }, + dataSrc () { + this.$el.removeAttribute('data-loaded') + } } } diff --git a/src/components/still-image/still-image.vue b/src/components/still-image/still-image.vue @@ -11,10 +11,11 @@ <!-- NOTE: key is required to force to re-render img tag when src is changed --> <img ref="src" - :key="src" + :key="realSrc" :alt="alt" :title="alt" - :src="src" + :data-src="dataSrc" + :src="realSrc" :referrerpolicy="referrerpolicy" @load="onLoad" @error="onError" diff --git a/src/components/timeago/timeago.vue b/src/components/timeago/timeago.vue @@ -3,7 +3,7 @@ :datetime="time" :title="localeDateString" > - {{ $tc(relativeTime.key, relativeTime.num, [relativeTime.num]) }} + {{ relativeTimeString }} </time> </template> @@ -13,7 +13,7 @@ import localeService from 'src/services/locale/locale.service.js' export default { name: 'Timeago', - props: ['time', 'autoUpdate', 'longFormat', 'nowThreshold'], + props: ['time', 'autoUpdate', 'longFormat', 'nowThreshold', 'templateKey'], data () { return { relativeTime: { key: 'time.now', num: 0 }, @@ -26,6 +26,23 @@ export default { return typeof this.time === 'string' ? new Date(Date.parse(this.time)).toLocaleString(browserLocale) : this.time.toLocaleString(browserLocale) + }, + relativeTimeString () { + const timeString = this.$i18n.tc(this.relativeTime.key, this.relativeTime.num, [this.relativeTime.num]) + + if (typeof this.templateKey === 'string' && this.relativeTime.key !== 'time.now') { + return this.$i18n.t(this.templateKey, [timeString]) + } + + return timeString + } + }, + watch: { + time (newVal, oldVal) { + if (oldVal !== newVal) { + clearTimeout(this.interval) + this.refreshRelativeTimeObject() + } } }, created () { diff --git a/src/components/update_notification/update_notification.js b/src/components/update_notification/update_notification.js @@ -2,6 +2,8 @@ import Modal from 'src/components/modal/modal.vue' import { library } from '@fortawesome/fontawesome-svg-core' import pleromaTan from 'src/assets/pleromatan_apology.png' import pleromaTanFox from 'src/assets/pleromatan_apology_fox.png' +import pleromaTanMask from 'src/assets/pleromatan_apology_mask.png' +import pleromaTanFoxMask from 'src/assets/pleromatan_apology_fox_mask.png' import { faTimes @@ -15,9 +17,9 @@ export const CURRENT_UPDATE_COUNTER = 1 const UpdateNotification = { data () { return { + showingImage: false, pleromaTanVariant: Math.random() > 0.5 ? pleromaTan : pleromaTanFox, - showingMore: false, - contentHeight: 0 + showingMore: false } }, components: { @@ -25,13 +27,9 @@ const UpdateNotification = { }, computed: { pleromaTanStyles () { + const mask = this.pleromaTanVariant === pleromaTan ? pleromaTanMask : pleromaTanFoxMask return { - 'shape-outside': 'url(' + this.pleromaTanVariant + ')' - } - }, - dynamicStyles () { - return { - '--____extraInfoGroupHeight': this.contentHeight + 'px' + 'shape-outside': 'url(' + mask + ')' } }, shouldShow () { @@ -57,9 +55,14 @@ const UpdateNotification = { } }, mounted () { - setTimeout(() => { - this.contentHeight = this.$refs.animatedText.scrollHeight - }, 1000) + this.contentHeightNoImage = this.$refs.animatedText.scrollHeight + + // Workaround to get the text height only after mask loaded. A bit hacky. + const newImg = new Image() + newImg.onload = () => { + setTimeout(() => { this.showingImage = true }, 100) + } + newImg.src = this.pleromaTanVariant === pleromaTan ? pleromaTanMask : pleromaTanFoxMask } } diff --git a/src/components/update_notification/update_notification.scss b/src/components/update_notification/update_notification.scss @@ -35,6 +35,12 @@ margin-top: calc(-1 * var(--__top-fringe)); margin-bottom: calc(-1 * var(--__bottom-fringe)); margin-right: calc(-1 * var(--__right-fringe)); + + &.-noImage { + .text { + padding-right: var(--__right-fringe); + } + } } .panel-body { @@ -75,9 +81,9 @@ .extra-info-group { transition: max-height, padding, height; - transition-timing-function: ease-in-out; - transition-duration: 500ms; - max-height: calc(var(--____extraInfoGroupHeight) + 1em); // include bottom padding + transition-timing-function: ease-in; + transition-duration: 700ms; + max-height: 70vh; mask: linear-gradient(to top, white, transparent) bottom/100% 2px no-repeat, linear-gradient(to top, white, white); diff --git a/src/components/update_notification/update_notification.vue b/src/components/update_notification/update_notification.vue @@ -7,7 +7,6 @@ <div class="UpdateNotificationModal panel" :class="{ '-peek': !showingMore }" - :style="dynamicStyles" > <div class="panel-heading"> <span class="title"> @@ -15,8 +14,12 @@ </span> </div> <div class="panel-body"> - <div class="content"> + <div + class="content" + :class="{ '-noImage': !showingImage }" + > <img + v-if="showingImage" class="pleroma-tan" :src="pleromaTanVariant" :style="pleromaTanStyles" diff --git a/src/i18n/en.json b/src/i18n/en.json @@ -199,8 +199,20 @@ "add_emoji": "Insert emoji", "custom": "Custom emoji", "unicode": "Unicode emoji", + "unicode_groups": { + "activities": "Activities", + "animals-and-nature": "Animals & Nature", + "flags": "Flags", + "food-and-drink": "Food & Drink", + "objects": "Objects", + "people-and-body": "People & Body", + "smileys-and-emotion": "Smileys & Emotion", + "symbols": "Symbols", + "travel-and-places": "Travel & Places" + }, "load_all_hint": "Loaded first {saneAmount} emoji, loading all emoji may cause performance issues.", - "load_all": "Loading all {emojiAmount} emoji" + "load_all": "Loading all {emojiAmount} emoji", + "regional_indicator": "Regional indicator {letter}" }, "errors": { "storage_unavailable": "Pleroma could not access browser storage. Your login or your local settings won't be saved and you might encounter unexpected issues. Try enabling cookies." @@ -214,6 +226,7 @@ "load_older": "Load older interactions" }, "post_status": { + "edit_status": "Edit status", "new_status": "Post new status", "account_not_locked_warning": "Your account is not {0}. Anyone can follow you to view your follower-only posts.", "account_not_locked_warning_link": "locked", @@ -229,6 +242,8 @@ "default": "Just landed in L.A.", "direct_warning_to_all": "This post will be visible to all the mentioned users.", "direct_warning_to_first_only": "This post will only be visible to the mentioned users at the beginning of the message.", + "edit_remote_warning": "Other remote instances may not support editing and unable to receive the latest version of your post.", + "edit_unsupported_warning": "Pleroma does not support editing mentions or polls.", "posting": "Posting", "post": "Post", "preview": "Preview", @@ -797,6 +812,8 @@ "favorites": "Favorites", "repeats": "Repeats", "delete": "Delete status", + "edit": "Edit status", + "edited_at": "(last edited {time})", "pin": "Pin on profile", "unpin": "Unpin from profile", "pinned": "Pinned", @@ -844,7 +861,8 @@ "ancestor_follow_with_icon": "{icon} {text}", "show_all_conversation_with_icon": "{icon} {text}", "show_all_conversation": "Show full conversation ({numStatus} other status) | Show full conversation ({numStatus} other statuses)", - "show_only_conversation_under_this": "Only show replies to this status" + "show_only_conversation_under_this": "Only show replies to this status", + "status_history": "Status history" }, "user_card": { "approve": "Approve", @@ -872,6 +890,7 @@ "muted": "Muted", "per_day": "per day", "remote_follow": "Remote follow", + "remove_follower": "Remove follower", "report": "Report", "statuses": "Statuses", "subscribe": "Subscribe", diff --git a/src/i18n/languages.js b/src/i18n/languages.js @@ -0,0 +1,53 @@ + +const languages = [ + 'ar', + 'ca', + 'cs', + 'de', + 'eo', + 'en', + 'es', + 'et', + 'eu', + 'fi', + 'fr', + 'ga', + 'he', + 'hu', + 'it', + 'ja', + 'ja_easy', + 'ko', + 'nb', + 'nl', + 'oc', + 'pl', + 'pt', + 'ro', + 'ru', + 'sk', + 'te', + 'uk', + 'zh', + 'zh_Hant' +] + +const specialJsonName = { + ja: 'ja_pedantic' +} + +const langCodeToJsonName = (code) => specialJsonName[code] || code + +const langCodeToCldrName = (code) => code + +const ensureFinalFallback = codes => { + const codeList = Array.isArray(codes) ? codes : [codes] + return codeList.includes('en') ? codeList : codeList.concat(['en']) +} + +module.exports = { + languages, + langCodeToJsonName, + langCodeToCldrName, + ensureFinalFallback +} diff --git a/src/i18n/messages.js b/src/i18n/messages.js @@ -7,46 +7,26 @@ // sed -i -e "s/'//gm" -e 's/"/\\"/gm' -re 's/^( +)(.+?): ((.+?))?(,?)(\{?)$/\1"\2": "\4"/gm' -e 's/\"\{\"/{/g' -e 's/,"$/",/g' file.json // There's only problem that apostrophe character ' gets replaced by \\ so you have to fix it manually, sorry. -const loaders = { - ar: () => import('./ar.json'), - ca: () => import('./ca.json'), - cs: () => import('./cs.json'), - de: () => import('./de.json'), - eo: () => import('./eo.json'), - es: () => import('./es.json'), - et: () => import('./et.json'), - eu: () => import('./eu.json'), - fi: () => import('./fi.json'), - fr: () => import('./fr.json'), - ga: () => import('./ga.json'), - he: () => import('./he.json'), - hu: () => import('./hu.json'), - it: () => import('./it.json'), - ja: () => import('./ja_pedantic.json'), - ja_easy: () => import('./ja_easy.json'), - ko: () => import('./ko.json'), - nb: () => import('./nb.json'), - nl: () => import('./nl.json'), - oc: () => import('./oc.json'), - pl: () => import('./pl.json'), - pt: () => import('./pt.json'), - ro: () => import('./ro.json'), - ru: () => import('./ru.json'), - sk: () => import('./sk.json'), - te: () => import('./te.json'), - uk: () => import('./uk.json'), - zh: () => import('./zh.json'), - zh_Hant: () => import('./zh_Hant.json') +import { languages, langCodeToJsonName } from './languages.js' + +const hasLanguageFile = (code) => languages.includes(code) + +const loadLanguageFile = (code) => { + return import( + /* webpackInclude: /\.json$/ */ + /* webpackChunkName: "i18n/[request]" */ + `./${langCodeToJsonName(code)}.json` + ) } const messages = { - languages: ['en', ...Object.keys(loaders)], + languages, default: { en: require('./en.json').default }, setLanguage: async (i18n, language) => { - if (loaders[language]) { - const messages = await loaders[language]() + if (hasLanguageFile(language)) { + const messages = await loadLanguageFile(language) i18n.setLocaleMessage(language, messages.default) } i18n.locale = language diff --git a/src/main.js b/src/main.js @@ -20,6 +20,9 @@ import oauthTokensModule from './modules/oauth_tokens.js' import reportsModule from './modules/reports.js' import pollsModule from './modules/polls.js' import postStatusModule from './modules/postStatus.js' +import editStatusModule from './modules/editStatus.js' +import statusHistoryModule from './modules/statusHistory.js' + import chatsModule from './modules/chats.js' import { createI18n } from 'vue-i18n' @@ -86,6 +89,8 @@ const persistedStateOptions = { reports: reportsModule, polls: pollsModule, postStatus: postStatusModule, + editStatus: editStatusModule, + statusHistory: statusHistoryModule, chats: chatsModule }, plugins, diff --git a/src/modules/api.js b/src/modules/api.js @@ -16,7 +16,7 @@ const api = { followRequests: [] }, getters: { - followRequestCount: state => state.api.followRequests.length + followRequestCount: state => state.followRequests.length }, mutations: { setBackendInteractor (state, backendInteractor) { @@ -103,6 +103,13 @@ const api = { showImmediately: timelineData.visibleStatuses.length === 0, timeline: 'friends' }) + } else if (message.event === 'status.update') { + dispatch('addNewStatuses', { + statuses: [message.status], + userId: false, + showImmediately: message.status.id in timelineData.visibleStatusesObject, + timeline: 'friends' + }) } else if (message.event === 'delete') { dispatch('deleteStatusById', message.id) } else if (message.event === 'pleroma:chat_update') { diff --git a/src/modules/config.js b/src/modules/config.js @@ -183,6 +183,7 @@ const config = { break case 'interfaceLanguage': messages.setLanguage(this.getters.i18n, value) + dispatch('loadUnicodeEmojiData', value) Cookies.set(BACKEND_LANGUAGE_COOKIE_NAME, localeService.internalToBackendLocale(value)) break case 'thirdColumnMode': diff --git a/src/modules/editStatus.js b/src/modules/editStatus.js @@ -0,0 +1,25 @@ +const editStatus = { + state: { + params: null, + modalActivated: false + }, + mutations: { + openEditStatusModal (state, params) { + state.params = params + state.modalActivated = true + }, + closeEditStatusModal (state) { + state.modalActivated = false + } + }, + actions: { + openEditStatusModal ({ commit }, params) { + commit('openEditStatusModal', params) + }, + closeEditStatusModal ({ commit }) { + commit('closeEditStatusModal') + } + } +} + +export default editStatus diff --git a/src/modules/instance.js b/src/modules/instance.js @@ -2,6 +2,39 @@ import { getPreset, applyTheme } from '../services/style_setter/style_setter.js' import { CURRENT_VERSION } from '../services/theme_data/theme_data.service.js' import apiService from '../services/api/api.service.js' import { instanceDefaultProperties } from './config.js' +import { langCodeToCldrName, ensureFinalFallback } from '../i18n/languages.js' + +const SORTED_EMOJI_GROUP_IDS = [ + 'smileys-and-emotion', + 'people-and-body', + 'animals-and-nature', + 'food-and-drink', + 'travel-and-places', + 'activities', + 'objects', + 'symbols', + 'flags' +] + +const REGIONAL_INDICATORS = (() => { + const start = 0x1F1E6 + const end = 0x1F1FF + const A = 'A'.codePointAt(0) + const res = new Array(end - start + 1) + for (let i = start; i <= end; ++i) { + const letter = String.fromCodePoint(A + i - start) + res[i - start] = { + replacement: String.fromCodePoint(i), + imageUrl: false, + displayText: 'regional_indicator_' + letter, + displayTextI18n: { + key: 'emoji.regional_indicator', + args: { letter } + } + } + } + return res +})() const defaultState = { // Stuff from apiConfig @@ -64,8 +97,9 @@ const defaultState = { // Nasty stuff customEmoji: [], customEmojiFetched: false, - emoji: [], + emoji: {}, emojiFetched: false, + unicodeEmojiAnnotations: {}, pleromaBackend: true, postFormats: [], restrictedNicknames: [], @@ -97,6 +131,31 @@ const defaultState = { } } +const loadAnnotations = (lang) => { + return import( + /* webpackChunkName: "emoji-annotations/[request]" */ + `@kazvmoe-infra/unicode-emoji-json/annotations/${langCodeToCldrName(lang)}.json` + ) + .then(k => k.default) +} + +const injectAnnotations = (emoji, annotations) => { + const availableLangs = Object.keys(annotations) + + return { + ...emoji, + annotations: availableLangs.reduce((acc, cur) => { + acc[cur] = annotations[cur][emoji.replacement] + return acc + }, {}) + } +} + +const injectRegionalIndicators = groups => { + groups.symbols.push(...REGIONAL_INDICATORS) + return groups +} + const instance = { state: defaultState, mutations: { @@ -107,6 +166,9 @@ const instance = { }, setKnownDomains (state, domains) { state.knownDomains = domains + }, + setUnicodeEmojiAnnotations (state, { lang, annotations }) { + state.unicodeEmojiAnnotations[lang] = annotations } }, getters: { @@ -115,6 +177,41 @@ const instance = { .map(key => [key, state[key]]) .reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {}) }, + groupedCustomEmojis (state) { + const packsOf = emoji => { + return emoji.tags + .filter(k => k.startsWith('pack:')) + .map(k => k.slice(5)) // remove 'pack:' prefix + } + + return state.customEmoji + .reduce((res, emoji) => { + packsOf(emoji).forEach(packName => { + const packId = `custom-${packName}` + if (!res[packId]) { + res[packId] = ({ + id: packId, + text: packName, + image: emoji.imageUrl, + emojis: [] + }) + } + res[packId].emojis.push(emoji) + }) + return res + }, {}) + }, + standardEmojiList (state) { + return SORTED_EMOJI_GROUP_IDS + .map(groupId => (state.emoji[groupId] || []).map(k => injectAnnotations(k, state.unicodeEmojiAnnotations))) + .reduce((a, b) => a.concat(b), []) + }, + standardEmojiGroupList (state) { + return SORTED_EMOJI_GROUP_IDS.map(groupId => ({ + id: groupId, + emojis: (state.emoji[groupId] || []).map(k => injectAnnotations(k, state.unicodeEmojiAnnotations)) + })) + }, instanceDomain (state) { return new URL(state.server).hostname } @@ -138,32 +235,52 @@ const instance = { }, async getStaticEmoji ({ commit }) { try { - const res = await window.fetch('/static/emoji.json') - if (res.ok) { - const values = await res.json() - const emoji = Object.keys(values).map((key) => { - return { - displayText: key, - imageUrl: false, - replacement: values[key] - } - }).sort((a, b) => a.name > b.name ? 1 : -1) - commit('setInstanceOption', { name: 'emoji', value: emoji }) - } else { - throw (res) - } + const values = (await import(/* webpackChunkName: 'emoji' */ '../../static/emoji.json')).default + + const emoji = Object.keys(values).reduce((res, groupId) => { + res[groupId] = values[groupId].map(e => ({ + displayText: e.slug, + imageUrl: false, + replacement: e.emoji + })) + return res + }, {}) + commit('setInstanceOption', { name: 'emoji', value: injectRegionalIndicators(emoji) }) } catch (e) { console.warn("Can't load static emoji") console.warn(e) } }, + loadUnicodeEmojiData ({ commit, state }, language) { + const langList = ensureFinalFallback(language) + + return Promise.all( + langList + .map(async lang => { + if (!state.unicodeEmojiAnnotations[lang]) { + const annotations = await loadAnnotations(lang) + commit('setUnicodeEmojiAnnotations', { lang, annotations }) + } + })) + }, + async getCustomEmoji ({ commit, state }) { try { const res = await window.fetch('/api/pleroma/emoji.json') if (res.ok) { const result = await res.json() const values = Array.isArray(result) ? Object.assign({}, ...result) : result + const caseInsensitiveStrCmp = (a, b) => { + const la = a.toLowerCase() + const lb = b.toLowerCase() + return la > lb ? 1 : (la < lb ? -1 : 0) + } + const byPackThenByName = (a, b) => { + const packOf = emoji => (emoji.tags.filter(k => k.startsWith('pack:'))[0] || '').slice(5) + return caseInsensitiveStrCmp(packOf(a), packOf(b)) || caseInsensitiveStrCmp(a.displayText, b.displayText) + } + const emoji = Object.entries(values).map(([key, value]) => { const imageUrl = value.image_url return { @@ -174,7 +291,7 @@ const instance = { } // Technically could use tags but those are kinda useless right now, // should have been "pack" field, that would be more useful - }).sort((a, b) => a.displayText.toLowerCase() > b.displayText.toLowerCase() ? 1 : -1) + }).sort(byPackThenByName) commit('setInstanceOption', { name: 'customEmoji', value: emoji }) } else { throw (res) diff --git a/src/modules/statusHistory.js b/src/modules/statusHistory.js @@ -0,0 +1,25 @@ +const statusHistory = { + state: { + params: {}, + modalActivated: false + }, + mutations: { + openStatusHistoryModal (state, params) { + state.params = params + state.modalActivated = true + }, + closeStatusHistoryModal (state) { + state.modalActivated = false + } + }, + actions: { + openStatusHistoryModal ({ commit }, params) { + commit('openStatusHistoryModal', params) + }, + closeStatusHistoryModal ({ commit }) { + commit('closeStatusHistoryModal') + } + } +} + +export default statusHistory diff --git a/src/modules/statuses.js b/src/modules/statuses.js @@ -249,6 +249,9 @@ const addNewStatuses = (state, { statuses, showImmediately = false, timeline, us status: (status) => { addStatus(status, showImmediately) }, + edit: (status) => { + addStatus(status, showImmediately) + }, retweet: (status) => { // RetweetedStatuses are never shown immediately const retweetedStatus = addStatus(status.retweeted_status, false, false) @@ -606,6 +609,12 @@ const statuses = { return rootState.api.backendInteractor.fetchStatus({ id }) .then((status) => dispatch('addNewStatuses', { statuses: [status] })) }, + fetchStatusSource ({ rootState, dispatch }, status) { + return apiService.fetchStatusSource({ id: status.id, credentials: rootState.users.currentUser.credentials }) + }, + fetchStatusHistory ({ rootState, dispatch }, status) { + return apiService.fetchStatusHistory({ status }) + }, deleteStatus ({ rootState, commit }, status) { commit('setDeleted', { status }) apiService.deleteStatus({ id: status.id, credentials: rootState.users.currentUser.credentials }) diff --git a/src/modules/users.js b/src/modules/users.js @@ -51,6 +51,11 @@ const unblockUser = (store, id) => { .then((relationship) => store.commit('updateUserRelationship', [relationship])) } +const removeUserFromFollowers = (store, id) => { + return store.rootState.api.backendInteractor.removeUserFromFollowers({ id }) + .then((relationship) => store.commit('updateUserRelationship', [relationship])) +} + const muteUser = (store, id) => { const predictedRelationship = store.state.relationships[id] || { id } predictedRelationship.muting = true @@ -321,6 +326,9 @@ const users = { unblockUser (store, id) { return unblockUser(store, id) }, + removeUserFromFollowers (store, id) { + return removeUserFromFollowers(store, id) + }, blockUsers (store, ids = []) { return Promise.all(ids.map(id => blockUser(store, id))) }, diff --git a/src/services/api/api.service.js b/src/services/api/api.service.js @@ -1,5 +1,5 @@ import { each, map, concat, last, get } from 'lodash' -import { parseStatus, parseUser, parseNotification, parseAttachment, parseChat, parseLinkHeaderPagination } from '../entity_normalizer/entity_normalizer.service.js' +import { parseStatus, parseSource, parseUser, parseNotification, parseAttachment, parseChat, parseLinkHeaderPagination } from '../entity_normalizer/entity_normalizer.service.js' import { RegistrationError, StatusCodeError } from '../errors/errors' /* eslint-env browser */ @@ -49,6 +49,8 @@ const MASTODON_PUBLIC_TIMELINE = '/api/v1/timelines/public' const MASTODON_USER_HOME_TIMELINE_URL = '/api/v1/timelines/home' const MASTODON_STATUS_URL = id => `/api/v1/statuses/${id}` const MASTODON_STATUS_CONTEXT_URL = id => `/api/v1/statuses/${id}/context` +const MASTODON_STATUS_SOURCE_URL = id => `/api/v1/statuses/${id}/source` +const MASTODON_STATUS_HISTORY_URL = id => `/api/v1/statuses/${id}/history` const MASTODON_USER_URL = '/api/v1/accounts' const MASTODON_USER_LOOKUP_URL = '/api/v1/accounts/lookup' const MASTODON_USER_RELATIONSHIPS_URL = '/api/v1/accounts/relationships' @@ -65,6 +67,7 @@ const MASTODON_BLOCK_USER_URL = id => `/api/v1/accounts/${id}/block` const MASTODON_UNBLOCK_USER_URL = id => `/api/v1/accounts/${id}/unblock` const MASTODON_MUTE_USER_URL = id => `/api/v1/accounts/${id}/mute` const MASTODON_UNMUTE_USER_URL = id => `/api/v1/accounts/${id}/unmute` +const MASTODON_REMOVE_USER_FROM_FOLLOWERS = id => `/api/v1/accounts/${id}/remove_from_followers` const MASTODON_SUBSCRIBE_USER = id => `/api/v1/pleroma/accounts/${id}/subscribe` const MASTODON_UNSUBSCRIBE_USER = id => `/api/v1/pleroma/accounts/${id}/unsubscribe` const MASTODON_BOOKMARK_STATUS_URL = id => `/api/v1/statuses/${id}/bookmark` @@ -305,6 +308,13 @@ const unblockUser = ({ id, credentials }) => { }).then((data) => data.json()) } +const removeUserFromFollowers = ({ id, credentials }) => { + return fetch(MASTODON_REMOVE_USER_FROM_FOLLOWERS(id), { + headers: authHeaders(credentials), + method: 'POST' + }).then((data) => data.json()) +} + const approveUser = ({ id, credentials }) => { const url = MASTODON_APPROVE_USER_URL(id) return fetch(url, { @@ -522,6 +532,31 @@ const fetchStatus = ({ id, credentials }) => { .then((data) => parseStatus(data)) } +const fetchStatusSource = ({ id, credentials }) => { + const url = MASTODON_STATUS_SOURCE_URL(id) + return fetch(url, { headers: authHeaders(credentials) }) + .then((data) => { + if (data.ok) { + return data + } + throw new Error('Error fetching source', data) + }) + .then((data) => data.json()) + .then((data) => parseSource(data)) +} + +const fetchStatusHistory = ({ status, credentials }) => { + const url = MASTODON_STATUS_HISTORY_URL(status.id) + return promisedRequest({ url, credentials }) + .then((data) => { + data.reverse() + return data.map((item) => { + item.originalStatus = status + return parseStatus(item) + }) + }) +} + const tagUser = ({ tag, credentials, user }) => { const screenName = user.screen_name const form = { @@ -825,6 +860,54 @@ const postStatus = ({ .then((data) => data.error ? data : parseStatus(data)) } +const editStatus = ({ + id, + credentials, + status, + spoilerText, + sensitive, + poll, + mediaIds = [], + contentType +}) => { + const form = new FormData() + const pollOptions = poll.options || [] + + form.append('status', status) + if (spoilerText) form.append('spoiler_text', spoilerText) + if (sensitive) form.append('sensitive', sensitive) + if (contentType) form.append('content_type', contentType) + mediaIds.forEach(val => { + form.append('media_ids[]', val) + }) + + if (pollOptions.some(option => option !== '')) { + const normalizedPoll = { + expires_in: poll.expiresIn, + multiple: poll.multiple + } + Object.keys(normalizedPoll).forEach(key => { + form.append(`poll[${key}]`, normalizedPoll[key]) + }) + + pollOptions.forEach(option => { + form.append('poll[options][]', option) + }) + } + + const putHeaders = authHeaders(credentials) + + return fetch(MASTODON_STATUS_URL(id), { + body: form, + method: 'PUT', + headers: putHeaders + }) + .then((response) => { + return response.json() + }) + .then((data) => data.error ? data : parseStatus(data)) +} + const deleteStatus = ({ id, credentials }) => { return fetch(MASTODON_DELETE_URL(id), { headers: authHeaders(credentials), @@ -1291,7 +1374,8 @@ const MASTODON_STREAMING_EVENTS = new Set([ 'update', 'notification', 'delete', - 'filters_changed' + 'filters_changed', + 'status.update' ]) const PLEROMA_STREAMING_EVENTS = new Set([ @@ -1363,6 +1447,8 @@ export const handleMastoWS = (wsEvent) => { const data = payload ? JSON.parse(payload) : null if (event === 'update') { return { event, status: parseStatus(data) } + } else if (event === 'status.update') { + return { event, status: parseStatus(data) } } else if (event === 'notification') { return { event, notification: parseNotification(data) } } else if (event === 'pleroma:chat_update') { @@ -1497,6 +1583,8 @@ const apiService = { fetchPinnedStatuses, fetchConversation, fetchStatus, + fetchStatusSource, + fetchStatusHistory, fetchFriends, exportFriends, fetchFollowers, @@ -1508,6 +1596,7 @@ const apiService = { unmuteConversation, blockUser, unblockUser, + removeUserFromFollowers, fetchUser, fetchUserByName, fetchUserRelationship, @@ -1518,6 +1607,7 @@ const apiService = { bookmarkStatus, unbookmarkStatus, postStatus, + editStatus, deleteStatus, uploadMedia, setMediaDescription, diff --git a/src/services/entity_normalizer/entity_normalizer.service.js b/src/services/entity_normalizer/entity_normalizer.service.js @@ -251,6 +251,16 @@ export const parseAttachment = (data) => { return output } +export const parseSource = (data) => { + const output = {} + + output.text = data.text + output.spoiler_text = data.spoiler_text + output.content_type = data.content_type + + return output +} + export const parseStatus = (data) => { const output = {} const masto = Object.prototype.hasOwnProperty.call(data, 'account') @@ -272,6 +282,8 @@ export const parseStatus = (data) => { output.tags = data.tags + output.edited_at = data.edited_at + if (data.pleroma) { const { pleroma } = data output.text = pleroma.content ? data.pleroma.content['text/plain'] : data.content @@ -373,6 +385,10 @@ export const parseStatus = (data) => { output.favoritedBy = [] output.rebloggedBy = [] + if (Object.prototype.hasOwnProperty.call(data, 'originalStatus')) { + Object.assign(output, data.originalStatus) + } + return output } diff --git a/src/services/status_poster/status_poster.service.js b/src/services/status_poster/status_poster.service.js @@ -47,6 +47,47 @@ const postStatus = ({ }) } +const editStatus = ({ + store, + statusId, + status, + spoilerText, + sensitive, + poll, + media = [], + contentType = 'text/plain' +}) => { + const mediaIds = map(media, 'id') + + return apiService.editStatus({ + id: statusId, + credentials: store.state.users.currentUser.credentials, + status, + spoilerText, + sensitive, + poll, + mediaIds, + contentType + }) + .then((data) => { + if (!data.error) { + store.dispatch('addNewStatuses', { + statuses: [data], + timeline: 'friends', + showImmediately: true, + noIdUpdate: true // To prevent missing notices on next pull. + }) + } + return data + }) + .catch((err) => { + console.error('Error editing status', err) + return { + error: err.message + } + }) +} + const uploadMedia = ({ store, formData }) => { const credentials = store.state.users.currentUser.credentials return apiService.uploadMedia({ credentials, formData }) @@ -59,6 +100,7 @@ const setMediaDescription = ({ store, id, description }) => { const statusPosterService = { postStatus, + editStatus, uploadMedia, setMediaDescription } diff --git a/static/emoji.json b/static/emoji.json @@ -1,1431 +0,0 @@ -{ - "100": "๐Ÿ’ฏ", - "1234": "๐Ÿ”ข", - "1st_place_medal": "๐Ÿฅ‡", - "2nd_place_medal": "๐Ÿฅˆ", - "3rd_place_medal": "๐Ÿฅ‰", - "8ball": "๐ŸŽฑ", - "a_button_blood_type": "๐Ÿ…ฐ", - "ab": "๐Ÿ†Ž", - "abacus": "๐Ÿงฎ", - "abc": "๐Ÿ”ค", - "abcd": "๐Ÿ”ก", - "accept": "๐Ÿ‰‘", - "adhesive_bandage": "๐Ÿฉน", - "admission_tickets": "๐ŸŽŸ", - "adult": "๐Ÿง‘", - "aerial_tramway": "๐Ÿšก", - "airplane": "โœˆ", - "airplane_arriving": "๐Ÿ›ฌ", - "airplane_departure": "๐Ÿ›ซ", - "alarm_clock": "โฐ", - "alembic": "โš—๏ธ", - "alien": "๐Ÿ‘ฝ", - "ambulance": "๐Ÿš‘", - "amphora": "๐Ÿบ", - "anchor": "โš“", - "angel": "๐Ÿ‘ผ", - "anger": "๐Ÿ’ข", - "anger_right": "๐Ÿ—ฏ", - "angry": "๐Ÿ˜ ", - "anguished": "๐Ÿ˜ง", - "ant": "๐Ÿœ", - "apple": "๐ŸŽ", - "aquarius": "โ™’", - "aries": "โ™ˆ", - "arrow_backward": "โ—€๏ธ", - "arrow_double_down": "โฌ", - "arrow_double_up": "โซ", - "arrow_down": "โฌ‡๏ธ", - "arrow_down_small": "๐Ÿ”ฝ", - "arrow_forward": "โ–ถ๏ธ", - "arrow_heading_down": "โคต๏ธ", - "arrow_heading_up": "โคด๏ธ", - "arrow_left": "โฌ…๏ธ", - "arrow_lower_left": "โ†™๏ธ", - "arrow_lower_right": "โ†˜๏ธ", - "arrow_right": "โžก", - "arrow_right_hook": "โ†ช๏ธ", - "arrow_up": "โฌ†๏ธ", - "arrow_up_down": "โ†•", - "arrow_up_small": "๐Ÿ”ผ", - "arrow_upper_left": "โ†–", - "arrow_upper_right": "โ†—๏ธ", - "arrows_clockwise": "๐Ÿ”ƒ", - "arrows_counterclockwise": "๐Ÿ”„", - "art": "๐ŸŽจ", - "articulated_lorry": "๐Ÿš›", - "artist_palette": "๐ŸŽจ", - "asterisk": "*โƒฃ", - "astonished": "๐Ÿ˜ฒ", - "athletic_shoe": "๐Ÿ‘Ÿ", - "atm": "๐Ÿง", - "atom": "โš›", - "atom_symbol": "โš›๏ธ", - "auto_rickshaw": "๐Ÿ›บ", - "automobile": "๐Ÿš—", - "avocado": "๐Ÿฅ‘", - "axe": "๐Ÿช“", - "b_button_blood_type": "๐Ÿ…ฑ", - "baby": "๐Ÿ‘ถ", - "baby_bottle": "๐Ÿผ", - "baby_chick": "๐Ÿค", - "baby_symbol": "๐Ÿšผ", - "back": "๐Ÿ”™", - "bacon": "๐Ÿฅ“", - "badger": "๐Ÿฆก", - "badminton": "๐Ÿธ", - "bagel": "๐Ÿฅฏ", - "baggage_claim": "๐Ÿ›„", - "baguette_bread": "๐Ÿฅ–", - "balance_scale": "โš–๏ธ", - "bald": "๐Ÿฆฒ", - "ballet_shoes": "๐Ÿฉฐ", - "balloon": "๐ŸŽˆ", - "ballot_box": "๐Ÿ—ณ", - "ballot_box_with_check": "โ˜‘๏ธ", - "bamboo": "๐ŸŽ", - "banana": "๐ŸŒ", - "bangbang": "โ€ผ๏ธ", - "banjo": "๐Ÿช•", - "bank": "๐Ÿฆ", - "bar_chart": "๐Ÿ“Š", - "barber": "๐Ÿ’ˆ", - "baseball": "โšพ", - "basket": "๐Ÿงบ", - "basketball": "๐Ÿ€", - "basketballer": "โ›น", - "bat": "๐Ÿฆ‡", - "bath": "๐Ÿ›€", - "bathtub": "๐Ÿ›", - "battery": "๐Ÿ”‹", - "beach_umbrella": "โ›ฑ", - "beach_with_umbrella": "๐Ÿ–", - "bear": "๐Ÿป", - "beard": "๐Ÿง”", - "bearded_person": "๐Ÿง”", - "bed": "๐Ÿ›", - "bee": "๐Ÿ", - "beer": "๐Ÿบ", - "beers": "๐Ÿป", - "beetle": "๐Ÿž", - "beginner": "๐Ÿ”ฐ", - "bell": "๐Ÿ””", - "bellhop_bell": "๐Ÿ›Ž", - "bento": "๐Ÿฑ", - "beverage_box": "๐Ÿงƒ", - "bicyclist": "๐Ÿšด", - "bike": "๐Ÿšฒ", - "bikini": "๐Ÿ‘™", - "billed_cap": "๐Ÿงข", - "biohazard": "โ˜ฃ๏ธ", - "bird": "๐Ÿฆ", - "birthday": "๐ŸŽ‚", - "black_circle": "โšซ", - "black_heart": "๐Ÿ–ค", - "black_joker": "๐Ÿƒ", - "black_large_square": "โฌ›", - "black_medium_small_square": "โ—พ", - "black_medium_square": "โ—ผ", - "black_nib": "โœ’๏ธ", - "black_small_square": "โ–ช", - "black_square_button": "๐Ÿ”ฒ", - "blond_haired_person": "๐Ÿ‘ฑ", - "blossom": "๐ŸŒผ", - "blowfish": "๐Ÿก", - "blue_book": "๐Ÿ“˜", - "blue_car": "๐Ÿš™", - "blue_circle": "๐Ÿ”ต", - "blue_heart": "๐Ÿ’™", - "blue_square": "๐ŸŸฆ", - "blush": "๐Ÿ˜Š", - "boar": "๐Ÿ—", - "bomb": "๐Ÿ’ฃ", - "bone": "๐Ÿฆด", - "book": "๐Ÿ“–", - "bookmark": "๐Ÿ”–", - "bookmark_tabs": "๐Ÿ“‘", - "books": "๐Ÿ“š", - "boom": "๐Ÿ’ฅ", - "boot": "๐Ÿ‘ข", - "bouquet": "๐Ÿ’", - "bow": "๐Ÿ™‡", - "bow_and_arrow": "๐Ÿน", - "bowl_with_spoon": "๐Ÿฅฃ", - "bowling": "๐ŸŽณ", - "boxing_glove": "๐ŸฅŠ", - "boy": "๐Ÿ‘ฆ", - "brain": "๐Ÿง ", - "bread": "๐Ÿž", - "breast_feeding": "๐Ÿคฑ", - "breastfeeding": "๐Ÿคฑ", - "brick": "๐Ÿงฑ", - "bride_with_veil": "๐Ÿ‘ฐ", - "bridge_at_night": "๐ŸŒ‰", - "briefcase": "๐Ÿ’ผ", - "briefs": "๐Ÿฉฒ", - "broccoli": "๐Ÿฅฆ", - "broken_heart": "๐Ÿ’”", - "broom": "๐Ÿงน", - "brown_circle": "๐ŸŸค", - "brown_heart": "๐ŸคŽ", - "bug": "๐Ÿ›", - "building_construction": "๐Ÿ—", - "bulb": "๐Ÿ’ก", - "bullettrain_front": "๐Ÿš…", - "bullettrain_side": "๐Ÿš„", - "burrito": "๐ŸŒฏ", - "bus": "๐ŸšŒ", - "busstop": "๐Ÿš", - "bust_in_silhouette": "๐Ÿ‘ค", - "busts_in_silhouette": "๐Ÿ‘ฅ", - "butter": "๐Ÿงˆ", - "butterfly": "๐Ÿฆ‹", - "cactus": "๐ŸŒต", - "cake": "๐Ÿฐ", - "calendar": "๐Ÿ“†", - "call_me": "๐Ÿค™", - "call_me_hand": "๐Ÿค™", - "calling": "๐Ÿ“ฒ", - "camel": "๐Ÿซ", - "camera": "๐Ÿ“ท", - "camera_with_flash": "๐Ÿ“ธ", - "camping": "๐Ÿ•", - "cancer": "โ™‹", - "candle": "๐Ÿ•ฏ", - "candy": "๐Ÿฌ", - "canned_food": "๐Ÿฅซ", - "canoe": "๐Ÿ›ถ", - "capital_abcd": "๐Ÿ” ", - "capricorn": "โ™‘", - "card_file_box": "๐Ÿ—ƒ", - "card_index": "๐Ÿ“‡", - "card_index_dividers": "๐Ÿ—‚", - "carousel_horse": "๐ŸŽ ", - "carrot": "๐Ÿฅ•", - "cat": "๐Ÿฑ", - "cat2": "๐Ÿˆ", - "cd": "๐Ÿ’ฟ", - "chains": "โ›“๏ธ", - "chair": "๐Ÿช‘", - "champagne": "๐Ÿพ", - "champagne_glass": "๐Ÿฅ‚", - "chart": "๐Ÿ’น", - "chart_with_downwards_trend": "๐Ÿ“‰", - "chart_with_upwards_trend": "๐Ÿ“ˆ", - "check_box_with_check": "โ˜‘", - "check_mark": "โœ”", - "checkered_flag": "๐Ÿ", - "cheese": "๐Ÿง€", - "cheese_wedge": "๐Ÿง€", - "cherries": "๐Ÿ’", - "cherry_blossom": "๐ŸŒธ", - "chess_pawn": "โ™Ÿ", - "chestnut": "๐ŸŒฐ", - "chicken": "๐Ÿ”", - "child": "๐Ÿง’", - "children_crossing": "๐Ÿšธ", - "chipmunk": "๐Ÿฟ", - "chocolate_bar": "๐Ÿซ", - "chopsticks": "๐Ÿฅข", - "christmas_tree": "๐ŸŽ„", - "church": "โ›ช", - "cinema": "๐ŸŽฆ", - "circled_m": "โ“‚", - "circus_tent": "๐ŸŽช", - "city_dusk": "๐ŸŒ†", - "city_sunset": "๐ŸŒ‡", - "cityscape": "๐Ÿ™", - "cityscape_at_dusk": "๐ŸŒ†", - "cl": "๐Ÿ†‘", - "clap": "๐Ÿ‘", - "clapper": "๐ŸŽฌ", - "classical_building": "๐Ÿ›", - "clinking_glasses": "๐Ÿฅ‚", - "clipboard": "๐Ÿ“‹", - "clock1": "๐Ÿ•", - "clock10": "๐Ÿ•™", - "clock1030": "๐Ÿ•ฅ", - "clock11": "๐Ÿ•š", - "clock1130": "๐Ÿ•ฆ", - "clock12": "๐Ÿ•›", - "clock1230": "๐Ÿ•ง", - "clock130": "๐Ÿ•œ", - "clock2": "๐Ÿ•‘", - "clock230": "๐Ÿ•", - "clock3": "๐Ÿ•’", - "clock330": "๐Ÿ•ž", - "clock4": "๐Ÿ•“", - "clock430": "๐Ÿ•Ÿ", - "clock5": "๐Ÿ•”", - "clock530": "๐Ÿ• ", - "clock6": "๐Ÿ••", - "clock630": "๐Ÿ•ก", - "clock7": "๐Ÿ•–", - "clock730": "๐Ÿ•ข", - "clock8": "๐Ÿ•—", - "clock830": "๐Ÿ•ฃ", - "clock9": "๐Ÿ•˜", - "clock930": "๐Ÿ•ค", - "closed_book": "๐Ÿ“•", - "closed_lock_with_key": "๐Ÿ”", - "closed_umbrella": "๐ŸŒ‚", - "cloud": "โ˜๏ธ", - "cloud_with_lightning": "๐ŸŒฉ", - "cloud_with_lightning_and_rain": "โ›ˆ๏ธ", - "cloud_with_rain": "๐ŸŒง", - "cloud_with_snow": "๐ŸŒจ", - "clown": "๐Ÿคก", - "clown_face": "๐Ÿคก", - "club_suit": "โ™ฃ๏ธ", - "clubs": "โ™ฃ", - "coat": "๐Ÿงฅ", - "cocktail": "๐Ÿธ", - "coconut": "๐Ÿฅฅ", - "coffee": "โ˜•", - "coffin": "โšฐ๏ธ", - "cold_face": "๐Ÿฅถ", - "cold_sweat": "๐Ÿ˜ฐ", - "comet": "โ˜„๏ธ", - "compass": "๐Ÿงญ", - "compression": "๐Ÿ—œ", - "computer": "๐Ÿ’ป", - "computer_mouse": "๐Ÿ–ฑ", - "confetti_ball": "๐ŸŽŠ", - "confounded": "๐Ÿ˜–", - "confused": "๐Ÿ˜•", - "congratulations": "ใŠ—", - "construction": "๐Ÿšง", - "construction_worker": "๐Ÿ‘ท", - "control_knobs": "๐ŸŽ›", - "convenience_store": "๐Ÿช", - "cookie": "๐Ÿช", - "cooking": "๐Ÿณ", - "cool": "๐Ÿ†’", - "cop": "๐Ÿ‘ฎ", - "copyright": "ยฉ", - "corn": "๐ŸŒฝ", - "couch_and_lamp": "๐Ÿ›‹", - "couple": "๐Ÿ‘ซ", - "couple_with_heart": "๐Ÿ’‘", - "couplekiss": "๐Ÿ’", - "cow": "๐Ÿฎ", - "cow2": "๐Ÿ„", - "cowboy": "๐Ÿค ", - "cowboy_hat_face": "๐Ÿค ", - "crab": "๐Ÿฆ€", - "crayon": "๐Ÿ–", - "crazy_face": "๐Ÿคช", - "credit_card": "๐Ÿ’ณ", - "crescent_moon": "๐ŸŒ™", - "cricket": "๐Ÿฆ—", - "cricket_game": "๐Ÿ", - "crocodile": "๐ŸŠ", - "croissant": "๐Ÿฅ", - "cross": "โœ๏ธ", - "crossed_fingers": "๐Ÿคž", - "crossed_flags": "๐ŸŽŒ", - "crossed_swords": "โš”๏ธ", - "crown": "๐Ÿ‘‘", - "cry": "๐Ÿ˜ข", - "crying_cat_face": "๐Ÿ˜ฟ", - "crystal_ball": "๐Ÿ”ฎ", - "cucumber": "๐Ÿฅ’", - "cup_with_straw": "๐Ÿฅค", - "cupcake": "๐Ÿง", - "cupid": "๐Ÿ’˜", - "curling_stone": "๐ŸฅŒ", - "curly_hair": "๐Ÿฆฑ", - "curly_loop": "โžฐ", - "currency_exchange": "๐Ÿ’ฑ", - "curry": "๐Ÿ›", - "custard": "๐Ÿฎ", - "customs": "๐Ÿ›ƒ", - "cut_of_meat": "๐Ÿฅฉ", - "cyclone": "๐ŸŒ€", - "dagger": "๐Ÿ—ก", - "dancer": "๐Ÿ’ƒ", - "dancers": "๐Ÿ‘ฏ", - "dango": "๐Ÿก", - "dark_skin_tone": "๐Ÿฟ", - "dark_sunglasses": "๐Ÿ•ถ", - "dart": "๐ŸŽฏ", - "dash": "๐Ÿ’จ", - "date": "๐Ÿ“…", - "deaf_person": "๐Ÿง", - "deciduous_tree": "๐ŸŒณ", - "deer": "๐ŸฆŒ", - "department_store": "๐Ÿฌ", - "derelict_house": "๐Ÿš", - "desert": "๐Ÿœ", - "desert_island": "๐Ÿ", - "desktop_computer": "๐Ÿ–ฅ", - "detective": "๐Ÿ•ต", - "diamond_shape_with_a_dot_inside": "๐Ÿ’ ", - "diamond_suit": "โ™ฆ๏ธ", - "diamonds": "โ™ฆ", - "disappointed": "๐Ÿ˜ž", - "disappointed_relieved": "๐Ÿ˜ฅ", - "diving_mask": "๐Ÿคฟ", - "diya_lamp": "๐Ÿช”", - "dizzy": "๐Ÿ’ซ", - "dizzy_face": "๐Ÿ˜ต", - "dna": "๐Ÿงฌ", - "do_not_litter": "๐Ÿšฏ", - "dog": "๐Ÿถ", - "dog2": "๐Ÿ•", - "dollar": "๐Ÿ’ต", - "dolls": "๐ŸŽŽ", - "dolphin": "๐Ÿฌ", - "door": "๐Ÿšช", - "double_exclamation_mark": "โ€ผ", - "doughnut": "๐Ÿฉ", - "dove": "๐Ÿ•Š", - "down_arrow": "โฌ‡", - "downleft_arrow": "โ†™", - "downright_arrow": "โ†˜", - "dragon": "๐Ÿ‰", - "dragon_face": "๐Ÿฒ", - "dress": "๐Ÿ‘—", - "dromedary_camel": "๐Ÿช", - "drooling_face": "๐Ÿคค", - "drop_of_blood": "๐Ÿฉธ", - "droplet": "๐Ÿ’ง", - "drum": "๐Ÿฅ", - "duck": "๐Ÿฆ†", - "dumpling": "๐ŸฅŸ", - "dvd": "๐Ÿ“€", - "e-mail": "๐Ÿ“ง", - "eagle": "๐Ÿฆ…", - "ear": "๐Ÿ‘‚", - "ear_of_rice": "๐ŸŒพ", - "ear_with_hearing_aid": "๐Ÿฆป", - "earth_africa": "๐ŸŒ", - "earth_americas": "๐ŸŒŽ", - "earth_asia": "๐ŸŒ", - "egg": "๐Ÿฅš", - "eggplant": "๐Ÿ†", - "eight": "8โƒฃ", - "eight_pointed_black_star": "โœด๏ธ", - "eight_spoked_asterisk": "โœณ๏ธ", - "eightpointed_star": "โœด", - "eightspoked_asterisk": "โœณ", - "eject_button": "โ", - "electric_plug": "๐Ÿ”Œ", - "elephant": "๐Ÿ˜", - "elf": "๐Ÿง", - "end": "๐Ÿ”š", - "envelope": "โœ‰", - "envelope_with_arrow": "๐Ÿ“ฉ", - "euro": "๐Ÿ’ถ", - "european_castle": "๐Ÿฐ", - "european_post_office": "๐Ÿค", - "evergreen_tree": "๐ŸŒฒ", - "exclamation": "โ—", - "exclamation_question_mark": "โ‰", - "exploding_head": "๐Ÿคฏ", - "expressionless": "๐Ÿ˜‘", - "eye": "๐Ÿ‘", - "eyeglasses": "๐Ÿ‘“", - "eyes": "๐Ÿ‘€", - "face_vomiting": "๐Ÿคฎ", - "face_with_hand_over_mouth": "๐Ÿคญ", - "face_with_headbandage": "๐Ÿค•", - "face_with_monocle": "๐Ÿง", - "face_with_raised_eyebrow": "๐Ÿคจ", - "face_with_symbols_on_mouth": "๐Ÿคฌ", - "face_with_symbols_over_mouth": "๐Ÿคฌ", - "face_with_thermometer": "๐Ÿค’", - "factory": "๐Ÿญ", - "fairy": "๐Ÿงš", - "falafel": "๐Ÿง†", - "fallen_leaf": "๐Ÿ‚", - "family": "๐Ÿ‘ช", - "fast_forward": "โฉ", - "fax": "๐Ÿ“ ", - "fearful": "๐Ÿ˜จ", - "feet": "๐Ÿพ", - "female_sign": "โ™€", - "ferris_wheel": "๐ŸŽก", - "ferry": "โ›ด๏ธ", - "field_hockey": "๐Ÿ‘", - "file_cabinet": "๐Ÿ—„", - "file_folder": "๐Ÿ“", - "film_frames": "๐ŸŽž", - "film_projector": "๐Ÿ“ฝ", - "fingers_crossed": "๐Ÿคž", - "fire": "๐Ÿ”ฅ", - "fire_engine": "๐Ÿš’", - "fire_extinguisher": "๐Ÿงฏ", - "firecracker": "๐Ÿงจ", - "fireworks": "๐ŸŽ†", - "first_place": "๐Ÿฅ‡", - "first_quarter_moon": "๐ŸŒ“", - "first_quarter_moon_with_face": "๐ŸŒ›", - "fish": "๐ŸŸ", - "fish_cake": "๐Ÿฅ", - "fishing_pole_and_fish": "๐ŸŽฃ", - "fist": "โœŠ", - "five": "5โƒฃ", - "flag_black": "๐Ÿด", - "flag_white": "๐Ÿณ", - "flags": "๐ŸŽ", - "flamingo": "๐Ÿฆฉ", - "flashlight": "๐Ÿ”ฆ", - "flat_shoe": "๐Ÿฅฟ", - "fleur-de-lis": "โšœ", - "fleurde-lis": "โšœ๏ธ", - "floppy_disk": "๐Ÿ’พ", - "flower_playing_cards": "๐ŸŽด", - "flushed": "๐Ÿ˜ณ", - "flying_disc": "๐Ÿฅ", - "flying_saucer": "๐Ÿ›ธ", - "fog": "๐ŸŒซ", - "foggy": "๐ŸŒ", - "foot": "๐Ÿฆถ", - "football": "๐Ÿˆ", - "footprints": "๐Ÿ‘ฃ", - "fork_and_knife": "๐Ÿด", - "fork_and_knife_with_plate": "๐Ÿฝ", - "fortune_cookie": "๐Ÿฅ ", - "fountain": "โ›ฒ", - "fountain_pen": "๐Ÿ–‹", - "four": "4โƒฃ", - "four_leaf_clover": "๐Ÿ€", - "fox": "๐ŸฆŠ", - "framed_picture": "๐Ÿ–ผ", - "free": "๐Ÿ†“", - "french_bread": "๐Ÿฅ–", - "fried_shrimp": "๐Ÿค", - "fries": "๐ŸŸ", - "frog": "๐Ÿธ", - "frowning": "๐Ÿ˜ฆ", - "frowning_face": "โ˜น๏ธ", - "fuelpump": "โ›ฝ", - "full_moon": "๐ŸŒ•", - "full_moon_with_face": "๐ŸŒ", - "funeral_urn": "โšฑ๏ธ", - "game_die": "๐ŸŽฒ", - "garlic": "๐Ÿง„", - "gear": "โš™๏ธ", - "gem": "๐Ÿ’Ž", - "gemini": "โ™Š", - "genie": "๐Ÿงž", - "ghost": "๐Ÿ‘ป", - "gift": "๐ŸŽ", - "gift_heart": "๐Ÿ’", - "giraffe": "๐Ÿฆ’", - "girl": "๐Ÿ‘ง", - "glass_of_milk": "๐Ÿฅ›", - "globe_with_meridians": "๐ŸŒ", - "gloves": "๐Ÿงค", - "goal": "๐Ÿฅ…", - "goal_net": "๐Ÿฅ…", - "goat": "๐Ÿ", - "goggles": "๐Ÿฅฝ", - "golf": "โ›ณ", - "golfer": "๐ŸŒ", - "gorilla": "๐Ÿฆ", - "grapes": "๐Ÿ‡", - "green_apple": "๐Ÿ", - "green_book": "๐Ÿ“—", - "green_circle": "๐ŸŸข", - "green_heart": "๐Ÿ’š", - "green_salad": "๐Ÿฅ—", - "green_square": "๐ŸŸฉ", - "grey_exclamation": "โ•", - "grey_question": "โ”", - "grimacing": "๐Ÿ˜ฌ", - "grin": "๐Ÿ˜", - "grinning": "๐Ÿ˜€", - "guard": "๐Ÿ’‚", - "guardsman": "๐Ÿ’‚", - "guide_dog": "๐Ÿฆฎ", - "guitar": "๐ŸŽธ", - "gun": "๐Ÿ”ซ", - "haircut": "๐Ÿ’‡", - "hamburger": "๐Ÿ”", - "hammer": "๐Ÿ”จ", - "hammer_and_pick": "โš’๏ธ", - "hammer_and_wrench": "๐Ÿ› ", - "hamster": "๐Ÿน", - "hand_with_fingers_splayed": "๐Ÿ–", - "handbag": "๐Ÿ‘œ", - "handshake": "๐Ÿค", - "hash": "#โƒฃ", - "hatched_chick": "๐Ÿฅ", - "hatching_chick": "๐Ÿฃ", - "head_bandage": "๐Ÿค•", - "headphones": "๐ŸŽง", - "hear_no_evil": "๐Ÿ™‰", - "heart": "โค๏ธ", - "heart_decoration": "๐Ÿ’Ÿ", - "heart_exclamation": "โฃ", - "heart_eyes": "๐Ÿ˜", - "heart_eyes_cat": "๐Ÿ˜ป", - "heart_suit": "โ™ฅ๏ธ", - "heartbeat": "๐Ÿ’“", - "heartpulse": "๐Ÿ’—", - "hearts": "โ™ฅ", - "heavy_check_mark": "โœ”๏ธ", - "heavy_division_sign": "โž—", - "heavy_dollar_sign": "๐Ÿ’ฒ", - "heavy_minus_sign": "โž–", - "heavy_multiplication_x": "โœ–๏ธ", - "heavy_plus_sign": "โž•", - "hedgehog": "๐Ÿฆ”", - "helicopter": "๐Ÿš", - "herb": "๐ŸŒฟ", - "hibiscus": "๐ŸŒบ", - "high_brightness": "๐Ÿ”†", - "high_heel": "๐Ÿ‘ ", - "hiking_boot": "๐Ÿฅพ", - "hindu_temple": "๐Ÿ›•", - "hippopotamus": "๐Ÿฆ›", - "hockey": "๐Ÿ’", - "hole": "๐Ÿ•ณ", - "honey_pot": "๐Ÿฏ", - "horse": "๐Ÿด", - "horse_racing": "๐Ÿ‡", - "hospital": "๐Ÿฅ", - "hot_face": "๐Ÿฅต", - "hot_pepper": "๐ŸŒถ", - "hot_springs": "โ™จ", - "hotdog": "๐ŸŒญ", - "hotel": "๐Ÿจ", - "hotsprings": "โ™จ๏ธ", - "hourglass": "โŒ›", - "hourglass_flowing_sand": "โณ", - "house": "๐Ÿ ", - "house_with_garden": "๐Ÿก", - "houses": "๐Ÿ˜", - "hugging": "๐Ÿค—", - "hundred_points": "๐Ÿ’ฏ", - "hushed": "๐Ÿ˜ฏ", - "ice": "๐ŸงŠ", - "ice_cream": "๐Ÿจ", - "ice_hockey": "๐Ÿ’", - "ice_skate": "โ›ธ๏ธ", - "icecream": "๐Ÿฆ", - "id": "๐Ÿ†”", - "ideograph_advantage": "๐Ÿ‰", - "imp": "๐Ÿ‘ฟ", - "inbox_tray": "๐Ÿ“ฅ", - "incoming_envelope": "๐Ÿ“จ", - "index_pointing_up": "โ˜", - "infinity": "โ™พ", - "information": "โ„น๏ธ", - "information_desk_person": "๐Ÿ’", - "information_source": "โ„น", - "innocent": "๐Ÿ˜‡", - "input_numbers": "๐Ÿ”ข", - "interrobang": "โ‰๏ธ", - "iphone": "๐Ÿ“ฑ", - "izakaya_lantern": "๐Ÿฎ", - "jack_o_lantern": "๐ŸŽƒ", - "japan": "๐Ÿ—พ", - "japanese_castle": "๐Ÿฏ", - "japanese_congratulations_button": "ใŠ—๏ธ", - "japanese_free_of_charge_button": "๐Ÿˆš", - "japanese_goblin": "๐Ÿ‘บ", - "japanese_ogre": "๐Ÿ‘น", - "japanese_reserved_button": "๐Ÿˆฏ", - "japanese_secret_button": "ใŠ™๏ธ", - "japanese_service_charge_button": "๐Ÿˆ‚", - "jeans": "๐Ÿ‘–", - "joy": "๐Ÿ˜‚", - "joy_cat": "๐Ÿ˜น", - "joystick": "๐Ÿ•น", - "kaaba": "๐Ÿ•‹", - "kangaroo": "๐Ÿฆ˜", - "key": "๐Ÿ”‘", - "keyboard": "โŒจ๏ธ", - "keycap_ten": "๐Ÿ”Ÿ", - "kick_scooter": "๐Ÿ›ด", - "kimono": "๐Ÿ‘˜", - "kiss": "๐Ÿ’‹", - "kissing": "๐Ÿ˜—", - "kissing_cat": "๐Ÿ˜ฝ", - "kissing_closed_eyes": "๐Ÿ˜š", - "kissing_heart": "๐Ÿ˜˜", - "kissing_smiling_eyes": "๐Ÿ˜™", - "kitchen_knife": "๐Ÿ”ช", - "kite": "๐Ÿช", - "kiwi": "๐Ÿฅ", - "kiwi_fruit": "๐Ÿฅ", - "knife": "๐Ÿ”ช", - "koala": "๐Ÿจ", - "koko": "๐Ÿˆ", - "lab_coat": "๐Ÿฅผ", - "label": "๐Ÿท", - "lacrosse": "๐Ÿฅ", - "large_blue_diamond": "๐Ÿ”ท", - "large_orange_diamond": "๐Ÿ”ถ", - "last_quarter_moon": "๐ŸŒ—", - "last_quarter_moon_with_face": "๐ŸŒœ", - "last_track_button": "โฎ๏ธ", - "latin_cross": "โœ", - "laughing": "๐Ÿ˜†", - "leafy_green": "๐Ÿฅฌ", - "leaves": "๐Ÿƒ", - "ledger": "๐Ÿ“’", - "left_arrow": "โฌ…", - "left_arrow_curving_right": "โ†ช", - "left_facing_fist": "๐Ÿค›", - "left_luggage": "๐Ÿ›…", - "left_right_arrow": "โ†”", - "leftfacing_fist": "๐Ÿค›", - "leftright_arrow": "โ†”๏ธ", - "leftwards_arrow_with_hook": "โ†ฉ๏ธ", - "leg": "๐Ÿฆต", - "lemon": "๐Ÿ‹", - "leo": "โ™Œ", - "leopard": "๐Ÿ†", - "level_slider": "๐ŸŽš", - "libra": "โ™Ž", - "light_rail": "๐Ÿšˆ", - "light_skin_tone": "๐Ÿป", - "link": "๐Ÿ”—", - "linked_paperclips": "๐Ÿ–‡", - "lion_face": "๐Ÿฆ", - "lips": "๐Ÿ‘„", - "lipstick": "๐Ÿ’„", - "lizard": "๐ŸฆŽ", - "llama": "๐Ÿฆ™", - "lobster": "๐Ÿฆž", - "lock": "๐Ÿ”’", - "lock_with_ink_pen": "๐Ÿ”", - "lollipop": "๐Ÿญ", - "loop": "โžฟ", - "lotion_bottle": "๐Ÿงด", - "loud_sound": "๐Ÿ”Š", - "loudspeaker": "๐Ÿ“ข", - "love_hotel": "๐Ÿฉ", - "love_letter": "๐Ÿ’Œ", - "love_you_gesture": "๐ŸคŸ", - "loveyou_gesture": "๐ŸคŸ", - "low_brightness": "๐Ÿ”…", - "luggage": "๐Ÿงณ", - "lying_face": "๐Ÿคฅ", - "m": "โ“‚๏ธ", - "mag": "๐Ÿ”", - "mag_right": "๐Ÿ”Ž", - "mage": "๐Ÿง™", - "magnet": "๐Ÿงฒ", - "mahjong": "๐Ÿ€„", - "mailbox": "๐Ÿ“ซ", - "mailbox_closed": "๐Ÿ“ช", - "mailbox_with_mail": "๐Ÿ“ฌ", - "mailbox_with_no_mail": "๐Ÿ“ญ", - "male_sign": "โ™‚", - "man": "๐Ÿ‘จ", - "man_dancing": "๐Ÿ•บ", - "man_in_suit": "๐Ÿ•ด", - "man_in_tuxedo": "๐Ÿคต", - "man_with_chinese_cap": "๐Ÿ‘ฒ", - "man_with_gua_pi_mao": "๐Ÿ‘ฒ", - "man_with_turban": "๐Ÿ‘ณ", - "mango": "๐Ÿฅญ", - "mans_shoe": "๐Ÿ‘ž", - "mantelpiece_clock": "๐Ÿ•ฐ", - "manual_wheelchair": "๐Ÿฆฝ", - "maple_leaf": "๐Ÿ", - "martial_arts_uniform": "๐Ÿฅ‹", - "mask": "๐Ÿ˜ท", - "massage": "๐Ÿ’†", - "mate": "๐Ÿง‰", - "meat_on_bone": "๐Ÿ–", - "mechanical_arm": "๐Ÿฆพ", - "mechanical_leg": "๐Ÿฆฟ", - "medal": "๐Ÿ…", - "medical_symbol": "โš•", - "medium_skin_tone": "๐Ÿฝ", - "mediumdark_skin_tone": "๐Ÿพ", - "mediumlight_skin_tone": "๐Ÿผ", - "mega": "๐Ÿ“ฃ", - "melon": "๐Ÿˆ", - "memo": "๐Ÿ“", - "menorah": "๐Ÿ•Ž", - "mens": "๐Ÿšน", - "merperson": "๐Ÿงœ", - "metal": "๐Ÿค˜", - "metro": "๐Ÿš‡", - "microbe": "๐Ÿฆ ", - "microphone": "๐ŸŽค", - "microscope": "๐Ÿ”ฌ", - "middle_finger": "๐Ÿ–•", - "military_medal": "๐ŸŽ–", - "milk": "๐Ÿฅ›", - "milky_way": "๐ŸŒŒ", - "minibus": "๐Ÿš", - "minidisc": "๐Ÿ’ฝ", - "mobile_phone_off": "๐Ÿ“ด", - "money_mouth": "๐Ÿค‘", - "money_with_wings": "๐Ÿ’ธ", - "moneybag": "๐Ÿ’ฐ", - "moneymouth_face": "๐Ÿค‘", - "monkey": "๐Ÿ’", - "monkey_face": "๐Ÿต", - "monorail": "๐Ÿš", - "moon_cake": "๐Ÿฅฎ", - "mortar_board": "๐ŸŽ“", - "mosque": "๐Ÿ•Œ", - "mosquito": "๐ŸฆŸ", - "motor_boat": "๐Ÿ›ฅ", - "motor_scooter": "๐Ÿ›ต", - "motorcycle": "๐Ÿ", - "motorized_wheelchair": "๐Ÿฆผ", - "motorway": "๐Ÿ›ฃ", - "mount_fuji": "๐Ÿ—ป", - "mountain": "โ›ฐ๏ธ", - "mountain_bicyclist": "๐Ÿšต", - "mountain_cableway": "๐Ÿš ", - "mountain_railway": "๐Ÿšž", - "mouse": "๐Ÿญ", - "mouse2": "๐Ÿ", - "movie_camera": "๐ŸŽฅ", - "moyai": "๐Ÿ—ฟ", - "mrs_claus": "๐Ÿคถ", - "multiplication_sign": "โœ–", - "muscle": "๐Ÿ’ช", - "mushroom": "๐Ÿ„", - "musical_keyboard": "๐ŸŽน", - "musical_note": "๐ŸŽต", - "musical_score": "๐ŸŽผ", - "mute": "๐Ÿ”‡", - "nail_care": "๐Ÿ’…", - "name_badge": "๐Ÿ“›", - "national_park": "๐Ÿž", - "nauseated_face": "๐Ÿคข", - "nazar_amulet": "๐Ÿงฟ", - "necktie": "๐Ÿ‘”", - "negative_squared_cross_mark": "โŽ", - "nerd": "๐Ÿค“", - "neutral_face": "๐Ÿ˜", - "new": "๐Ÿ†•", - "new_moon": "๐ŸŒ‘", - "new_moon_with_face": "๐ŸŒš", - "newspaper": "๐Ÿ“ฐ", - "next_track_button": "โญ๏ธ", - "ng": "๐Ÿ†–", - "night_with_stars": "๐ŸŒƒ", - "nine": "9โƒฃ", - "no_bell": "๐Ÿ”•", - "no_bicycles": "๐Ÿšณ", - "no_entry": "โ›”", - "no_entry_sign": "๐Ÿšซ", - "no_good": "๐Ÿ™…", - "no_mobile_phones": "๐Ÿ“ต", - "no_mouth": "๐Ÿ˜ถ", - "no_pedestrians": "๐Ÿšท", - "no_smoking": "๐Ÿšญ", - "non-potable_water": "๐Ÿšฑ", - "nose": "๐Ÿ‘ƒ", - "notebook": "๐Ÿ““", - "notebook_with_decorative_cover": "๐Ÿ“”", - "notes": "๐ŸŽถ", - "nut_and_bolt": "๐Ÿ”ฉ", - "o": "โญ•", - "o_button_blood_type": "๐Ÿ…พ", - "ocean": "๐ŸŒŠ", - "octagonal_sign": "๐Ÿ›‘", - "octopus": "๐Ÿ™", - "oden": "๐Ÿข", - "office": "๐Ÿข", - "oil_drum": "๐Ÿ›ข", - "ok": "๐Ÿ†—", - "ok_hand": "๐Ÿ‘Œ", - "ok_woman": "๐Ÿ™†", - "old_key": "๐Ÿ—", - "older_adult": "๐Ÿง“", - "older_man": "๐Ÿ‘ด", - "older_person": "๐Ÿง“", - "older_woman": "๐Ÿ‘ต", - "om_symbol": "๐Ÿ•‰", - "on": "๐Ÿ”›", - "oncoming_automobile": "๐Ÿš˜", - "oncoming_bus": "๐Ÿš", - "oncoming_fist": "๐Ÿ‘Š", - "oncoming_police_car": "๐Ÿš”", - "oncoming_taxi": "๐Ÿš–", - "one": "1โƒฃ", - "onepiece_swimsuit": "๐Ÿฉฑ", - "onion": "๐Ÿง…", - "open_file_folder": "๐Ÿ“‚", - "open_hands": "๐Ÿ‘", - "open_mouth": "๐Ÿ˜ฎ", - "ophiuchus": "โ›Ž", - "orange_book": "๐Ÿ“™", - "orange_circle": "๐ŸŸ ", - "orange_heart": "๐Ÿงก", - "orange_square": "๐ŸŸง", - "orangutan": "๐Ÿฆง", - "orthodox_cross": "โ˜ฆ๏ธ", - "otter": "๐Ÿฆฆ", - "outbox_tray": "๐Ÿ“ค", - "owl": "๐Ÿฆ‰", - "ox": "๐Ÿ‚", - "oyster": "๐Ÿฆช", - "p_button": "๐Ÿ…ฟ", - "package": "๐Ÿ“ฆ", - "page_facing_up": "๐Ÿ“„", - "page_with_curl": "๐Ÿ“ƒ", - "pager": "๐Ÿ“Ÿ", - "paintbrush": "๐Ÿ–Œ", - "palm_tree": "๐ŸŒด", - "palms_up_together": "๐Ÿคฒ", - "pancakes": "๐Ÿฅž", - "panda_face": "๐Ÿผ", - "paperclip": "๐Ÿ“Ž", - "parachute": "๐Ÿช‚", - "parrot": "๐Ÿฆœ", - "part_alternation_mark": "ใ€ฝ", - "partly_sunny": "โ›…", - "partying_face": "๐Ÿฅณ", - "passenger_ship": "๐Ÿ›ณ", - "passport_control": "๐Ÿ›‚", - "pause_button": "โธ๏ธ", - "peace": "โ˜ฎ", - "peace_symbol": "โ˜ฎ๏ธ", - "peach": "๐Ÿ‘", - "peacock": "๐Ÿฆš", - "peanuts": "๐Ÿฅœ", - "pear": "๐Ÿ", - "pen": "๐Ÿ–Š", - "pencil": "๐Ÿ“", - "pencil2": "โœ", - "penguin": "๐Ÿง", - "pensive": "๐Ÿ˜”", - "people_with_bunny_ears_partying": "๐Ÿ‘ฏ", - "people_wrestling": "๐Ÿคผ", - "performing_arts": "๐ŸŽญ", - "persevere": "๐Ÿ˜ฃ", - "person": "๐Ÿง‘", - "person_biking": "๐Ÿšด", - "person_bouncing_ball": "โ›น๏ธ", - "person_bowing": "๐Ÿ™‡", - "person_cartwheeling": "๐Ÿคธ", - "person_climbing": "๐Ÿง—", - "person_doing_cartwheel": "๐Ÿคธ", - "person_facepalming": "๐Ÿคฆ", - "person_fencing": "๐Ÿคบ", - "person_frowning": "๐Ÿ™", - "person_gesturing_no": "๐Ÿ™…", - "person_gesturing_ok": "๐Ÿ™†", - "person_getting_haircut": "๐Ÿ’‡", - "person_getting_massage": "๐Ÿ’†", - "person_in_lotus_position": "๐Ÿง˜", - "person_in_steamy_room": "๐Ÿง–", - "person_juggling": "๐Ÿคน", - "person_kneeling": "๐ŸงŽ", - "person_mountain_biking": "๐Ÿšต", - "person_playing_handball": "๐Ÿคพ", - "person_playing_water_polo": "๐Ÿคฝ", - "person_pouting": "๐Ÿ™Ž", - "person_raising_hand": "๐Ÿ™‹", - "person_rowing_boat": "๐Ÿšฃ", - "person_running": "๐Ÿƒ", - "person_shrugging": "๐Ÿคท", - "person_standing": "๐Ÿง", - "person_surfing": "๐Ÿ„", - "person_swimming": "๐ŸŠ", - "person_tipping_hand": "๐Ÿ’", - "person_walking": "๐Ÿšถ", - "person_wearing_turban": "๐Ÿ‘ณ", - "person_with_blond_hair": "๐Ÿ‘ฑ", - "person_with_pouting_face": "๐Ÿ™Ž", - "petri_dish": "๐Ÿงซ", - "pick": "โ›๏ธ", - "pie": "๐Ÿฅง", - "pig": "๐Ÿท", - "pig2": "๐Ÿ–", - "pig_nose": "๐Ÿฝ", - "pill": "๐Ÿ’Š", - "pinching_hand": "๐Ÿค", - "pineapple": "๐Ÿ", - "ping_pong": "๐Ÿ“", - "pisces": "โ™“", - "pizza": "๐Ÿ•", - "place_of_worship": "๐Ÿ›", - "play_button": "โ–ถ", - "play_or_pause_button": "โฏ๏ธ", - "play_pause": "โฏ", - "pleading_face": "๐Ÿฅบ", - "point_down": "๐Ÿ‘‡", - "point_left": "๐Ÿ‘ˆ", - "point_right": "๐Ÿ‘‰", - "point_up": "โ˜๏ธ", - "point_up_2": "๐Ÿ‘†", - "police_car": "๐Ÿš“", - "police_officer": "๐Ÿ‘ฎ", - "poodle": "๐Ÿฉ", - "poop": "๐Ÿ’ฉ", - "popcorn": "๐Ÿฟ", - "post_office": "๐Ÿฃ", - "postal_horn": "๐Ÿ“ฏ", - "postbox": "๐Ÿ“ฎ", - "potable_water": "๐Ÿšฐ", - "potato": "๐Ÿฅ”", - "pouch": "๐Ÿ‘", - "poultry_leg": "๐Ÿ—", - "pound": "๐Ÿ’ท", - "pouting_cat": "๐Ÿ˜พ", - "pray": "๐Ÿ™", - "prayer_beads": "๐Ÿ“ฟ", - "pregnant_woman": "๐Ÿคฐ", - "pretzel": "๐Ÿฅจ", - "prince": "๐Ÿคด", - "princess": "๐Ÿ‘ธ", - "printer": "๐Ÿ–จ", - "probing_cane": "๐Ÿฆฏ", - "punch": "๐Ÿ‘Š", - "purple_circle": "๐ŸŸฃ", - "purple_heart": "๐Ÿ’œ", - "purse": "๐Ÿ‘›", - "pushpin": "๐Ÿ“Œ", - "put_litter_in_its_place": "๐Ÿšฎ", - "puzzle_piece": "๐Ÿงฉ", - "question": "โ“", - "rabbit": "๐Ÿฐ", - "rabbit2": "๐Ÿ‡", - "raccoon": "๐Ÿฆ", - "racehorse": "๐ŸŽ", - "racing_car": "๐ŸŽ", - "radio": "๐Ÿ“ป", - "radio_button": "๐Ÿ”˜", - "radioactive": "โ˜ข๏ธ", - "rage": "๐Ÿ˜ก", - "railway_car": "๐Ÿšƒ", - "railway_track": "๐Ÿ›ค", - "rainbow": "๐ŸŒˆ", - "raised_back_of_hand": "๐Ÿคš", - "raised_hand": "โœ‹", - "raised_hands": "๐Ÿ™Œ", - "raising_hand": "๐Ÿ™‹", - "ram": "๐Ÿ", - "ramen": "๐Ÿœ", - "rat": "๐Ÿ€", - "razor": "๐Ÿช’", - "receipt": "๐Ÿงพ", - "record_button": "โบ๏ธ", - "recycle": "โ™ป", - "recycling_symbol": "โ™ป๏ธ", - "red_car": "๐Ÿš—", - "red_circle": "๐Ÿ”ด", - "red_envelope": "๐Ÿงง", - "red_hair": "๐Ÿฆฐ", - "red_heart": "โค", - "red_square": "๐ŸŸฅ", - "regional_indicator_a": "๐Ÿ‡ฆ", - "regional_indicator_b": "๐Ÿ‡ง", - "regional_indicator_c": "๐Ÿ‡จ", - "regional_indicator_d": "๐Ÿ‡ฉ", - "regional_indicator_e": "๐Ÿ‡ช", - "regional_indicator_f": "๐Ÿ‡ซ", - "regional_indicator_g": "๐Ÿ‡ฌ", - "regional_indicator_h": "๐Ÿ‡ญ", - "regional_indicator_i": "๐Ÿ‡ฎ", - "regional_indicator_j": "๐Ÿ‡ฏ", - "regional_indicator_k": "๐Ÿ‡ฐ", - "regional_indicator_l": "๐Ÿ‡ฑ", - "regional_indicator_m": "๐Ÿ‡ฒ", - "regional_indicator_n": "๐Ÿ‡ณ", - "regional_indicator_o": "๐Ÿ‡ด", - "regional_indicator_p": "๐Ÿ‡ต", - "regional_indicator_q": "๐Ÿ‡ถ", - "regional_indicator_r": "๐Ÿ‡ท", - "regional_indicator_s": "๐Ÿ‡ธ", - "regional_indicator_t": "๐Ÿ‡น", - "regional_indicator_u": "๐Ÿ‡บ", - "regional_indicator_v": "๐Ÿ‡ป", - "regional_indicator_w": "๐Ÿ‡ผ", - "regional_indicator_x": "๐Ÿ‡ฝ", - "regional_indicator_y": "๐Ÿ‡พ", - "regional_indicator_z": "๐Ÿ‡ฟ", - "registered": "ยฎ", - "relieved": "๐Ÿ˜Œ", - "reminder_ribbon": "๐ŸŽ—", - "repeat": "๐Ÿ”", - "repeat_one": "๐Ÿ”‚", - "rescue_workerโ€™s_helmet": "โ›‘๏ธ", - "restroom": "๐Ÿšป", - "reverse_button": "โ—€", - "revolving_hearts": "๐Ÿ’ž", - "rewind": "โช", - "rhino": "๐Ÿฆ", - "rhinoceros": "๐Ÿฆ", - "ribbon": "๐ŸŽ€", - "rice": "๐Ÿš", - "rice_ball": "๐Ÿ™", - "rice_cracker": "๐Ÿ˜", - "rice_scene": "๐ŸŽ‘", - "right_arrow": "โžก๏ธ", - "right_arrow_curving_down": "โคต", - "right_arrow_curving_left": "โ†ฉ", - "right_arrow_curving_up": "โคด", - "right_facing_fist": "๐Ÿคœ", - "rightfacing_fist": "๐Ÿคœ", - "ring": "๐Ÿ’", - "ringed_planet": "๐Ÿช", - "robot": "๐Ÿค–", - "rocket": "๐Ÿš€", - "rofl": "๐Ÿคฃ", - "roll_of_paper": "๐Ÿงป", - "rolledup_newspaper": "๐Ÿ—ž", - "roller_coaster": "๐ŸŽข", - "rolling_eyes": "๐Ÿ™„", - "rolling_on_the_floor_laughing": "๐Ÿคฃ", - "rooster": "๐Ÿ“", - "rose": "๐ŸŒน", - "rosette": "๐Ÿต", - "rotating_light": "๐Ÿšจ", - "round_pushpin": "๐Ÿ“", - "rowboat": "๐Ÿšฃ", - "rugby_football": "๐Ÿ‰", - "runner": "๐Ÿƒ", - "running_shirt_with_sash": "๐ŸŽฝ", - "safety_pin": "๐Ÿงท", - "safety_vest": "๐Ÿฆบ", - "sagittarius": "โ™", - "sailboat": "โ›ต", - "sake": "๐Ÿถ", - "salad": "๐Ÿฅ—", - "salt": "๐Ÿง‚", - "sandal": "๐Ÿ‘ก", - "sandwich": "๐Ÿฅช", - "santa": "๐ŸŽ…", - "sari": "๐Ÿฅป", - "satellite": "๐Ÿ“ก", - "sauropod": "๐Ÿฆ•", - "saxophone": "๐ŸŽท", - "scales": "โš–", - "scarf": "๐Ÿงฃ", - "school": "๐Ÿซ", - "school_satchel": "๐ŸŽ’", - "scissors": "โœ‚", - "scooter": "๐Ÿ›ด", - "scorpion": "๐Ÿฆ‚", - "scorpius": "โ™", - "scream": "๐Ÿ˜ฑ", - "scream_cat": "๐Ÿ™€", - "scroll": "๐Ÿ“œ", - "seat": "๐Ÿ’บ", - "second_place": "๐Ÿฅˆ", - "secret": "ใŠ™", - "see_no_evil": "๐Ÿ™ˆ", - "seedling": "๐ŸŒฑ", - "selfie": "๐Ÿคณ", - "seven": "7โƒฃ", - "shallow_pan_of_food": "๐Ÿฅ˜", - "shamrock": "โ˜˜๏ธ", - "shark": "๐Ÿฆˆ", - "shaved_ice": "๐Ÿง", - "sheep": "๐Ÿ‘", - "shell": "๐Ÿš", - "shield": "๐Ÿ›ก", - "shinto_shrine": "โ›ฉ๏ธ", - "ship": "๐Ÿšข", - "shirt": "๐Ÿ‘•", - "shopping_bags": "๐Ÿ›", - "shopping_cart": "๐Ÿ›’", - "shorts": "๐Ÿฉณ", - "shower": "๐Ÿšฟ", - "shrimp": "๐Ÿฆ", - "shushing_face": "๐Ÿคซ", - "sign_of_the_horns": "๐Ÿค˜", - "signal_strength": "๐Ÿ“ถ", - "six": "6โƒฃ", - "six_pointed_star": "๐Ÿ”ฏ", - "skateboard": "๐Ÿ›น", - "ski": "๐ŸŽฟ", - "skier": "โ›ท๏ธ", - "skull": "๐Ÿ’€", - "skull_and_crossbones": "โ˜ ๏ธ", - "skull_crossbones": "โ˜ ", - "skunk": "๐Ÿฆจ", - "sled": "๐Ÿ›ท", - "sleeping": "๐Ÿ˜ด", - "sleeping_accommodation": "๐Ÿ›Œ", - "sleepy": "๐Ÿ˜ช", - "slight_frown": "๐Ÿ™", - "slight_smile": "๐Ÿ™‚", - "slightly_frowning_face": "๐Ÿ™", - "slot_machine": "๐ŸŽฐ", - "sloth": "๐Ÿฆฅ", - "small_airplane": "๐Ÿ›ฉ", - "small_blue_diamond": "๐Ÿ”น", - "small_orange_diamond": "๐Ÿ”ธ", - "small_red_triangle": "๐Ÿ”บ", - "small_red_triangle_down": "๐Ÿ”ป", - "smile": "๐Ÿ˜„", - "smile_cat": "๐Ÿ˜ธ", - "smiley": "๐Ÿ˜ƒ", - "smiley_cat": "๐Ÿ˜บ", - "smiling": "โ˜บ๏ธ", - "smiling_face": "โ˜บ", - "smiling_face_with_hearts": "๐Ÿฅฐ", - "smiling_imp": "๐Ÿ˜ˆ", - "smirk": "๐Ÿ˜", - "smirk_cat": "๐Ÿ˜ผ", - "smoking": "๐Ÿšฌ", - "snail": "๐ŸŒ", - "snake": "๐Ÿ", - "sneezing_face": "๐Ÿคง", - "snowboarder": "๐Ÿ‚", - "snowcapped_mountain": "๐Ÿ”", - "snowflake": "โ„", - "snowman": "โ›„", - "soap": "๐Ÿงผ", - "sob": "๐Ÿ˜ญ", - "soccer": "โšฝ", - "socks": "๐Ÿงฆ", - "softball": "๐ŸฅŽ", - "soon": "๐Ÿ”œ", - "sos": "๐Ÿ†˜", - "sound": "๐Ÿ”‰", - "space_invader": "๐Ÿ‘พ", - "spade_suit": "โ™ ๏ธ", - "spades": "โ™ ", - "spaghetti": "๐Ÿ", - "sparkle": "โ‡", - "sparkler": "๐ŸŽ‡", - "sparkles": "โœจ", - "sparkling_heart": "๐Ÿ’–", - "speak_no_evil": "๐Ÿ™Š", - "speaker": "๐Ÿ”ˆ", - "speaking_head": "๐Ÿ—ฃ", - "speech_balloon": "๐Ÿ’ฌ", - "speech_left": "๐Ÿ—จ", - "speedboat": "๐Ÿšค", - "spider": "๐Ÿ•ท", - "spider_web": "๐Ÿ•ธ", - "spiral_calendar": "๐Ÿ—“", - "spiral_notepad": "๐Ÿ—’", - "sponge": "๐Ÿงฝ", - "spoon": "๐Ÿฅ„", - "squid": "๐Ÿฆ‘", - "stadium": "๐ŸŸ", - "star": "โญ", - "star2": "๐ŸŒŸ", - "star_and_crescent": "โ˜ช๏ธ", - "star_of_david": "โœก", - "star_struck": "๐Ÿคฉ", - "stars": "๐ŸŒ ", - "starstruck": "๐Ÿคฉ", - "station": "๐Ÿš‰", - "statue_of_liberty": "๐Ÿ—ฝ", - "steam_locomotive": "๐Ÿš‚", - "stethoscope": "๐Ÿฉบ", - "stew": "๐Ÿฒ", - "stop_button": "โน๏ธ", - "stopwatch": "โฑ๏ธ", - "straight_ruler": "๐Ÿ“", - "strawberry": "๐Ÿ“", - "stuck_out_tongue": "๐Ÿ˜›", - "stuck_out_tongue_closed_eyes": "๐Ÿ˜", - "stuck_out_tongue_winking_eye": "๐Ÿ˜œ", - "studio_microphone": "๐ŸŽ™", - "stuffed_flatbread": "๐Ÿฅ™", - "sun": "โ˜€", - "sun_behind_large_cloud": "๐ŸŒฅ", - "sun_behind_rain_cloud": "๐ŸŒฆ", - "sun_behind_small_cloud": "๐ŸŒค", - "sun_with_face": "๐ŸŒž", - "sunflower": "๐ŸŒป", - "sunglasses": "๐Ÿ˜Ž", - "sunny": "โ˜€๏ธ", - "sunrise": "๐ŸŒ…", - "sunrise_over_mountains": "๐ŸŒ„", - "superhero": "๐Ÿฆธ", - "supervillain": "๐Ÿฆน", - "surfer": "๐Ÿ„", - "sushi": "๐Ÿฃ", - "suspension_railway": "๐ŸšŸ", - "swan": "๐Ÿฆข", - "sweat": "๐Ÿ˜“", - "sweat_drops": "๐Ÿ’ฆ", - "sweat_smile": "๐Ÿ˜…", - "sweet_potato": "๐Ÿ ", - "swimmer": "๐ŸŠ", - "symbols": "๐Ÿ”ฃ", - "synagogue": "๐Ÿ•", - "syringe": "๐Ÿ’‰", - "t_rex": "๐Ÿฆ–", - "taco": "๐ŸŒฎ", - "tada": "๐ŸŽ‰", - "takeout_box": "๐Ÿฅก", - "tanabata_tree": "๐ŸŽ‹", - "tangerine": "๐ŸŠ", - "taurus": "โ™‰", - "taxi": "๐Ÿš•", - "tea": "๐Ÿต", - "teddy_bear": "๐Ÿงธ", - "telephone": "โ˜Ž", - "telephone_receiver": "๐Ÿ“ž", - "telescope": "๐Ÿ”ญ", - "tennis": "๐ŸŽพ", - "tent": "โ›บ", - "test_tube": "๐Ÿงช", - "thermometer": "๐ŸŒก", - "thermometer_face": "๐Ÿค’", - "thinking": "๐Ÿค”", - "third_place": "๐Ÿฅ‰", - "thought_balloon": "๐Ÿ’ญ", - "thread": "๐Ÿงต", - "three": "3โƒฃ", - "thumbsdown": "๐Ÿ‘Ž", - "thumbsup": "๐Ÿ‘", - "ticket": "๐ŸŽซ", - "tiger": "๐Ÿฏ", - "tiger2": "๐Ÿ…", - "timer_clock": "โฒ๏ธ", - "tired_face": "๐Ÿ˜ซ", - "tm": "โ„ข", - "toilet": "๐Ÿšฝ", - "tokyo_tower": "๐Ÿ—ผ", - "tomato": "๐Ÿ…", - "tone1": "๐Ÿป", - "tone2": "๐Ÿผ", - "tone3": "๐Ÿฝ", - "tone4": "๐Ÿพ", - "tone5": "๐Ÿฟ", - "tongue": "๐Ÿ‘…", - "toolbox": "๐Ÿงฐ", - "tooth": "๐Ÿฆท", - "top": "๐Ÿ”", - "tophat": "๐ŸŽฉ", - "tornado": "๐ŸŒช", - "track_next": "โญ", - "track_previous": "โฎ", - "trackball": "๐Ÿ–ฒ", - "tractor": "๐Ÿšœ", - "trade_mark": "โ„ข๏ธ", - "traffic_light": "๐Ÿšฅ", - "train": "๐Ÿš‹", - "train2": "๐Ÿš†", - "tram": "๐ŸšŠ", - "trex": "๐Ÿฆ–", - "triangular_flag_on_post": "๐Ÿšฉ", - "triangular_ruler": "๐Ÿ“", - "trident": "๐Ÿ”ฑ", - "triumph": "๐Ÿ˜ค", - "trolleybus": "๐ŸšŽ", - "trophy": "๐Ÿ†", - "tropical_drink": "๐Ÿน", - "tropical_fish": "๐Ÿ ", - "truck": "๐Ÿšš", - "trumpet": "๐ŸŽบ", - "tulip": "๐ŸŒท", - "tumbler_glass": "๐Ÿฅƒ", - "turkey": "๐Ÿฆƒ", - "turtle": "๐Ÿข", - "tv": "๐Ÿ“บ", - "twisted_rightwards_arrows": "๐Ÿ”€", - "two": "2โƒฃ", - "two_hearts": "๐Ÿ’•", - "two_men_holding_hands": "๐Ÿ‘ฌ", - "two_women_holding_hands": "๐Ÿ‘ญ", - "u5272": "๐Ÿˆน", - "u5408": "๐Ÿˆด", - "u55b6": "๐Ÿˆบ", - "u6307": "๐Ÿˆฏ", - "u6708": "๐Ÿˆท", - "u6709": "๐Ÿˆถ", - "u6e80": "๐Ÿˆต", - "u7121": "๐Ÿˆš", - "u7533": "๐Ÿˆธ", - "u7981": "๐Ÿˆฒ", - "u7a7a": "๐Ÿˆณ", - "umbrella": "โ˜”", - "umbrella_on_ground": "โ›ฑ๏ธ", - "unamused": "๐Ÿ˜’", - "underage": "๐Ÿ”ž", - "unicorn": "๐Ÿฆ„", - "unlock": "๐Ÿ”“", - "up": "๐Ÿ†™", - "up_arrow": "โฌ†", - "updown_arrow": "โ†•๏ธ", - "upleft_arrow": "โ†–๏ธ", - "upright_arrow": "โ†—", - "upside_down": "๐Ÿ™ƒ", - "v": "โœŒ๏ธ", - "vampire": "๐Ÿง›", - "vertical_traffic_light": "๐Ÿšฆ", - "vhs": "๐Ÿ“ผ", - "vibration_mode": "๐Ÿ“ณ", - "victory_hand": "โœŒ", - "video_camera": "๐Ÿ“น", - "video_game": "๐ŸŽฎ", - "violin": "๐ŸŽป", - "virgo": "โ™", - "volcano": "๐ŸŒ‹", - "volleyball": "๐Ÿ", - "vs": "๐Ÿ†š", - "vulcan": "๐Ÿ––", - "vulcan_salute": "๐Ÿ––", - "waffle": "๐Ÿง‡", - "walking": "๐Ÿšถ", - "waning_crescent_moon": "๐ŸŒ˜", - "waning_gibbous_moon": "๐ŸŒ–", - "warning": "โš ", - "wastebasket": "๐Ÿ—‘", - "watch": "โŒš", - "water_buffalo": "๐Ÿƒ", - "watermelon": "๐Ÿ‰", - "wave": "๐Ÿ‘‹", - "wavy_dash": "ใ€ฐ๏ธ", - "waxing_crescent_moon": "๐ŸŒ’", - "waxing_gibbous_moon": "๐ŸŒ”", - "wc": "๐Ÿšพ", - "weary": "๐Ÿ˜ฉ", - "wedding": "๐Ÿ’’", - "weightlifter": "๐Ÿ‹", - "whale": "๐Ÿณ", - "whale2": "๐Ÿ‹", - "wheel_of_dharma": "โ˜ธ๏ธ", - "wheelchair": "โ™ฟ", - "white_check_mark": "โœ…", - "white_circle": "โšช", - "white_flower": "๐Ÿ’ฎ", - "white_hair": "๐Ÿฆณ", - "white_heart": "๐Ÿค", - "white_large_square": "โฌœ", - "white_medium_small_square": "โ—ฝ", - "white_medium_square": "โ—ป๏ธ", - "white_small_square": "โ–ซ๏ธ", - "white_square_button": "๐Ÿ”ณ", - "wilted_flower": "๐Ÿฅ€", - "wilted_rose": "๐Ÿฅ€", - "wind_blowing_face": "๐ŸŒฌ", - "wind_chime": "๐ŸŽ", - "wine_glass": "๐Ÿท", - "wink": "๐Ÿ˜‰", - "wolf": "๐Ÿบ", - "woman": "๐Ÿ‘ฉ", - "woman_with_headscarf": "๐Ÿง•", - "womans_clothes": "๐Ÿ‘š", - "womans_hat": "๐Ÿ‘’", - "womens": "๐Ÿšบ", - "woozy_face": "๐Ÿฅด", - "world_map": "๐Ÿ—บ", - "worried": "๐Ÿ˜Ÿ", - "wrench": "๐Ÿ”ง", - "writing_hand": "โœ๏ธ", - "x": "โŒ", - "yarn": "๐Ÿงถ", - "yawning_face": "๐Ÿฅฑ", - "yellow_circle": "๐ŸŸก", - "yellow_heart": "๐Ÿ’›", - "yellow_square": "๐ŸŸจ", - "yen": "๐Ÿ’ด", - "yin_yang": "โ˜ฏ๏ธ", - "yoyo": "๐Ÿช€", - "yum": "๐Ÿ˜‹", - "zany_face": "๐Ÿคช", - "zap": "โšก", - "zebra": "๐Ÿฆ“", - "zero": "0โƒฃ", - "zipper_mouth": "๐Ÿค", - "zombie": "๐ŸงŸ", - "zzz": "๐Ÿ’ค" -} -\ No newline at end of file diff --git a/yarn.lock b/yarn.lock @@ -1433,31 +1433,31 @@ minimatch "^3.1.2" strip-json-comments "^3.1.1" -"@fortawesome/fontawesome-common-types@6.1.2": - version "6.1.2" - resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.1.2.tgz#c1095b1bbabf19f37f9ff0719db38d92a410bcfe" - integrity sha512-wBaAPGz1Awxg05e0PBRkDRuTsy4B3dpBm+zreTTyd9TH4uUM27cAL4xWyWR0rLJCrRwzVsQ4hF3FvM6rqydKPA== +"@fortawesome/fontawesome-common-types@6.2.0": + version "6.2.0" + resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.2.0.tgz#76467a94aa888aeb22aafa43eb6ff889df3a5a7f" + integrity sha512-rBevIsj2nclStJ7AxTdfsa3ovHb1H+qApwrxcTVo+NNdeJiB9V75hsKfrkG5AwNcRUNxrPPiScGYCNmLMoh8pg== -"@fortawesome/fontawesome-svg-core@6.1.2": - version "6.1.2" - resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.1.2.tgz#11e2e8583a7dea75d734e4d0e53d91c63fae7511" - integrity sha512-853G/Htp0BOdXnPoeCPTjFrVwyrJHpe8MhjB/DYE9XjwhnNDfuBCd3aKc2YUYbEfHEcBws4UAA0kA9dymZKGjA== +"@fortawesome/fontawesome-svg-core@6.2.0": + version "6.2.0" + resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.2.0.tgz#11856eaf4dd1d865c442ddea1eed8ee855186ba2" + integrity sha512-Cf2mAAeMWFMzpLC7Y9H1I4o3wEU+XovVJhTiNG8ZNgSQj53yl7OCJaS80K4YjrABWZzbAHVaoHE1dVJ27AAYXw== dependencies: - "@fortawesome/fontawesome-common-types" "6.1.2" + "@fortawesome/fontawesome-common-types" "6.2.0" -"@fortawesome/free-regular-svg-icons@6.1.2": - version "6.1.2" - resolved "https://registry.yarnpkg.com/@fortawesome/free-regular-svg-icons/-/free-regular-svg-icons-6.1.2.tgz#9f04009098addcc11d0d185126f058ed042c3099" - integrity sha512-xR4hA+tAwsaTHGfb+25H1gVU/aJ0Rzu+xIUfnyrhaL13yNQ7TWiI2RvzniAaB+VGHDU2a+Pk96Ve+pkN3/+TTQ== +"@fortawesome/free-regular-svg-icons@6.2.0": + version "6.2.0" + resolved "https://registry.yarnpkg.com/@fortawesome/free-regular-svg-icons/-/free-regular-svg-icons-6.2.0.tgz#947e1f03be17da3a60bfeb2666b5348b19448ce2" + integrity sha512-M1dG+PAmkYMTL9BSUHFXY5oaHwBYfHCPhbJ8qj8JELsc9XCrUJ6eEHWip4q0tE+h9C0DVyFkwIM9t7QYyCpprQ== dependencies: - "@fortawesome/fontawesome-common-types" "6.1.2" + "@fortawesome/fontawesome-common-types" "6.2.0" -"@fortawesome/free-solid-svg-icons@6.1.2": - version "6.1.2" - resolved "https://registry.yarnpkg.com/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.1.2.tgz#491d668b8a6603698d0ce1ac620f66fd22b74c84" - integrity sha512-lTgZz+cMpzjkHmCwOG3E1ilUZrnINYdqMmrkv30EC3XbRsGlbIOL8H9LaNp5SV4g0pNJDfQ4EdTWWaMvdwyLiQ== +"@fortawesome/free-solid-svg-icons@6.2.0": + version "6.2.0" + resolved "https://registry.yarnpkg.com/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.2.0.tgz#8dcde48109354fd7a5ece8ea48d678bb91d4b5f0" + integrity sha512-UjCILHIQ4I8cN46EiQn0CZL/h8AwCGgR//1c4R96Q5viSRwuKVo0NdQEc4bm+69ZwC0dUvjbDqAHF1RR5FA3XA== dependencies: - "@fortawesome/fontawesome-common-types" "6.1.2" + "@fortawesome/fontawesome-common-types" "6.2.0" "@fortawesome/vue-fontawesome@3.0.1": version "3.0.1" @@ -1629,6 +1629,11 @@ dependencies: pointer-tracker "^2.0.3" +"@kazvmoe-infra/unicode-emoji-json@0.4.0": + version "0.4.0" + resolved "https://registry.yarnpkg.com/@kazvmoe-infra/unicode-emoji-json/-/unicode-emoji-json-0.4.0.tgz#555bab2f8d11db74820ef0a2fbe2805b17c22587" + integrity sha512-22OffREdHzD0U6A/W4RaFPV8NR73za6euibtAxNxO/fu5A6TwxRO2lAdbDWKJH9COv/vYs8zqfEiSalXH2nXJA== + "@nightwatch/chai@5.0.2": version "5.0.2" resolved "https://registry.yarnpkg.com/@nightwatch/chai/-/chai-5.0.2.tgz#86b20908fc090dffd5c9567c0392bc6a494cc2e6" @@ -1667,6 +1672,34 @@ resolved "https://registry.yarnpkg.com/@ruffle-rs/ruffle/-/ruffle-0.1.0-nightly.2022.7.12.tgz#c2d77fce7a0e98d51a6535371550e0bff019d0ea" integrity sha512-DFsiT4kdUuSHsYXzHV97e9Ui3FkcsHEg1GyHJipt/lCpCoZ2uRtP41uEz9eNc9ug8jWd7UyXxJmdkkRvs9UHgQ== +"@sinonjs/commons@^1.6.0", "@sinonjs/commons@^1.7.0", "@sinonjs/commons@^1.8.3": + version "1.8.3" + resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-1.8.3.tgz#3802ddd21a50a949b6721ddd72da36e67e7f1b2d" + integrity sha512-xkNcLAn/wZaX14RPlwizcKicDk9G3F8m2nU3L7Ukm5zBgTwiT0wsoFAHx9Jq56fJA1z/7uKGtCRu16sOUCLIHQ== + dependencies: + type-detect "4.0.8" + +"@sinonjs/fake-timers@>=5", "@sinonjs/fake-timers@^9.1.2": + version "9.1.2" + resolved "https://registry.yarnpkg.com/@sinonjs/fake-timers/-/fake-timers-9.1.2.tgz#4eaab737fab77332ab132d396a3c0d364bd0ea8c" + integrity sha512-BPS4ynJW/o92PUR4wgriz2Ud5gpST5vz6GQfMixEDK0Z8ZCUv2M7SkBLykH56T++Xs+8ln9zTGbOvNGIe02/jw== + dependencies: + "@sinonjs/commons" "^1.7.0" + +"@sinonjs/samsam@^6.1.1": + version "6.1.1" + resolved "https://registry.yarnpkg.com/@sinonjs/samsam/-/samsam-6.1.1.tgz#627f7f4cbdb56e6419fa2c1a3e4751ce4f6a00b1" + integrity sha512-cZ7rKJTLiE7u7Wi/v9Hc2fs3Ucc3jrWeMgPHbbTCeVAB2S0wOBbYlkJVeNSL04i7fdhT8wIbDq1zhC/PXTD2SA== + dependencies: + "@sinonjs/commons" "^1.6.0" + lodash.get "^4.4.2" + type-detect "^4.0.8" + +"@sinonjs/text-encoding@^0.7.1": + version "0.7.2" + resolved "https://registry.yarnpkg.com/@sinonjs/text-encoding/-/text-encoding-0.7.2.tgz#5981a8db18b56ba38ef0efb7d995b12aa7b51918" + integrity sha512-sXXKG+uL9IrKqViTtao2Ws6dy0znu9sOaP1di/jKGW1M6VssO8vlpXCQcpZ+jisQ1tTFAC5Jo/EOzFbggBagFQ== + "@socket.io/base64-arraybuffer@~1.0.2": version "1.0.2" resolved "https://registry.yarnpkg.com/@socket.io/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz#568d9beae00b0d835f4f8c53fd55714986492e61" @@ -1854,47 +1887,47 @@ html-tags "^3.1.0" svg-tags "^1.0.0" -"@vue/compiler-core@3.2.37": - version "3.2.37" - resolved "https://registry.yarnpkg.com/@vue/compiler-core/-/compiler-core-3.2.37.tgz#b3c42e04c0e0f2c496ff1784e543fbefe91e215a" - integrity sha512-81KhEjo7YAOh0vQJoSmAD68wLfYqJvoiD4ulyedzF+OEk/bk6/hx3fTNVfuzugIIaTrOx4PGx6pAiBRe5e9Zmg== +"@vue/compiler-core@3.2.38": + version "3.2.38" + resolved "https://registry.yarnpkg.com/@vue/compiler-core/-/compiler-core-3.2.38.tgz#0a2a7bffd2280ac19a96baf5301838a2cf1964d7" + integrity sha512-/FsvnSu7Z+lkd/8KXMa4yYNUiqQrI22135gfsQYVGuh5tqEgOB0XqrUdb/KnCLa5+TmQLPwvyUnKMyCpu+SX3Q== dependencies: "@babel/parser" "^7.16.4" - "@vue/shared" "3.2.37" + "@vue/shared" "3.2.38" estree-walker "^2.0.2" source-map "^0.6.1" -"@vue/compiler-dom@3.2.37": - version "3.2.37" - resolved "https://registry.yarnpkg.com/@vue/compiler-dom/-/compiler-dom-3.2.37.tgz#10d2427a789e7c707c872da9d678c82a0c6582b5" - integrity sha512-yxJLH167fucHKxaqXpYk7x8z7mMEnXOw3G2q62FTkmsvNxu4FQSu5+3UMb+L7fjKa26DEzhrmCxAgFLLIzVfqQ== +"@vue/compiler-dom@3.2.38": + version "3.2.38" + resolved "https://registry.yarnpkg.com/@vue/compiler-dom/-/compiler-dom-3.2.38.tgz#53d04ed0c0c62d1ef259bf82f9b28100a880b6fd" + integrity sha512-zqX4FgUbw56kzHlgYuEEJR8mefFiiyR3u96498+zWPsLeh1WKvgIReoNE+U7gG8bCUdvsrJ0JRmev0Ky6n2O0g== dependencies: - "@vue/compiler-core" "3.2.37" - "@vue/shared" "3.2.37" + "@vue/compiler-core" "3.2.38" + "@vue/shared" "3.2.38" -"@vue/compiler-sfc@3.2.37": - version "3.2.37" - resolved "https://registry.yarnpkg.com/@vue/compiler-sfc/-/compiler-sfc-3.2.37.tgz#3103af3da2f40286edcd85ea495dcb35bc7f5ff4" - integrity sha512-+7i/2+9LYlpqDv+KTtWhOZH+pa8/HnX/905MdVmAcI/mPQOBwkHHIzrsEsucyOIZQYMkXUiTkmZq5am/NyXKkg== +"@vue/compiler-sfc@3.2.38": + version "3.2.38" + resolved "https://registry.yarnpkg.com/@vue/compiler-sfc/-/compiler-sfc-3.2.38.tgz#9e763019471a535eb1fceeaac9d4d18a83f0940f" + integrity sha512-KZjrW32KloMYtTcHAFuw3CqsyWc5X6seb8KbkANSWt3Cz9p2qA8c1GJpSkksFP9ABb6an0FLCFl46ZFXx3kKpg== dependencies: "@babel/parser" "^7.16.4" - "@vue/compiler-core" "3.2.37" - "@vue/compiler-dom" "3.2.37" - "@vue/compiler-ssr" "3.2.37" - "@vue/reactivity-transform" "3.2.37" - "@vue/shared" "3.2.37" + "@vue/compiler-core" "3.2.38" + "@vue/compiler-dom" "3.2.38" + "@vue/compiler-ssr" "3.2.38" + "@vue/reactivity-transform" "3.2.38" + "@vue/shared" "3.2.38" estree-walker "^2.0.2" magic-string "^0.25.7" postcss "^8.1.10" source-map "^0.6.1" -"@vue/compiler-ssr@3.2.37": - version "3.2.37" - resolved "https://registry.yarnpkg.com/@vue/compiler-ssr/-/compiler-ssr-3.2.37.tgz#4899d19f3a5fafd61524a9d1aee8eb0505313cff" - integrity sha512-7mQJD7HdXxQjktmsWp/J67lThEIcxLemz1Vb5I6rYJHR5vI+lON3nPGOH3ubmbvYGt8xEUaAr1j7/tIFWiEOqw== +"@vue/compiler-ssr@3.2.38": + version "3.2.38" + resolved "https://registry.yarnpkg.com/@vue/compiler-ssr/-/compiler-ssr-3.2.38.tgz#933b23bf99e667e5078eefc6ba94cb95fd765dfe" + integrity sha512-bm9jOeyv1H3UskNm4S6IfueKjUNFmi2kRweFIGnqaGkkRePjwEcfCVqyS3roe7HvF4ugsEkhf4+kIvDhip6XzQ== dependencies: - "@vue/compiler-dom" "3.2.37" - "@vue/shared" "3.2.37" + "@vue/compiler-dom" "3.2.38" + "@vue/shared" "3.2.38" "@vue/devtools-api@^6.0.0-beta.11": version "6.1.3" @@ -1906,53 +1939,53 @@ resolved "https://registry.yarnpkg.com/@vue/devtools-api/-/devtools-api-6.2.1.tgz#6f2948ff002ec46df01420dfeff91de16c5b4092" integrity sha512-OEgAMeQXvCoJ+1x8WyQuVZzFo0wcyCmUR3baRVLmKBo1LmYZWMlRiXlux5jd0fqVJu6PfDbOrZItVqUEzLobeQ== -"@vue/reactivity-transform@3.2.37": - version "3.2.37" - resolved "https://registry.yarnpkg.com/@vue/reactivity-transform/-/reactivity-transform-3.2.37.tgz#0caa47c4344df4ae59f5a05dde2a8758829f8eca" - integrity sha512-IWopkKEb+8qpu/1eMKVeXrK0NLw9HicGviJzhJDEyfxTR9e1WtpnnbYkJWurX6WwoFP0sz10xQg8yL8lgskAZg== +"@vue/reactivity-transform@3.2.38": + version "3.2.38" + resolved "https://registry.yarnpkg.com/@vue/reactivity-transform/-/reactivity-transform-3.2.38.tgz#a856c217b2ead99eefb6fddb1d61119b2cb67984" + integrity sha512-3SD3Jmi1yXrDwiNJqQ6fs1x61WsDLqVk4NyKVz78mkaIRh6d3IqtRnptgRfXn+Fzf+m6B1KxBYWq1APj6h4qeA== dependencies: "@babel/parser" "^7.16.4" - "@vue/compiler-core" "3.2.37" - "@vue/shared" "3.2.37" + "@vue/compiler-core" "3.2.38" + "@vue/shared" "3.2.38" estree-walker "^2.0.2" magic-string "^0.25.7" -"@vue/reactivity@3.2.37": - version "3.2.37" - resolved "https://registry.yarnpkg.com/@vue/reactivity/-/reactivity-3.2.37.tgz#5bc3847ac58828e2b78526e08219e0a1089f8848" - integrity sha512-/7WRafBOshOc6m3F7plwzPeCu/RCVv9uMpOwa/5PiY1Zz+WLVRWiy0MYKwmg19KBdGtFWsmZ4cD+LOdVPcs52A== +"@vue/reactivity@3.2.38": + version "3.2.38" + resolved "https://registry.yarnpkg.com/@vue/reactivity/-/reactivity-3.2.38.tgz#d576fdcea98eefb96a1f1ad456e289263e87292e" + integrity sha512-6L4myYcH9HG2M25co7/BSo0skKFHpAN8PhkNPM4xRVkyGl1K5M3Jx4rp5bsYhvYze2K4+l+pioN4e6ZwFLUVtw== dependencies: - "@vue/shared" "3.2.37" + "@vue/shared" "3.2.38" -"@vue/runtime-core@3.2.37": - version "3.2.37" - resolved "https://registry.yarnpkg.com/@vue/runtime-core/-/runtime-core-3.2.37.tgz#7ba7c54bb56e5d70edfc2f05766e1ca8519966e3" - integrity sha512-JPcd9kFyEdXLl/i0ClS7lwgcs0QpUAWj+SKX2ZC3ANKi1U4DOtiEr6cRqFXsPwY5u1L9fAjkinIdB8Rz3FoYNQ== +"@vue/runtime-core@3.2.38": + version "3.2.38" + resolved "https://registry.yarnpkg.com/@vue/runtime-core/-/runtime-core-3.2.38.tgz#d19cf591c210713f80e6a94ffbfef307c27aea06" + integrity sha512-kk0qiSiXUU/IKxZw31824rxmFzrLr3TL6ZcbrxWTKivadoKupdlzbQM4SlGo4MU6Zzrqv4fzyUasTU1jDoEnzg== dependencies: - "@vue/reactivity" "3.2.37" - "@vue/shared" "3.2.37" + "@vue/reactivity" "3.2.38" + "@vue/shared" "3.2.38" -"@vue/runtime-dom@3.2.37": - version "3.2.37" - resolved "https://registry.yarnpkg.com/@vue/runtime-dom/-/runtime-dom-3.2.37.tgz#002bdc8228fa63949317756fb1e92cdd3f9f4bbd" - integrity sha512-HimKdh9BepShW6YozwRKAYjYQWg9mQn63RGEiSswMbW+ssIht1MILYlVGkAGGQbkhSh31PCdoUcfiu4apXJoPw== +"@vue/runtime-dom@3.2.38": + version "3.2.38" + resolved "https://registry.yarnpkg.com/@vue/runtime-dom/-/runtime-dom-3.2.38.tgz#fec711f65c2485991289fd4798780aa506469b48" + integrity sha512-4PKAb/ck2TjxdMSzMsnHViOrrwpudk4/A56uZjhzvusoEU9xqa5dygksbzYepdZeB5NqtRw5fRhWIiQlRVK45A== dependencies: - "@vue/runtime-core" "3.2.37" - "@vue/shared" "3.2.37" + "@vue/runtime-core" "3.2.38" + "@vue/shared" "3.2.38" csstype "^2.6.8" -"@vue/server-renderer@3.2.37": - version "3.2.37" - resolved "https://registry.yarnpkg.com/@vue/server-renderer/-/server-renderer-3.2.37.tgz#840a29c8dcc29bddd9b5f5ffa22b95c0e72afdfc" - integrity sha512-kLITEJvaYgZQ2h47hIzPh2K3jG8c1zCVbp/o/bzQOyvzaKiCquKS7AaioPI28GNxIsE/zSx+EwWYsNxDCX95MA== +"@vue/server-renderer@3.2.38": + version "3.2.38" + resolved "https://registry.yarnpkg.com/@vue/server-renderer/-/server-renderer-3.2.38.tgz#01a4c0f218e90b8ad1815074208a1974ded109aa" + integrity sha512-pg+JanpbOZ5kEfOZzO2bt02YHd+ELhYP8zPeLU1H0e7lg079NtuuSB8fjLdn58c4Ou8UQ6C1/P+528nXnLPAhA== dependencies: - "@vue/compiler-ssr" "3.2.37" - "@vue/shared" "3.2.37" + "@vue/compiler-ssr" "3.2.38" + "@vue/shared" "3.2.38" -"@vue/shared@3.2.37": - version "3.2.37" - resolved "https://registry.yarnpkg.com/@vue/shared/-/shared-3.2.37.tgz#8e6adc3f2759af52f0e85863dfb0b711ecc5c702" - integrity sha512-4rSJemR2NQIo9Klm1vabqWjD8rs/ZaJSzMxkMNeJS6lHiUjjUeYFbooN19NgFjztubEKh3WlZUeOLVdbbUWHsw== +"@vue/shared@3.2.38": + version "3.2.38" + resolved "https://registry.yarnpkg.com/@vue/shared/-/shared-3.2.38.tgz#e823f0cb2e85b6bf43430c0d6811b1441c300f3c" + integrity sha512-dTyhTIRmGXBjxJE+skC8tTWCGLCVc4wQgRRLt8+O9p5ewBAjoBwtCAkLPrtToSr1xltoe3st21Pv953aOZ7alg== "@vue/test-utils@2.0.2": version "2.0.2" @@ -1966,12 +1999,12 @@ dependencies: vue-demi "^0.13.4" -"@vuelidate/validators@2.0.0-alpha.31": - version "2.0.0-alpha.31" - resolved "https://registry.yarnpkg.com/@vuelidate/validators/-/validators-2.0.0-alpha.31.tgz#04d63307bc0a12db9f7ad94243350b83aacee998" - integrity sha512-+MFA9nZ7Y9zCpq383/voPDk/hiAmu6KqiJJhLOYB/FmrUPVoyKnuKnI9Bwiq8ok9GZlVkI8BnIrKPKGj9QpwiQ== +"@vuelidate/validators@2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@vuelidate/validators/-/validators-2.0.0.tgz#1ddd86c6c81b2cfbb5720961e951cc53ec0a80be" + integrity sha512-fQQcmDWfz7pyH5/JPi0Ng2GEgNK1pUHn/Z/j5rG/Q+HwhgIXvJblTPcZwKOj1ABL7V4UVuGKECvZCDHNGOwdrg== dependencies: - vue-demi "^0.13.4" + vue-demi "^0.13.11" "@webassemblyjs/ast@1.11.1": version "1.11.1" @@ -3373,16 +3406,16 @@ didyoumean@1.2.2: resolved "https://registry.yarnpkg.com/didyoumean/-/didyoumean-1.2.2.tgz#989346ffe9e839b4555ecf5666edea0d3e8ad037" integrity sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw== -diff@3.5.0, diff@^3.1.0: - version "3.5.0" - resolved "https://registry.yarnpkg.com/diff/-/diff-3.5.0.tgz#800c0dd1e0a8bfbc95835c202ad220fe317e5a12" - integrity sha512-A46qtFgd+g7pDZinpnwiRJtxbC1hpgf0uzP3iG89scHk0AUC7A1TGxf5OiiOUv/JMZR8GOt8hL900hV0bOy5xA== - diff@5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/diff/-/diff-5.0.0.tgz#7ed6ad76d859d030787ec35855f5b1daf31d852b" integrity sha512-/VTCrvm5Z0JGty/BWHljh+BAiw3IK+2j87NGMu8Nwc/f48WoDAC395uomO9ZD117ZOBaHmkX1oyLvkVM/aIT3w== +diff@^5.0.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/diff/-/diff-5.1.0.tgz#bc52d298c5ea8df9194800224445ed43ffc87e40" + integrity sha512-D+mk+qE8VC/PAUrlAU34N+VfXev0ghe5ywmpqrawphmVZc1bEfn56uo9qpyGp1p4xpzOHkSW4ztBd6L7Xx4ACw== + dijkstrajs@^1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/dijkstrajs/-/dijkstrajs-1.0.2.tgz#2e48c0d3b825462afe75ab4ad5e829c8ece36257" @@ -4213,12 +4246,6 @@ form-data@^4.0.0: combined-stream "^1.0.8" mime-types "^2.1.12" -formatio@1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/formatio/-/formatio-1.2.0.tgz#f3b2167d9068c4698a8d51f4f760a39a54d818eb" - dependencies: - samsam "1.x" - forwarded@0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.2.0.tgz#2269936428aad4c15c7ebe9779a84bf0b2a81811" @@ -5210,6 +5237,11 @@ jszip@^3.10.0: readable-stream "~2.3.6" setimmediate "^1.0.5" +just-extend@^4.0.2: + version "4.2.1" + resolved "https://registry.yarnpkg.com/just-extend/-/just-extend-4.2.1.tgz#ef5e589afb61e5d66b24eca749409a8939a8c744" + integrity sha512-g3UB796vUFIY90VIv/WX3L2c8CS2MdWUww3CNrYmqza1Fg0DURc2K/O4YrnklBdQarSJ/y8JnJYDGc+1iumQjg== + karma-coverage@2.2.0: version "2.2.0" resolved "https://registry.yarnpkg.com/karma-coverage/-/karma-coverage-2.2.0.tgz#64f838b66b71327802e7f6f6c39d569b7024e40c" @@ -5530,6 +5562,11 @@ lodash.find@^3.2.1: lodash.isarray "^3.0.0" lodash.keys "^3.0.0" +lodash.get@^4.4.2: + version "4.4.2" + resolved "https://registry.yarnpkg.com/lodash.get/-/lodash.get-4.4.2.tgz#2d177f652fa31e939b4438d5341499dfa3825e99" + integrity sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ== + lodash.isarguments@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz#2f573d85c6a24289ff00663b491c1d338ff3458a" @@ -5672,11 +5709,6 @@ log4js@^6.4.1: rfdc "^1.3.0" streamroller "^3.0.6" -lolex@1.6.0, lolex@^1.6.0: - version "1.6.0" - resolved "https://registry.yarnpkg.com/lolex/-/lolex-1.6.0.tgz#3a9a0283452a47d7439e72731b9e07d7386e49f6" - integrity sha1-OpoCg0UqR9dDnnJzG54H1zhuSfY= - longest-streak@^2.0.0: version "2.0.4" resolved "https://registry.yarnpkg.com/longest-streak/-/longest-streak-2.0.4.tgz#b8599957da5b5dab64dee3fe316fa774597d90e4" @@ -5696,6 +5728,11 @@ lower-case@^2.0.2: dependencies: tslib "^2.0.3" +lozad@1.16.0: + version "1.16.0" + resolved "https://registry.yarnpkg.com/lozad/-/lozad-1.16.0.tgz#86ce732c64c69926ccdebb81c8c90bb3735948b4" + integrity sha512-JBr9WjvEFeKoyim3svo/gsQPTkgG/mOHJmDctZ/+U9H3ymUuvEkqpn8bdQMFsvTMcyRJrdJkLv0bXqGm0sP72w== + lru-cache@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-6.0.0.tgz#6d6fe6570ebd96aaf90fcad1dafa3b2566db3a94" @@ -6064,10 +6101,6 @@ nanoid@^3.3.4: resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.4.tgz#730b67e3cd09e2deacf03c027c81c9d9dbc5e8ab" integrity sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw== -native-promise-only@^0.8.1: - version "0.8.1" - resolved "https://registry.yarnpkg.com/native-promise-only/-/native-promise-only-0.8.1.tgz#20a318c30cb45f71fe7adfbf7b21c99c1472ef11" - natural-compare@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" @@ -6118,6 +6151,17 @@ nightwatch@2.3.3: stacktrace-parser "^0.1.10" strip-ansi "6.0.1" +nise@^5.1.1: + version "5.1.1" + resolved "https://registry.yarnpkg.com/nise/-/nise-5.1.1.tgz#ac4237e0d785ecfcb83e20f389185975da5c31f3" + integrity sha512-yr5kW2THW1AkxVmCnKEh4nbYkJdB3I7LUkiUgOvEkOp414mc2UMaHMA7pjq1nYowhdoJZGwEKGaQVbxfpWj10A== + dependencies: + "@sinonjs/commons" "^1.8.3" + "@sinonjs/fake-timers" ">=5" + "@sinonjs/text-encoding" "^0.7.1" + just-extend "^4.0.2" + path-to-regexp "^1.7.0" + no-case@^3.0.4: version "3.0.4" resolved "https://registry.yarnpkg.com/no-case/-/no-case-3.0.4.tgz#d361fd5c9800f558551a8369fc0dcd4662b6124d" @@ -7330,10 +7374,6 @@ safe-buffer@^5.1.0, safe-buffer@~5.1.0, safe-buffer@~5.1.1: version "2.1.2" resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" -samsam@1.x, samsam@^1.1.3: - version "1.3.0" - resolved "https://registry.yarnpkg.com/samsam/-/samsam-1.3.0.tgz#8d1d9350e25622da30de3e44ba692b5221ab7c50" - sass-loader@13.0.2: version "13.0.2" resolved "https://registry.yarnpkg.com/sass-loader/-/sass-loader-13.0.2.tgz#e81a909048e06520e9f2ff25113a801065adb3fe" @@ -7342,10 +7382,10 @@ sass-loader@13.0.2: klona "^2.0.4" neo-async "^2.6.2" -sass@1.54.5: - version "1.54.5" - resolved "https://registry.yarnpkg.com/sass/-/sass-1.54.5.tgz#93708f5560784f6ff2eab8542ade021a4a947b3a" - integrity sha512-p7DTOzxkUPa/63FU0R3KApkRHwcVZYC0PLnLm5iyZACyp15qSi32x7zVUhRdABAATmkALqgGrjCJAcWvobmhHw== +sass@1.55.0: + version "1.55.0" + resolved "https://registry.yarnpkg.com/sass/-/sass-1.55.0.tgz#0c4d3c293cfe8f8a2e8d3b666e1cf1bff8065d1c" + integrity sha512-Pk+PMy7OGLs9WaxZGJMn7S96dvlyVBwwtToX895WmCpAOr5YiJYEUJfiJidMuKb613z2xNWcXCHEuOvjZbqC6A== dependencies: chokidar ">=3.0.0 <4.0.0" immutable "^4.0.0" @@ -7522,19 +7562,17 @@ sinon-chai@3.7.0: resolved "https://registry.yarnpkg.com/sinon-chai/-/sinon-chai-3.7.0.tgz#cfb7dec1c50990ed18c153f1840721cf13139783" integrity sha512-mf5NURdUaSdnatJx3uhoBOrY9dtL19fiOtAdT1Azxg3+lNJFiuN0uzaU3xX1LeAfL17kHQhTAJgpsfhbMJMY2g== -sinon@2.4.1: - version "2.4.1" - resolved "https://registry.yarnpkg.com/sinon/-/sinon-2.4.1.tgz#021fd64b54cb77d9d2fb0d43cdedfae7629c3a36" - integrity sha512-vFTrO9Wt0ECffDYIPSP/E5bBugt0UjcBQOfQUMh66xzkyPEnhl/vM2LRZi2ajuTdkH07sA6DzrM6KvdvGIH8xw== +sinon@14.0.0: + version "14.0.0" + resolved "https://registry.yarnpkg.com/sinon/-/sinon-14.0.0.tgz#203731c116d3a2d58dc4e3cbe1f443ba9382a031" + integrity sha512-ugA6BFmE+WrJdh0owRZHToLd32Uw3Lxq6E6LtNRU+xTVBefx632h03Q7apXWRsRdZAJ41LB8aUfn2+O4jsDNMw== dependencies: - diff "^3.1.0" - formatio "1.2.0" - lolex "^1.6.0" - native-promise-only "^0.8.1" - path-to-regexp "^1.7.0" - samsam "^1.1.3" - text-encoding "0.6.4" - type-detect "^4.0.0" + "@sinonjs/commons" "^1.8.3" + "@sinonjs/fake-timers" "^9.1.2" + "@sinonjs/samsam" "^6.1.1" + diff "^5.0.0" + nise "^5.1.1" + supports-color "^7.2.0" slash@^3.0.0: version "3.0.0" @@ -7893,6 +7931,13 @@ supports-color@^7.1.0: dependencies: has-flag "^4.0.0" +supports-color@^7.2.0: + version "7.2.0" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da" + integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw== + dependencies: + has-flag "^4.0.0" + supports-preserve-symlinks-flag@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09" @@ -7960,10 +8005,6 @@ terser@^5.10.0, terser@^5.14.1: commander "^2.20.0" source-map-support "~0.5.20" -text-encoding@0.6.4: - version "0.6.4" - resolved "https://registry.yarnpkg.com/text-encoding/-/text-encoding-0.6.4.tgz#e399a982257a276dae428bb92845cb71bdc26d19" - text-table@0.2.0, text-table@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4" @@ -8023,7 +8064,7 @@ type-check@^0.4.0, type-check@~0.4.0: dependencies: prelude-ls "^1.2.1" -type-detect@4.0.8, type-detect@^4.0.0, type-detect@^4.0.5: +type-detect@4.0.8, type-detect@^4.0.0, type-detect@^4.0.5, type-detect@^4.0.8: version "4.0.8" resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-4.0.8.tgz#7646fb5f18871cfbb7749e69bd39a6388eb7450c" @@ -8239,6 +8280,11 @@ void-elements@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/void-elements/-/void-elements-2.0.1.tgz#c066afb582bb1cb4128d60ea92392e94d5e9dbec" +vue-demi@^0.13.11: + version "0.13.11" + resolved "https://registry.yarnpkg.com/vue-demi/-/vue-demi-0.13.11.tgz#7d90369bdae8974d87b1973564ad390182410d99" + integrity sha512-IR8HoEEGM65YY3ZJYAjMlKygDQn25D5ajNFNoKh9RSDMQtlzCxtfQjdQgv9jjK+m3377SsJXY8ysq8kLCZL25A== + vue-demi@^0.13.4: version "0.13.5" resolved "https://registry.yarnpkg.com/vue-demi/-/vue-demi-0.13.5.tgz#d5eddbc9eaefb89ce5995269d1fa6b0486312092" @@ -8299,16 +8345,16 @@ vue-template-compiler@2.7.10: de-indent "^1.0.2" he "^1.2.0" -vue@3.2.37: - version "3.2.37" - resolved "https://registry.yarnpkg.com/vue/-/vue-3.2.37.tgz#da220ccb618d78579d25b06c7c21498ca4e5452e" - integrity sha512-bOKEZxrm8Eh+fveCqS1/NkG/n6aMidsI6hahas7pa0w/l7jkbssJVsRhVDs07IdDq7h9KHswZOgItnwJAgtVtQ== +vue@3.2.38: + version "3.2.38" + resolved "https://registry.yarnpkg.com/vue/-/vue-3.2.38.tgz#cda3a414631745b194971219318a792dbbccdec0" + integrity sha512-hHrScEFSmDAWL0cwO4B6WO7D3sALZPbfuThDsGBebthrNlDxdJZpGR3WB87VbjpPh96mep1+KzukYEhpHDFa8Q== dependencies: - "@vue/compiler-dom" "3.2.37" - "@vue/compiler-sfc" "3.2.37" - "@vue/runtime-dom" "3.2.37" - "@vue/server-renderer" "3.2.37" - "@vue/shared" "3.2.37" + "@vue/compiler-dom" "3.2.38" + "@vue/compiler-sfc" "3.2.38" + "@vue/runtime-dom" "3.2.38" + "@vue/server-renderer" "3.2.38" + "@vue/shared" "3.2.38" vuex@4.0.2: version "4.0.2"