ソースを参照

Merge branch 'from/develop/tusooa/grouped-emoji-picker' into 'develop'

Group emojis into packs in emoji picker

See merge request pleroma/pleroma-fe!1408
HJ 2 年 前
コミット
03b61f0a9c

+ 1 - 1
.babelrc

@@ -1,5 +1,5 @@
 {
   "presets": ["@babel/preset-env"],
   "plugins": ["@babel/plugin-transform-runtime", "lodash", "@vue/babel-plugin-jsx"],
-  "comments": false
+  "comments": true
 }

+ 1 - 0
.gitignore

@@ -7,3 +7,4 @@ test/e2e/reports
 selenium-debug.log
 .idea/
 config/local.json
+static/emoji.json

+ 3 - 0
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)

+ 3 - 0
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

+ 27 - 0
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.')
+  }
+}

+ 2 - 1
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: {

+ 2 - 0
package.json

@@ -23,6 +23,7 @@
     "@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",
@@ -34,6 +35,7 @@
     "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",

+ 47 - 3
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()

+ 2 - 1
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>

+ 17 - 17
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
     })
 }
 

+ 210 - 92
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
+      }
     }
   }
 }

+ 44 - 8
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;
+      }
     }
 
   }

+ 38 - 14
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">

+ 3 - 3
src/components/post_status_form/post_status_form.js

@@ -189,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
@@ -198,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 || []

+ 2 - 2
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

+ 2 - 2
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
         ]
       })

+ 25 - 2
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')
+    }
   }
 }
 

+ 3 - 2
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"

+ 13 - 1
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."

+ 53 - 0
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
+}

+ 13 - 33
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

+ 1 - 0
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':

+ 133 - 16
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)

+ 0 - 1431
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": "💤"
-}

+ 10 - 0
yarn.lock

@@ -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"
@@ -5733,6 +5738,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"