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

Group emojis into packs in emoji picker

See merge request pleroma/pleroma-fe!1408
This commit is contained in:
HJ 2022-09-22 08:11:25 +00:00
commit 03b61f0a9c
25 changed files with 660 additions and 1634 deletions

View File

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

1
.gitignore vendored
View File

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

View File

@ -18,6 +18,9 @@ console.log(
var spinner = ora('building for production...') var spinner = ora('building for production...')
spinner.start() spinner.start()
var updateEmoji = require('./update-emoji').updateEmoji
updateEmoji()
var assetsPath = path.join(config.build.assetsRoot, config.build.assetsSubDirectory) var assetsPath = path.join(config.build.assetsRoot, config.build.assetsSubDirectory)
rm('-rf', assetsPath) rm('-rf', assetsPath)
mkdir('-p', assetsPath) mkdir('-p', assetsPath)

View File

@ -10,6 +10,9 @@ var webpackConfig = process.env.NODE_ENV === 'testing'
? require('./webpack.prod.conf') ? require('./webpack.prod.conf')
: require('./webpack.dev.conf') : require('./webpack.dev.conf')
var updateEmoji = require('./update-emoji').updateEmoji
updateEmoji()
// default port where dev server listens for incoming traffic // default port where dev server listens for incoming traffic
var port = process.env.PORT || config.dev.port var port = process.env.PORT || config.dev.port
// Define HTTP proxies to your custom API backend // Define HTTP proxies to your custom API backend

27
build/update-emoji.js Normal file
View File

@ -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.')
}
}

View File

@ -24,7 +24,8 @@ module.exports = {
output: { output: {
path: config.build.assetsRoot, path: config.build.assetsRoot,
publicPath: process.env.NODE_ENV === 'production' ? config.build.assetsPublicPath : config.dev.assetsPublicPath, publicPath: process.env.NODE_ENV === 'production' ? config.build.assetsPublicPath : config.dev.assetsPublicPath,
filename: '[name].js' filename: '[name].js',
chunkFilename: '[name].js'
}, },
optimization: { optimization: {
splitChunks: { splitChunks: {

View File

@ -23,6 +23,7 @@
"@fortawesome/free-solid-svg-icons": "6.2.0", "@fortawesome/free-solid-svg-icons": "6.2.0",
"@fortawesome/vue-fontawesome": "3.0.1", "@fortawesome/vue-fontawesome": "3.0.1",
"@kazvmoe-infra/pinch-zoom-element": "1.2.0", "@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", "@ruffle-rs/ruffle": "0.1.0-nightly.2022.7.12",
"@vuelidate/core": "2.0.0-alpha.44", "@vuelidate/core": "2.0.0-alpha.44",
"@vuelidate/validators": "2.0.0-alpha.31", "@vuelidate/validators": "2.0.0-alpha.31",
@ -34,6 +35,7 @@
"escape-html": "1.0.3", "escape-html": "1.0.3",
"js-cookie": "3.0.1", "js-cookie": "3.0.1",
"localforage": "1.10.0", "localforage": "1.10.0",
"lozad": "^1.16.0",
"parse-link-header": "2.0.0", "parse-link-header": "2.0.0",
"phoenix": "1.6.2", "phoenix": "1.6.2",
"punycode.js": "2.1.0", "punycode.js": "2.1.0",

View File

@ -3,7 +3,7 @@ import EmojiPicker from '../emoji_picker/emoji_picker.vue'
import UnicodeDomainIndicator from '../unicode_domain_indicator/unicode_domain_indicator.vue' import UnicodeDomainIndicator from '../unicode_domain_indicator/unicode_domain_indicator.vue'
import { take } from 'lodash' import { take } from 'lodash'
import { findOffset } from '../../services/offset_finder/offset_finder.service.js' import { findOffset } from '../../services/offset_finder/offset_finder.service.js'
import { ensureFinalFallback } from '../../i18n/languages.js'
import { library } from '@fortawesome/fontawesome-svg-core' import { library } from '@fortawesome/fontawesome-svg-core'
import { import {
faSmileBeam faSmileBeam
@ -143,6 +143,51 @@ const EmojiInput = {
const word = Completion.wordAtPosition(this.modelValue, this.caret - 1) || {} const word = Completion.wordAtPosition(this.modelValue, this.caret - 1) || {}
return word 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 () { mounted () {
@ -181,7 +226,7 @@ const EmojiInput = {
const firstchar = newWord.charAt(0) const firstchar = newWord.charAt(0)
this.suggestions = [] this.suggestions = []
if (newWord === firstchar) return 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 // Async: cancel if textAtCaret has changed during wait
if (this.textAtCaret !== newWord) return if (this.textAtCaret !== newWord) return
if (matchedSuggestions.length <= 0) return if (matchedSuggestions.length <= 0) return
@ -207,7 +252,6 @@ const EmojiInput = {
}, },
triggerShowPicker () { triggerShowPicker () {
this.showPicker = true this.showPicker = true
this.$refs.picker.startEmojiLoad()
this.$nextTick(() => { this.$nextTick(() => {
this.scrollIntoView() this.scrollIntoView()
this.focusPickerInput() this.focusPickerInput()

View File

@ -19,6 +19,7 @@
v-if="enableEmojiPicker" v-if="enableEmojiPicker"
ref="picker" ref="picker"
:class="{ hide: !showPicker }" :class="{ hide: !showPicker }"
:showing="showPicker"
:enable-sticker-picker="enableStickerPicker" :enable-sticker-picker="enableStickerPicker"
class="emoji-picker-panel" class="emoji-picker-panel"
@emoji="insert" @emoji="insert"
@ -63,7 +64,7 @@
v-if="!suggestion.user" v-if="!suggestion.user"
class="displayText" class="displayText"
> >
{{ suggestion.displayText }} {{ maybeLocalizedEmojiName(suggestion) }}
</span> </span>
<span class="detailText">{{ suggestion.detailText }}</span> <span class="detailText">{{ suggestion.detailText }}</span>
</div> </div>

View File

@ -2,7 +2,7 @@
* suggest - generates a suggestor function to be used by emoji-input * suggest - generates a suggestor function to be used by emoji-input
* data: object providing source information for specific types of suggestions: * data: object providing source information for specific types of suggestions:
* data.emoji - optional, an array of all emoji available i.e. * 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 * data.users - optional, an array of all known users
* updateUsersList - optional, a function to search and append to users * updateUsersList - optional, a function to search and append to users
* *
@ -13,10 +13,10 @@
export default data => { export default data => {
const emojiCurry = suggestEmoji(data.emoji) const emojiCurry = suggestEmoji(data.emoji)
const usersCurry = data.store && suggestUsers(data.store) const usersCurry = data.store && suggestUsers(data.store)
return input => { return (input, nameKeywordLocalizer) => {
const firstChar = input[0] const firstChar = input[0]
if (firstChar === ':' && data.emoji) { if (firstChar === ':' && data.emoji) {
return emojiCurry(input) return emojiCurry(input, nameKeywordLocalizer)
} }
if (firstChar === '@' && usersCurry) { if (firstChar === '@' && usersCurry) {
return usersCurry(input) 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) const noPrefix = input.toLowerCase().substr(1)
return emojis return emojis
.filter(({ displayText }) => displayText.toLowerCase().match(noPrefix)) .map(emoji => ({ ...emoji, ...nameKeywordLocalizer(emoji) }))
.sort((a, b) => { .filter((emoji) => (emoji.names.concat(emoji.keywords)).filter(kw => kw.toLowerCase().match(noPrefix)).length)
let aScore = 0 .map(k => {
let bScore = 0 let score = 0
// An exact match always wins // An exact match always wins
aScore += a.displayText.toLowerCase() === noPrefix ? 200 : 0 score += Math.max(...k.names.map(name => name.toLowerCase() === noPrefix ? 200 : 0), 0)
bScore += b.displayText.toLowerCase() === noPrefix ? 200 : 0
// Prioritize custom emoji a lot // Prioritize custom emoji a lot
aScore += a.imageUrl ? 100 : 0 score += k.imageUrl ? 100 : 0
bScore += b.imageUrl ? 100 : 0
// Prioritize prefix matches somewhat // Prioritize prefix matches somewhat
aScore += a.displayText.toLowerCase().startsWith(noPrefix) ? 10 : 0 score += Math.max(...k.names.map(kw => kw.toLowerCase().startsWith(noPrefix) ? 10 : 0), 0)
bScore += b.displayText.toLowerCase().startsWith(noPrefix) ? 10 : 0
// Sort by length // Sort by length
aScore -= a.displayText.length score -= k.displayText.length
bScore -= b.displayText.length
k.score = score
return k
})
.sort((a, b) => {
// Break ties alphabetically // Break ties alphabetically
const alphabetically = a.displayText > b.displayText ? 0.5 : -0.5 const alphabetically = a.displayText > b.displayText ? 0.5 : -0.5
return bScore - aScore + alphabetically return b.score - a.score + alphabetically
}) })
} }

View File

@ -1,33 +1,76 @@
import { defineAsyncComponent } from 'vue' import { defineAsyncComponent } from 'vue'
import Checkbox from '../checkbox/checkbox.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 { library } from '@fortawesome/fontawesome-svg-core'
import { import {
faBoxOpen, faBoxOpen,
faStickyNote, faStickyNote,
faSmileBeam faSmileBeam,
faSmile,
faUser,
faPaw,
faIceCream,
faBus,
faBasketballBall,
faLightbulb,
faCode,
faFlag
} from '@fortawesome/free-solid-svg-icons' } from '@fortawesome/free-solid-svg-icons'
import { trim } from 'lodash' import { debounce, trim } from 'lodash'
library.add( library.add(
faBoxOpen, faBoxOpen,
faStickyNote, faStickyNote,
faSmileBeam faSmileBeam,
faSmile,
faUser,
faPaw,
faIceCream,
faBus,
faBasketballBall,
faLightbulb,
faCode,
faFlag
) )
// At widest, approximately 20 emoji are visible in a row, const UNICODE_EMOJI_GROUP_ICON = {
// loading 3 rows, could be overkill for narrow picker 'smileys-and-emotion': 'smile',
const LOAD_EMOJI_BY = 60 '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 maybeLocalizedKeywords = (emoji, languages, nameLocalizer) => {
const LOAD_EMOJI_MARGIN = 64 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 if (keyword === '') return list
const keywordLowercase = keyword.toLowerCase() const keywordLowercase = keyword.toLowerCase()
const orderedEmojiList = [] const orderedEmojiList = []
for (const emoji of list) { 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 (indexOfKeyword > -1) {
if (!Array.isArray(orderedEmojiList[indexOfKeyword])) { if (!Array.isArray(orderedEmojiList[indexOfKeyword])) {
orderedEmojiList[indexOfKeyword] = [] orderedEmojiList[indexOfKeyword] = []
@ -44,6 +87,10 @@ const EmojiPicker = {
required: false, required: false,
type: Boolean, type: Boolean,
default: false default: false
},
showing: {
required: true,
type: Boolean
} }
}, },
data () { data () {
@ -53,16 +100,26 @@ const EmojiPicker = {
showingStickers: false, showingStickers: false,
groupsScrolledClass: 'scrolled-top', groupsScrolledClass: 'scrolled-top',
keepOpen: false, keepOpen: false,
customEmojiBufferSlice: LOAD_EMOJI_BY,
customEmojiTimeout: null, customEmojiTimeout: null,
customEmojiLoadAllConfirmed: false // Lazy-load only after the first time `showing` becomes true.
contentLoaded: false,
groupRefs: {},
emojiRefs: {},
filteredEmojiGroups: []
} }
}, },
components: { components: {
StickerPicker: defineAsyncComponent(() => import('../sticker_picker/sticker_picker.vue')), StickerPicker: defineAsyncComponent(() => import('../sticker_picker/sticker_picker.vue')),
Checkbox Checkbox,
StillImage
}, },
methods: { methods: {
setGroupRef (name) {
return el => { this.groupRefs[name] = el }
},
setEmojiRef (name) {
return el => { this.emojiRefs[name] = el }
},
onStickerUploaded (e) { onStickerUploaded (e) {
this.$emit('sticker-uploaded', e) this.$emit('sticker-uploaded', e)
}, },
@ -77,10 +134,38 @@ const EmojiPicker = {
const target = (e && e.target) || this.$refs['emoji-groups'] const target = (e && e.target) || this.$refs['emoji-groups']
this.updateScrolledClass(target) this.updateScrolledClass(target)
this.scrolledGroup(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) { highlight (key) {
const ref = this.$refs['group-' + key] const ref = this.groupRefs['group-' + key]
const top = ref.offsetTop const top = ref.offsetTop
this.setShowStickers(false) this.setShowStickers(false)
this.activeGroup = key this.activeGroup = key
@ -97,73 +182,90 @@ const EmojiPicker = {
this.groupsScrolledClass = 'scrolled-middle' 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()
}
},
scrolledGroup (target) {
const top = target.scrollTop + 5
this.$nextTick(() => {
this.emojisView.forEach(group => {
const ref = this.$refs['group-' + group.id]
if (ref.offsetTop <= top) {
this.activeGroup = group.id
}
})
})
},
loadEmoji () {
const allLoaded = this.customEmojiBuffer.length === this.filteredEmoji.length
if (allLoaded) {
return
}
this.customEmojiBufferSlice += LOAD_EMOJI_BY
},
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
}
this.customEmojiBufferSlice = LOAD_EMOJI_BY
},
toggleStickers () { toggleStickers () {
this.showingStickers = !this.showingStickers this.showingStickers = !this.showingStickers
}, },
setShowStickers (value) { setShowStickers (value) {
this.showingStickers = value this.showingStickers = value
},
filterByKeyword (list, keyword) {
return filterByKeyword(list, keyword, this.languages, this.maybeLocalizedEmojiName)
},
initializeLazyLoad () {
this.destroyLazyLoad()
this.$nextTick(() => {
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()
})
},
waitForDomAndInitializeLazyLoad () {
this.$nextTick(() => this.initializeLazyLoad())
},
destroyLazyLoad () {
if (this.$lozad) {
if (this.$lozad.observer) {
this.$lozad.observer.disconnect()
}
if (this.$lozad.mutationObserver) {
this.$lozad.mutationObserver.disconnect()
}
}
},
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)
}
})
}
},
getFilteredEmojiGroups () {
return this.allEmojiGroups
.map(group => ({
...group,
emojis: this.filterByKeyword(group.emojis, trim(this.keyword))
}))
.filter(group => group.emojis.length > 0)
} }
}, },
watch: { watch: {
keyword () { keyword () {
this.customEmojiLoadAllConfirmed = false
this.onScroll() 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: { computed: {
activeGroupView () { activeGroupView () {
return this.showingStickers ? '' : this.activeGroup return this.showingStickers ? '' : this.activeGroup
@ -174,39 +276,55 @@ const EmojiPicker = {
} }
return 0 return 0
}, },
filteredEmoji () { allCustomGroups () {
return filterByKeyword( return this.$store.getters.groupedCustomEmojis
this.$store.state.instance.customEmoji || [],
trim(this.keyword)
)
}, },
customEmojiBuffer () { defaultGroup () {
return this.filteredEmoji.slice(0, this.customEmojiBufferSlice) return Object.keys(this.allCustomGroups)[0]
}, },
emojis () { unicodeEmojiGroups () {
const standardEmojis = this.$store.state.instance.emoji || [] return this.$store.getters.standardEmojiGroupList.map(group => ({
const customEmojis = this.customEmojiBuffer id: `standard-${group.id}`,
text: this.$t(`emoji.unicode_groups.${group.id}`),
return [ icon: UNICODE_EMOJI_GROUP_ICON[group.id],
{ emojis: group.emojis
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))
}
]
}, },
emojisView () { allEmojiGroups () {
return this.emojis.filter(value => value.emojis.length > 0) return Object.entries(this.allCustomGroups)
.map(([_, v]) => v)
.concat(this.unicodeEmojiGroups)
}, },
stickerPickerEnabled () { stickerPickerEnabled () {
return (this.$store.state.instance.stickers || []).length !== 0 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
}
} }
} }
} }

View File

@ -1,5 +1,10 @@
@import '../../_variables.scss'; @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 { .emoji-picker {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -19,6 +24,23 @@
--lightText: var(--popoverLightText, $fallback--lightText); --lightText: var(--popoverLightText, $fallback--lightText);
--icon: var(--popoverIcon, $fallback--icon); --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, .keep-open,
.too-many-emoji { .too-many-emoji {
padding: 7px; padding: 7px;
@ -37,7 +59,6 @@
.heading { .heading {
display: flex; display: flex;
height: 32px;
padding: 10px 7px 5px; padding: 10px 7px 5px;
} }
@ -50,6 +71,10 @@
.emoji-tabs { .emoji-tabs {
flex-grow: 1; flex-grow: 1;
display: flex;
flex-direction: row;
flex-wrap: nowrap;
overflow-x: auto;
} }
.emoji-groups { .emoji-groups {
@ -57,6 +82,8 @@
} }
.additional-tabs { .additional-tabs {
display: flex;
flex: 1;
border-left: 1px solid; border-left: 1px solid;
border-left-color: $fallback--icon; border-left-color: $fallback--icon;
border-left-color: var(--icon, $fallback--icon); border-left-color: var(--icon, $fallback--icon);
@ -66,15 +93,20 @@
.additional-tabs, .additional-tabs,
.emoji-tabs { .emoji-tabs {
display: block;
min-width: 0;
flex-basis: auto; flex-basis: auto;
flex-shrink: 1; display: flex;
align-content: center;
&-item { &-item {
padding: 0 7px; padding: 0 7px;
cursor: pointer; cursor: pointer;
font-size: 1.85em; 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 { &.disabled {
opacity: 0.5; opacity: 0.5;
@ -164,22 +196,26 @@
} }
&-item { &-item {
width: 32px; width: $emoji-picker-emoji-size;
height: 32px; height: $emoji-picker-emoji-size;
box-sizing: border-box; box-sizing: border-box;
display: flex; display: flex;
font-size: 32px; line-height: $emoji-picker-emoji-size;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
margin: 4px; margin: 4px;
cursor: pointer; cursor: pointer;
img { .emoji-picker-emoji.-custom {
object-fit: contain; object-fit: contain;
max-width: 100%; max-width: 100%;
max-height: 100%; max-height: 100%;
} }
.emoji-picker-emoji.-unicode {
font-size: 24px;
overflow: hidden;
}
} }
} }

View File

@ -1,19 +1,34 @@
<template> <template>
<div class="emoji-picker panel panel-default panel-body"> <div
class="emoji-picker panel panel-default panel-body"
>
<div class="heading"> <div class="heading">
<span class="emoji-tabs"> <span
ref="header"
class="emoji-tabs"
>
<span <span
v-for="group in emojis" v-for="group in filteredEmojiGroups"
:ref="setGroupRef('group-header-' + group.id)"
:key="group.id" :key="group.id"
class="emoji-tabs-item" class="emoji-tabs-item"
:class="{ :class="{
active: activeGroupView === group.id, active: activeGroupView === group.id
disabled: group.emojis.length === 0
}" }"
:title="group.text" :title="group.text"
@click.prevent="highlight(group.id)" @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 <FAIcon
v-else
:icon="group.icon" :icon="group.icon"
fixed-width fixed-width
/> />
@ -36,7 +51,10 @@
</span> </span>
</span> </span>
</div> </div>
<div class="content"> <div
v-if="contentLoaded"
class="content"
>
<div <div
class="emoji-content" class="emoji-content"
:class="{hidden: showingStickers}" :class="{hidden: showingStickers}"
@ -57,12 +75,12 @@
@scroll="onScroll" @scroll="onScroll"
> >
<div <div
v-for="group in emojisView" v-for="group in filteredEmojiGroups"
:key="group.id" :key="group.id"
class="emoji-group" class="emoji-group"
> >
<h6 <h6
:ref="'group-' + group.id" :ref="setGroupRef('group-' + group.id)"
class="emoji-group-title" class="emoji-group-title"
> >
{{ group.text }} {{ group.text }}
@ -70,17 +88,23 @@
<span <span
v-for="emoji in group.emojis" v-for="emoji in group.emojis"
:key="group.id + emoji.displayText" :key="group.id + emoji.displayText"
:title="emoji.displayText" :title="maybeLocalizedEmojiName(emoji)"
class="emoji-item" class="emoji-item"
@click.stop.prevent="onEmoji(emoji)" @click.stop.prevent="onEmoji(emoji)"
> >
<span v-if="!emoji.imageUrl">{{ emoji.replacement }}</span> <span
<img v-if="!emoji.imageUrl"
class="emoji-picker-emoji -unicode"
>{{ emoji.replacement }}</span>
<still-image
v-else 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>
<span :ref="'group-end-' + group.id" /> <span :ref="setGroupRef('group-end-' + group.id)" />
</div> </div>
</div> </div>
<div class="keep-open"> <div class="keep-open">

View File

@ -189,7 +189,7 @@ const PostStatusForm = {
emojiUserSuggestor () { emojiUserSuggestor () {
return suggestor({ return suggestor({
emoji: [ emoji: [
...this.$store.state.instance.emoji, ...this.$store.getters.standardEmojiList,
...this.$store.state.instance.customEmoji ...this.$store.state.instance.customEmoji
], ],
store: this.$store store: this.$store
@ -198,13 +198,13 @@ const PostStatusForm = {
emojiSuggestor () { emojiSuggestor () {
return suggestor({ return suggestor({
emoji: [ emoji: [
...this.$store.state.instance.emoji, ...this.$store.getters.standardEmojiList,
...this.$store.state.instance.customEmoji ...this.$store.state.instance.customEmoji
] ]
}) })
}, },
emoji () { emoji () {
return this.$store.state.instance.emoji || [] return this.$store.getters.standardEmojiList || []
}, },
customEmoji () { customEmoji () {
return this.$store.state.instance.customEmoji || [] return this.$store.state.instance.customEmoji || []

View File

@ -59,7 +59,7 @@ const ReactButton = {
if (this.filterWord !== '') { if (this.filterWord !== '') {
const filterWordLowercase = trim(this.filterWord.toLowerCase()) const filterWordLowercase = trim(this.filterWord.toLowerCase())
const orderedEmojiList = [] 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] if (emoji.replacement === this.filterWord) return [emoji]
const indexOfFilterWord = emoji.displayText.toLowerCase().indexOf(filterWordLowercase) const indexOfFilterWord = emoji.displayText.toLowerCase().indexOf(filterWordLowercase)
@ -72,7 +72,7 @@ const ReactButton = {
} }
return orderedEmojiList.flat() return orderedEmojiList.flat()
} }
return this.$store.state.instance.emoji || [] return this.$store.getters.standardEmojiList || []
}, },
mergedConfig () { mergedConfig () {
return this.$store.getters.mergedConfig return this.$store.getters.mergedConfig

View File

@ -64,7 +64,7 @@ const ProfileTab = {
emojiUserSuggestor () { emojiUserSuggestor () {
return suggestor({ return suggestor({
emoji: [ emoji: [
...this.$store.state.instance.emoji, ...this.$store.getters.standardEmojiList,
...this.$store.state.instance.customEmoji ...this.$store.state.instance.customEmoji
], ],
store: this.$store store: this.$store
@ -73,7 +73,7 @@ const ProfileTab = {
emojiSuggestor () { emojiSuggestor () {
return suggestor({ return suggestor({
emoji: [ emoji: [
...this.$store.state.instance.emoji, ...this.$store.getters.standardEmojiList,
...this.$store.state.instance.customEmoji ...this.$store.state.instance.customEmoji
] ]
}) })

View File

@ -7,16 +7,23 @@ const StillImage = {
'imageLoadHandler', 'imageLoadHandler',
'alt', 'alt',
'height', 'height',
'width' 'width',
'dataSrc'
], ],
data () { data () {
return { return {
// for lazy loading, see loadLazy()
realSrc: this.src,
stopGifs: this.$store.getters.mergedConfig.stopGifs stopGifs: this.$store.getters.mergedConfig.stopGifs
} }
}, },
computed: { computed: {
animated () { 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 () { style () {
const appendPx = (str) => /\d$/.test(str) ? str + 'px' : str const appendPx = (str) => /\d$/.test(str) ? str + 'px' : str
@ -27,7 +34,15 @@ const StillImage = {
} }
}, },
methods: { methods: {
loadLazy () {
if (this.dataSrc) {
this.realSrc = this.dataSrc
}
},
onLoad () { onLoad () {
if (!this.realSrc) {
return
}
const image = this.$refs.src const image = this.$refs.src
if (!image) return if (!image) return
this.imageLoadHandler && this.imageLoadHandler(image) this.imageLoadHandler && this.imageLoadHandler(image)
@ -42,6 +57,14 @@ const StillImage = {
onError () { onError () {
this.imageLoadError && this.imageLoadError() this.imageLoadError && this.imageLoadError()
} }
},
watch: {
src () {
this.realSrc = this.src
},
dataSrc () {
this.$el.removeAttribute('data-loaded')
}
} }
} }

View File

@ -11,10 +11,11 @@
<!-- NOTE: key is required to force to re-render img tag when src is changed --> <!-- NOTE: key is required to force to re-render img tag when src is changed -->
<img <img
ref="src" ref="src"
:key="src" :key="realSrc"
:alt="alt" :alt="alt"
:title="alt" :title="alt"
:src="src" :data-src="dataSrc"
:src="realSrc"
:referrerpolicy="referrerpolicy" :referrerpolicy="referrerpolicy"
@load="onLoad" @load="onLoad"
@error="onError" @error="onError"

View File

@ -199,8 +199,20 @@
"add_emoji": "Insert emoji", "add_emoji": "Insert emoji",
"custom": "Custom emoji", "custom": "Custom emoji",
"unicode": "Unicode 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_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": { "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." "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
src/i18n/languages.js Normal file
View File

@ -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
}

View File

@ -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 // 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. // There's only problem that apostrophe character ' gets replaced by \\ so you have to fix it manually, sorry.
const loaders = { import { languages, langCodeToJsonName } from './languages.js'
ar: () => import('./ar.json'),
ca: () => import('./ca.json'), const hasLanguageFile = (code) => languages.includes(code)
cs: () => import('./cs.json'),
de: () => import('./de.json'), const loadLanguageFile = (code) => {
eo: () => import('./eo.json'), return import(
es: () => import('./es.json'), /* webpackInclude: /\.json$/ */
et: () => import('./et.json'), /* webpackChunkName: "i18n/[request]" */
eu: () => import('./eu.json'), `./${langCodeToJsonName(code)}.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')
} }
const messages = { const messages = {
languages: ['en', ...Object.keys(loaders)], languages,
default: { default: {
en: require('./en.json').default en: require('./en.json').default
}, },
setLanguage: async (i18n, language) => { setLanguage: async (i18n, language) => {
if (loaders[language]) { if (hasLanguageFile(language)) {
const messages = await loaders[language]() const messages = await loadLanguageFile(language)
i18n.setLocaleMessage(language, messages.default) i18n.setLocaleMessage(language, messages.default)
} }
i18n.locale = language i18n.locale = language

View File

@ -183,6 +183,7 @@ const config = {
break break
case 'interfaceLanguage': case 'interfaceLanguage':
messages.setLanguage(this.getters.i18n, value) messages.setLanguage(this.getters.i18n, value)
dispatch('loadUnicodeEmojiData', value)
Cookies.set(BACKEND_LANGUAGE_COOKIE_NAME, localeService.internalToBackendLocale(value)) Cookies.set(BACKEND_LANGUAGE_COOKIE_NAME, localeService.internalToBackendLocale(value))
break break
case 'thirdColumnMode': case 'thirdColumnMode':

View File

@ -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 { CURRENT_VERSION } from '../services/theme_data/theme_data.service.js'
import apiService from '../services/api/api.service.js' import apiService from '../services/api/api.service.js'
import { instanceDefaultProperties } from './config.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 = { const defaultState = {
// Stuff from apiConfig // Stuff from apiConfig
@ -64,8 +97,9 @@ const defaultState = {
// Nasty stuff // Nasty stuff
customEmoji: [], customEmoji: [],
customEmojiFetched: false, customEmojiFetched: false,
emoji: [], emoji: {},
emojiFetched: false, emojiFetched: false,
unicodeEmojiAnnotations: {},
pleromaBackend: true, pleromaBackend: true,
postFormats: [], postFormats: [],
restrictedNicknames: [], 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 = { const instance = {
state: defaultState, state: defaultState,
mutations: { mutations: {
@ -107,6 +166,9 @@ const instance = {
}, },
setKnownDomains (state, domains) { setKnownDomains (state, domains) {
state.knownDomains = domains state.knownDomains = domains
},
setUnicodeEmojiAnnotations (state, { lang, annotations }) {
state.unicodeEmojiAnnotations[lang] = annotations
} }
}, },
getters: { getters: {
@ -115,6 +177,41 @@ const instance = {
.map(key => [key, state[key]]) .map(key => [key, state[key]])
.reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {}) .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) { instanceDomain (state) {
return new URL(state.server).hostname return new URL(state.server).hostname
} }
@ -138,32 +235,52 @@ const instance = {
}, },
async getStaticEmoji ({ commit }) { async getStaticEmoji ({ commit }) {
try { try {
const res = await window.fetch('/static/emoji.json') const values = (await import(/* webpackChunkName: 'emoji' */ '../../static/emoji.json')).default
if (res.ok) {
const values = await res.json() const emoji = Object.keys(values).reduce((res, groupId) => {
const emoji = Object.keys(values).map((key) => { res[groupId] = values[groupId].map(e => ({
return { displayText: e.slug,
displayText: key, imageUrl: false,
imageUrl: false, replacement: e.emoji
replacement: values[key] }))
} return res
}).sort((a, b) => a.name > b.name ? 1 : -1) }, {})
commit('setInstanceOption', { name: 'emoji', value: emoji }) commit('setInstanceOption', { name: 'emoji', value: injectRegionalIndicators(emoji) })
} else {
throw (res)
}
} catch (e) { } catch (e) {
console.warn("Can't load static emoji") console.warn("Can't load static emoji")
console.warn(e) 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 }) { async getCustomEmoji ({ commit, state }) {
try { try {
const res = await window.fetch('/api/pleroma/emoji.json') const res = await window.fetch('/api/pleroma/emoji.json')
if (res.ok) { if (res.ok) {
const result = await res.json() const result = await res.json()
const values = Array.isArray(result) ? Object.assign({}, ...result) : result 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 emoji = Object.entries(values).map(([key, value]) => {
const imageUrl = value.image_url const imageUrl = value.image_url
return { return {
@ -174,7 +291,7 @@ const instance = {
} }
// Technically could use tags but those are kinda useless right now, // Technically could use tags but those are kinda useless right now,
// should have been "pack" field, that would be more useful // 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 }) commit('setInstanceOption', { name: 'customEmoji', value: emoji })
} else { } else {
throw (res) throw (res)

File diff suppressed because it is too large Load Diff

View File

@ -1629,6 +1629,11 @@
dependencies: dependencies:
pointer-tracker "^2.0.3" 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": "@nightwatch/chai@5.0.2":
version "5.0.2" version "5.0.2"
resolved "https://registry.yarnpkg.com/@nightwatch/chai/-/chai-5.0.2.tgz#86b20908fc090dffd5c9567c0392bc6a494cc2e6" resolved "https://registry.yarnpkg.com/@nightwatch/chai/-/chai-5.0.2.tgz#86b20908fc090dffd5c9567c0392bc6a494cc2e6"
@ -5733,6 +5738,11 @@ lower-case@^2.0.2:
dependencies: dependencies:
tslib "^2.0.3" 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: lru-cache@^6.0.0:
version "6.0.0" version "6.0.0"
resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-6.0.0.tgz#6d6fe6570ebd96aaf90fcad1dafa3b2566db3a94" resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-6.0.0.tgz#6d6fe6570ebd96aaf90fcad1dafa3b2566db3a94"