start on UI, shortcode -> shortcodes

This commit is contained in:
Nolan Lawson 2020-06-14 14:42:05 -07:00
parent 786027fbd6
commit 15af26d5e7
12 changed files with 193 additions and 85 deletions

View File

@ -542,16 +542,20 @@ Custom emoji should follow the format:
```js
[
{
shortcode: 'foo',
name: 'foo',
shortcodes: ['foo'],
url: 'http://example.com/foo.png'
},
{
shortcode: 'bar',
name: 'bar',
shortcodes: ['bar'],
url: 'http://example.com/bar.png'
}
]
```
Note that names are assumed to be unique (case-insensitive), and it's assumed that the `shortcodes` have at least one entry.
To pass custom emoji into the `Picker`:
```js

View File

@ -129,10 +129,10 @@ export default class Database {
return set(this._db, STORE_KEYVALUE, KEY_PREFERRED_SKINTONE, skinTone)
}
async incrementFavoriteEmojiCount (unicodeOrShortcode) {
assertNonEmptyString(unicodeOrShortcode)
async incrementFavoriteEmojiCount (unicodeOrName) {
assertNonEmptyString(unicodeOrName)
await this.ready()
return incrementFavoriteEmojiCount(this._db, unicodeOrShortcode)
return incrementFavoriteEmojiCount(this._db, unicodeOrName)
}
async getTopFavoriteEmoji (limit) {

View File

@ -5,31 +5,43 @@ import { findCommonMembers } from './utils/findCommonMembers'
export function customEmojiIndex (customEmojis) {
assertCustomEmojis(customEmojis)
// sort custom emojis by shortcode
const all = customEmojis.sort((a, b) => a.shortcode.toLowerCase() < b.shortcode.toLowerCase() ? -1 : 1)
// sort custom emojis by name
const all = customEmojis.sort((a, b) => a.name < b.name ? -1 : 1)
// search query to custom emoji. Similar to how we do this in IDB, the last token
// is treated as a prefix search, but every other one is treated as an exact match
// Then we AND the results together
const { byPrefix, byExactMatch } = trie(customEmojis, emoji => extractTokens(emoji.shortcode))
const emojiToTokens = emoji => (
[...new Set(emoji.shortcodes.map(shortcode => extractTokens(shortcode)).flat())]
)
const { byPrefix, byExactMatch } = trie(customEmojis, emojiToTokens)
const search = query => {
const searchTokens = extractTokens(query)
const intermediateResults = [
...searchTokens.slice(0, -1).map(byExactMatch),
byPrefix(searchTokens[searchTokens.length - 1])
]
return findCommonMembers(intermediateResults, _ => _.shortcode).sort((a, b) => a.shortcode < b.shortcode ? -1 : 1)
return findCommonMembers(intermediateResults, _ => _.name).sort((a, b) => a.name < b.name ? -1 : 1)
}
// shortcodes to custom emoji
const shortcodeToEmoji = new Map()
for (const customEmoji of customEmojis) {
shortcodeToEmoji.set(customEmoji.shortcode.toLowerCase(), customEmoji)
for (const shortcode of customEmoji.shortcodes) {
shortcodeToEmoji.set(shortcode.toLowerCase(), customEmoji)
}
}
const byShortcode = shortcode => shortcodeToEmoji.get(shortcode.toLowerCase())
const nameToEmoji = new Map()
for (const customEmoji of customEmojis) {
nameToEmoji.set(customEmoji.name.toLowerCase(), customEmoji)
}
const byName = name => nameToEmoji.get(name.toLowerCase())
return {
all,
search,
byShortcode
byShortcode,
byName
}
}

View File

@ -166,13 +166,13 @@ export function getTopFavoriteEmoji (db, customEmojiIndex, limit) {
cursor.continue()
}
const unicodeOrShortcode = cursor.primaryKey
const custom = customEmojiIndex.byShortcode(unicodeOrShortcode)
const unicodeOrName = cursor.primaryKey
const custom = customEmojiIndex.byName(unicodeOrName)
if (custom) {
return addResult(custom)
}
// TODO: this could be optimized by doing the get and the cursor.continue() in parallel
getIDB(emojiStore, unicodeOrShortcode, emoji => {
getIDB(emojiStore, unicodeOrName, emoji => {
if (emoji) {
return addResult(emoji)
}

View File

@ -1,5 +1,6 @@
const requiredKeys = [
'shortcode',
'name',
'shortcodes',
'url'
]

View File

@ -10,7 +10,8 @@ export default class Picker extends SveltePicker {
locale = DEFAULT_LOCALE,
dataSource = DEFAULT_DATA_SOURCE,
i18n = enI18n,
skinToneEmoji = DEFAULT_SKIN_TONE_EMOJI
skinToneEmoji = DEFAULT_SKIN_TONE_EMOJI,
customEmoji = []
} = {}) {
mark('initialLoad')
// Make the API simpler, directly pass in the props
@ -19,7 +20,8 @@ export default class Picker extends SveltePicker {
props: {
database: new Database({ dataSource, locale }),
i18n,
skinToneEmoji
skinToneEmoji,
customEmoji
}
})
this._locale = locale

View File

@ -123,8 +123,8 @@
{#each currentEmojis as emoji, i (emoji.unicode)}
<button role={searchMode ? 'option' : 'menuitem'}
{...(searchMode ? { 'aria-selected': i == activeSearchItem } : null)}
aria-label={emojiLabel(emoji)}
title={emojiTitle(emoji)}
aria-label={emoji.label}
title={emoji.title}
class="emoji {searchMode && i === activeSearchItem ? 'active' : ''}"
data-emoji={emoji.unicode}>
{unicodeWithSkin(emoji, currentSkinTone)}
@ -140,8 +140,8 @@
data-testid="favorites">
{#each currentFavorites as emoji, i (emoji.unicode)}
<button role="menuitem"
aria-label={emojiLabel(emoji)}
title={emojiTitle(emoji)}
aria-label={emoji.label}
title={emoji.title}
class="emoji"
data-emoji={emoji.unicode}>
{unicodeWithSkin(emoji, currentSkinTone)}

View File

@ -24,6 +24,7 @@ import {
} from '../../constants'
import { uniqBy } from '../../../shared/uniqBy'
import { mergeI18n } from '../../utils/mergeI18n'
import { summarizeEmojisForUI } from '../../utils/summarizeEmojisForUI'
let skinToneEmoji = DEFAULT_SKIN_TONE_EMOJI
let i18n = enI18n
@ -56,6 +57,7 @@ let defaultFavoriteEmojisPromise
let numColumns = DEFAULT_NUM_COLUMNS
let scrollbarWidth = 0 // eslint-disable-line no-unused-vars
let shouldUpdateFavorites = {} // hack to force svelte to recalc favorites
let customEmoji = []
const getBaselineEmojiWidth = thunk(() => calculateTextWidth(baselineEmoji))
@ -98,6 +100,12 @@ Promise.resolve().then(() => {
}
})
$: {
if (customEmoji && database) {
database.customEmoji = customEmoji
}
}
$: {
if (i18n !== enI18n) {
i18n = mergeI18n(enI18n, i18n) // if partial translations are provided, merge with English
@ -293,26 +301,7 @@ async function filterEmojisByVersion (emojis) {
}
async function summarizeEmojis (emojis) {
const emojiSupportLevel = await emojiSupportLevelPromise
// We don't need all the data on every emoji, so we can conserve memory by removing it
// Also we can simplify the way we access the "skins" object
const toSimpleSkinsMap = skins => {
const res = {}
for (const skin of skins) {
// ignore arrays like [1, 2] with multiple skin tones
// also ignore variants that are in an unsupported emoji version
// (these do exist - variants from a different version than their base emoji)
if (typeof skin.tone === 'number' && skin.version <= emojiSupportLevel) {
res[skin.tone] = skin.unicode
}
}
return res
}
return emojis.map(({ unicode, skins, shortcodes }) => ({
unicode,
skins: skins && toSimpleSkinsMap(skins),
shortcodes
}))
return summarizeEmojisForUI(emojis, await emojiSupportLevelPromise)
}
async function getEmojisByGroup (group) {
@ -480,18 +469,12 @@ async function onSkinToneOptionsBlur () {
skinTonePickerExpanded = false
}
}
// eslint-disable-next-line no-unused-vars
function emojiLabel (emoji) {
return emoji.unicode + ', ' + emoji.shortcodes.join(', ')
}
// eslint-disable-next-line no-unused-vars
function emojiTitle (emoji) {
return emoji.shortcodes.join(', ')
}
// eslint-disable-next-line no-unused-vars
function unicodeWithSkin (emoji, currentSkinTone) {
if (!emoji.unicode) { // custom emoji
return ''
}
if (currentSkinTone && emoji.skins && emoji.skins[currentSkinTone]) {
return emoji.skins[currentSkinTone]
}
@ -501,5 +484,6 @@ function unicodeWithSkin (emoji, currentSkinTone) {
export {
database,
i18n,
skinToneEmoji
skinToneEmoji,
customEmoji
}

View File

@ -0,0 +1,25 @@
// We don't need all the data on every emoji, and there are specific things we need
// for the UI, so build a "view model" from the emoji object we got from the database
export function summarizeEmojisForUI (emojis, emojiSupportLevel) {
const toSimpleSkinsMap = skins => {
const res = {}
for (const skin of skins) {
// ignore arrays like [1, 2] with multiple skin tones
// also ignore variants that are in an unsupported emoji version
// (these do exist - variants from a different version than their base emoji)
if (typeof skin.tone === 'number' && skin.version <= emojiSupportLevel) {
res[skin.tone] = skin.unicode
}
}
return res
}
return emojis.map(({ unicode, skins, shortcodes, url }) => ({
unicode,
skins: skins && toSimpleSkinsMap(skins),
shortcodes,
url,
label: (unicode ? (unicode + ', ') : '') + shortcodes.join(', '),
title: shortcodes.join(', ')
}))
}

View File

@ -93,11 +93,11 @@ export default class Database {
/**
* Increment the favorite count for an emoji by one. The unicode string must be non-empty. It should
* correspond to the base (non-skin-tone) unicode string from the emoji object, or in the case of
* custom emoji, it should be the shortcode.
* custom emoji, it should be the name.
*
* @param unicodeOrShortcode - unicode of the native emoji, or shortcode of a custom emoji
* @param unicodeOrName - unicode of a native emoji, or name of a custom emoji
*/
incrementFavoriteEmojiCount (unicodeOrShortcode: string): Promise<void> {
incrementFavoriteEmojiCount (unicodeOrName: string): Promise<void> {
return Promise.resolve()
}

View File

@ -90,12 +90,13 @@ export type SkinToneChangeEvent = Modify<UIEvent, {
}>
export interface EmojiPickerEventMap {
"emoji-click": EmojiClickEvent;
"skin-tone-change": SkinToneChangeEvent;
"emoji-click": EmojiClickEvent
"skin-tone-change": SkinToneChangeEvent
}
export interface CustomEmoji {
shortcode: string,
name: string
shortcodes: string[]
url: string
}

View File

@ -3,44 +3,55 @@ import Database from '../../../src/database/Database'
const customEmojis = [
{
shortcode: 'CapitalLettersLikeThis',
name: 'CapitalLettersLikeThis',
shortcodes: ['CapitalLettersLikeThis'],
url: 'caps.png'
},
{
shortcode: 'underscores_like_this',
name: 'underscores_like_this',
shortcodes: ['underscores_like_this'],
url: 'underscores.png'
},
{
shortcode: 'a',
name: 'a',
shortcodes: ['a'],
url: 'a.png'
},
{
shortcode: 'z',
name: 'z',
shortcodes: ['z'],
url: 'z.png'
},
{
shortcode: 'monkey', // conflicts with native emoji
name: 'monkey',
shortcodes: ['monkey'], // conflicts with native emoji
url: 'monkey.png'
},
{
shortcode: 'multiple_results_match',
name: 'multiple_results_match',
shortcodes: ['multiple_results_match'],
url: 'multiple1.png'
},
{
shortcode: 'multiple_results_match_too',
name: 'multiple_results_match_too',
shortcodes: ['multiple_results_match_too'],
url: 'multiple2.png'
},
{
shortcode: 'multiple_results_match_again',
name: 'multiple_results_match_again',
shortcodes: ['multiple_results_match_again'],
url: 'multiple3.png'
}
]
const summarize = ({ unicode, shortcode, url }) => {
const res = { shortcode, url }
const summarize = ({ unicode, shortcodes, url, name }) => {
const res = { shortcodes, url }
if (unicode) {
res.unicode = unicode
}
if (name) {
res.name = name
}
return res
}
@ -48,7 +59,7 @@ const summaryByUnicode = unicode => {
const emoji = truncatedEmoji.find(_ => _.emoji === unicode)
return summarize({
unicode: emoji.emoji,
shortcode: emoji.shortcode
shortcodes: emoji.shortcodes
})
}
@ -77,17 +88,17 @@ describe('custom emoji', () => {
db.customEmoji = customEmojis
expect(db.customEmoji).toStrictEqual(customEmojis)
expect(await db.getEmojiByShortcode('capitalletterslikethis')).toStrictEqual(
{ shortcode: 'CapitalLettersLikeThis', url: 'caps.png' }
{ name: 'CapitalLettersLikeThis', shortcodes: ['CapitalLettersLikeThis'], url: 'caps.png' }
)
expect(await db.getEmojiByShortcode('underscores_like_this')).toStrictEqual(
{ shortcode: 'underscores_like_this', url: 'underscores.png' }
{ name: 'underscores_like_this', shortcodes: ['underscores_like_this'], url: 'underscores.png' }
)
expect(await db.getEmojiByShortcode('Underscores_Like_This')).toStrictEqual(
{ shortcode: 'underscores_like_this', url: 'underscores.png' }
{ name: 'underscores_like_this', shortcodes: ['underscores_like_this'], url: 'underscores.png' }
)
// custom emoji take precedence over native in case of conflict
expect(await db.getEmojiByShortcode('monkey')).toStrictEqual(
{ shortcode: 'monkey', url: 'monkey.png' }
{ name: 'monkey', shortcodes: ['monkey'], url: 'monkey.png' }
)
})
@ -95,47 +106,115 @@ describe('custom emoji', () => {
db.customEmoji = customEmojis
expect((await db.getEmojiBySearchQuery('monkey')).map(summarize)).toStrictEqual([
{ shortcode: 'monkey', url: 'monkey.png' },
{ name: 'monkey', shortcodes: ['monkey'], url: 'monkey.png' },
summaryByUnicode('🐵'),
summaryByUnicode('🐒')
])
expect((await db.getEmojiBySearchQuery('undersc'))).toStrictEqual([
{ shortcode: 'underscores_like_this', url: 'underscores.png' }
{ name: 'underscores_like_this', shortcodes: ['underscores_like_this'], url: 'underscores.png' }
])
expect((await db.getEmojiBySearchQuery('underscores lik'))).toStrictEqual([
{ shortcode: 'underscores_like_this', url: 'underscores.png' }
{ name: 'underscores_like_this', shortcodes: ['underscores_like_this'], url: 'underscores.png' }
])
expect((await db.getEmojiBySearchQuery('undersc like'))).toStrictEqual([
])
expect((await db.getEmojiBySearchQuery('undersc lik'))).toStrictEqual([
])
expect((await db.getEmojiBySearchQuery('undersc like'))).toStrictEqual([])
expect((await db.getEmojiBySearchQuery('undersc lik'))).toStrictEqual([])
expect((await db.getEmojiBySearchQuery('underscores like'))).toStrictEqual([
{ shortcode: 'underscores_like_this', url: 'underscores.png' }
{ name: 'underscores_like_this', shortcodes: ['underscores_like_this'], url: 'underscores.png' }
])
expect((await db.getEmojiBySearchQuery('underscores like th'))).toStrictEqual([
{ shortcode: 'underscores_like_this', url: 'underscores.png' }
{ name: 'underscores_like_this', shortcodes: ['underscores_like_this'], url: 'underscores.png' }
])
expect((await db.getEmojiBySearchQuery('underscores like this'))).toStrictEqual([
{ shortcode: 'underscores_like_this', url: 'underscores.png' }
{ name: 'underscores_like_this', shortcodes: ['underscores_like_this'], url: 'underscores.png' }
])
expect((await db.getEmojiBySearchQuery('multiple match'))).toStrictEqual([
{
shortcode: 'multiple_results_match',
name: 'multiple_results_match',
shortcodes: ['multiple_results_match'],
url: 'multiple1.png'
},
{
shortcode: 'multiple_results_match_again',
name: 'multiple_results_match_again',
shortcodes: ['multiple_results_match_again'],
url: 'multiple3.png'
},
{
shortcode: 'multiple_results_match_too',
name: 'multiple_results_match_too',
shortcodes: ['multiple_results_match_too'],
url: 'multiple2.png'
}
])
})
test('increment favorites with custom', async () => {
db.customEmoji = customEmojis
const counts = {
'🐵': 5,
'🐒': 4,
'🤣': 2,
'🖐️': 1,
'🤏': 6,
'✌️': 8,
'🐶': 9,
'🎉': 3,
'✨': 3,
CapitalLettersLikeThis: 3,
underscores_like_this: 6,
monkey: 5
}
for (const [unicodeOrShortcode, count] of Object.entries(counts)) {
for (let i = 0; i < count; i++) {
await db.incrementFavoriteEmojiCount(unicodeOrShortcode)
}
}
expect((await db.getTopFavoriteEmoji(10)).map(summarize)).toStrictEqual([
summaryByUnicode('🐶'),
summaryByUnicode('✌️'),
summaryByUnicode('🤏'),
{ name: 'underscores_like_this', shortcodes: ['underscores_like_this'], url: 'underscores.png' },
summaryByUnicode('🐵'),
{ name: 'monkey', shortcodes: ['monkey'], url: 'monkey.png' },
summaryByUnicode('🐒'),
summaryByUnicode('🎉'),
summaryByUnicode('✨'),
{ name: 'CapitalLettersLikeThis', shortcodes: ['CapitalLettersLikeThis'], url: 'caps.png' }
])
expect((await db.getTopFavoriteEmoji(20)).map(summarize)).toStrictEqual([
summaryByUnicode('🐶'),
summaryByUnicode('✌️'),
summaryByUnicode('🤏'),
{ name: 'underscores_like_this', shortcodes: ['underscores_like_this'], url: 'underscores.png' },
summaryByUnicode('🐵'),
{ name: 'monkey', shortcodes: ['monkey'], url: 'monkey.png' },
summaryByUnicode('🐒'),
summaryByUnicode('🎉'),
summaryByUnicode('✨'),
{ name: 'CapitalLettersLikeThis', shortcodes: ['CapitalLettersLikeThis'], url: 'caps.png' },
summaryByUnicode('🤣'),
summaryByUnicode('🖐️')
])
db.customEmoji = []
// favorites are now in the database but missing from the in-memory index, so we should just skip them
expect((await db.getTopFavoriteEmoji(10)).map(summarize)).toStrictEqual([
summaryByUnicode('🐶'),
summaryByUnicode('✌️'),
summaryByUnicode('🤏'),
summaryByUnicode('🐵'),
summaryByUnicode('🐒'),
summaryByUnicode('🎉'),
summaryByUnicode('✨'),
summaryByUnicode('🤣'),
summaryByUnicode('🖐️')
])
})
})