From b9c612b56131572078be54a189075ebfa319f9f7 Mon Sep 17 00:00:00 2001 From: Nolan Lawson Date: Sun, 1 Oct 2017 22:22:24 -0700 Subject: [PATCH] Code-split emoji-mart picker and data (#5175) --- app/javascript/mastodon/actions/compose.js | 4 +- app/javascript/mastodon/emoji_data_light.js | 17 ++ app/javascript/mastodon/emoji_index_light.js | 154 ++++++++++++++++++ app/javascript/mastodon/emoji_utils.js | 137 ++++++++++++++++ .../components/emoji_picker_dropdown.js | 38 ++++- .../features/ui/util/async-components.js | 4 + .../mastodon/reducers/custom_emojis.js | 4 +- 7 files changed, 348 insertions(+), 10 deletions(-) create mode 100644 app/javascript/mastodon/emoji_data_light.js create mode 100644 app/javascript/mastodon/emoji_index_light.js create mode 100644 app/javascript/mastodon/emoji_utils.js diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js index a63894a989..7ac33bdd0a 100644 --- a/app/javascript/mastodon/actions/compose.js +++ b/app/javascript/mastodon/actions/compose.js @@ -1,6 +1,6 @@ import api from '../api'; -import { emojiIndex } from 'emoji-mart'; import { throttle } from 'lodash'; +import { search as emojiSearch } from '../emoji_index_light'; import { updateTimeline, @@ -261,7 +261,7 @@ const fetchComposeSuggestionsAccounts = throttle((dispatch, getState, token) => }, 200, { leading: true, trailing: true }); const fetchComposeSuggestionsEmojis = (dispatch, getState, token) => { - const results = emojiIndex.search(token.replace(':', ''), { maxResults: 5 }); + const results = emojiSearch(token.replace(':', ''), { maxResults: 5 }); dispatch(readyComposeSuggestionsEmojis(token, results)); }; diff --git a/app/javascript/mastodon/emoji_data_light.js b/app/javascript/mastodon/emoji_data_light.js new file mode 100644 index 0000000000..f034424552 --- /dev/null +++ b/app/javascript/mastodon/emoji_data_light.js @@ -0,0 +1,17 @@ +// @preval +const data = require('emoji-mart/dist/data').default; +const pick = require('lodash/pick'); + +const condensedEmojis = {}; +Object.keys(data.emojis).forEach(key => { + condensedEmojis[key] = pick(data.emojis[key], ['short_names', 'unified', 'search']); +}); + +// JSON.parse/stringify is to emulate what @preval is doing and avoid any +// inconsistent behavior in dev mode +module.exports = JSON.parse(JSON.stringify({ + emojis: condensedEmojis, + skins: data.skins, + categories: data.categories, + short_names: data.short_names, +})); diff --git a/app/javascript/mastodon/emoji_index_light.js b/app/javascript/mastodon/emoji_index_light.js new file mode 100644 index 0000000000..0719eda5e1 --- /dev/null +++ b/app/javascript/mastodon/emoji_index_light.js @@ -0,0 +1,154 @@ +// This code is largely borrowed from: +// https://github.com/missive/emoji-mart/blob/bbd4fbe/src/utils/emoji-index.js + +import data from './emoji_data_light'; +import { getData, getSanitizedData, intersect } from './emoji_utils'; + +let index = {}; +let emojisList = {}; +let emoticonsList = {}; +let previousInclude = []; +let previousExclude = []; + +for (let emoji in data.emojis) { + let emojiData = data.emojis[emoji], + { short_names, emoticons } = emojiData, + id = short_names[0]; + + for (let emoticon of (emoticons || [])) { + if (!emoticonsList[emoticon]) { + emoticonsList[emoticon] = id; + } + } + + emojisList[id] = getSanitizedData(id); +} + +function search(value, { emojisToShowFilter, maxResults, include, exclude, custom = [] } = {}) { + maxResults = maxResults || 75; + include = include || []; + exclude = exclude || []; + + if (custom.length) { + for (const emoji of custom) { + data.emojis[emoji.id] = getData(emoji); + emojisList[emoji.id] = getSanitizedData(emoji); + } + + data.categories.push({ + name: 'Custom', + emojis: custom.map(emoji => emoji.id), + }); + } + + let results = null; + let pool = data.emojis; + + if (value.length) { + if (value === '-' || value === '-1') { + return [emojisList['-1']]; + } + + let values = value.toLowerCase().split(/[\s|,|\-|_]+/); + + if (values.length > 2) { + values = [values[0], values[1]]; + } + + if (include.length || exclude.length) { + pool = {}; + + if (previousInclude !== include.sort().join(',') || previousExclude !== exclude.sort().join(',')) { + previousInclude = include.sort().join(','); + previousExclude = exclude.sort().join(','); + index = {}; + } + + for (let category of data.categories) { + let isIncluded = include && include.length ? include.indexOf(category.name.toLowerCase()) > -1 : true; + let isExcluded = exclude && exclude.length ? exclude.indexOf(category.name.toLowerCase()) > -1 : false; + if (!isIncluded || isExcluded) { + continue; + } + + for (let emojiId of category.emojis) { + pool[emojiId] = data.emojis[emojiId]; + } + } + } else if (previousInclude.length || previousExclude.length) { + index = {}; + } + + let allResults = values.map((value) => { + let aPool = pool; + let aIndex = index; + let length = 0; + + for (let char of value.split('')) { + length++; + + aIndex[char] = aIndex[char] || {}; + aIndex = aIndex[char]; + + if (!aIndex.results) { + let scores = {}; + + aIndex.results = []; + aIndex.pool = {}; + + for (let id in aPool) { + let emoji = aPool[id], + { search } = emoji, + sub = value.substr(0, length), + subIndex = search.indexOf(sub); + + if (subIndex !== -1) { + let score = subIndex + 1; + if (sub === id) { + score = 0; + } + + aIndex.results.push(emojisList[id]); + aIndex.pool[id] = emoji; + + scores[id] = score; + } + } + + aIndex.results.sort((a, b) => { + let aScore = scores[a.id], + bScore = scores[b.id]; + + return aScore - bScore; + }); + } + + aPool = aIndex.pool; + } + + return aIndex.results; + }).filter(a => a); + + if (allResults.length > 1) { + results = intersect(...allResults); + } else if (allResults.length) { + results = allResults[0]; + } else { + results = []; + } + } + + if (results) { + if (emojisToShowFilter) { + results = results.filter((result) => emojisToShowFilter(data.emojis[result.id].unified)); + } + + if (results && results.length > maxResults) { + results = results.slice(0, maxResults); + } + } + + return results; +} + +export { search }; diff --git a/app/javascript/mastodon/emoji_utils.js b/app/javascript/mastodon/emoji_utils.js new file mode 100644 index 0000000000..6475df5711 --- /dev/null +++ b/app/javascript/mastodon/emoji_utils.js @@ -0,0 +1,137 @@ +// This code is largely borrowed from: +// https://github.com/missive/emoji-mart/blob/bbd4fbe/src/utils/index.js + +import data from './emoji_data_light'; + +const COLONS_REGEX = /^(?:\:([^\:]+)\:)(?:\:skin-tone-(\d)\:)?$/; + +function buildSearch(thisData) { + const search = []; + + let addToSearch = (strings, split) => { + if (!strings) { + return; + } + + (Array.isArray(strings) ? strings : [strings]).forEach((string) => { + (split ? string.split(/[-|_|\s]+/) : [string]).forEach((s) => { + s = s.toLowerCase(); + + if (search.indexOf(s) === -1) { + search.push(s); + } + }); + }); + }; + + addToSearch(thisData.short_names, true); + addToSearch(thisData.name, true); + addToSearch(thisData.keywords, false); + addToSearch(thisData.emoticons, false); + + return search; +} + +function unifiedToNative(unified) { + let unicodes = unified.split('-'), + codePoints = unicodes.map((u) => `0x${u}`); + + return String.fromCodePoint(...codePoints); +} + +function sanitize(emoji) { + let { name, short_names, skin_tone, skin_variations, emoticons, unified, custom, imageUrl } = emoji, + id = emoji.id || short_names[0], + colons = `:${id}:`; + + if (custom) { + return { + id, + name, + colons, + emoticons, + custom, + imageUrl, + }; + } + + if (skin_tone) { + colons += `:skin-tone-${skin_tone}:`; + } + + return { + id, + name, + colons, + emoticons, + unified: unified.toLowerCase(), + skin: skin_tone || (skin_variations ? 1 : null), + native: unifiedToNative(unified), + }; +} + +function getSanitizedData(emoji) { + return sanitize(getData(emoji)); +} + +function getData(emoji) { + let emojiData = {}; + + if (typeof emoji === 'string') { + let matches = emoji.match(COLONS_REGEX); + + if (matches) { + emoji = matches[1]; + + } + + if (data.short_names.hasOwnProperty(emoji)) { + emoji = data.short_names[emoji]; + } + + if (data.emojis.hasOwnProperty(emoji)) { + emojiData = data.emojis[emoji]; + } + } else if (emoji.custom) { + emojiData = emoji; + + emojiData.search = buildSearch({ + short_names: emoji.short_names, + name: emoji.name, + keywords: emoji.keywords, + emoticons: emoji.emoticons, + }); + + emojiData.search = emojiData.search.join(','); + } else if (emoji.id) { + if (data.short_names.hasOwnProperty(emoji.id)) { + emoji.id = data.short_names[emoji.id]; + } + + if (data.emojis.hasOwnProperty(emoji.id)) { + emojiData = data.emojis[emoji.id]; + } + } + + emojiData.emoticons = emojiData.emoticons || []; + emojiData.variations = emojiData.variations || []; + + if (emojiData.variations && emojiData.variations.length) { + emojiData = JSON.parse(JSON.stringify(emojiData)); + emojiData.unified = emojiData.variations.shift(); + } + + return emojiData; +} + +function intersect(a, b) { + let aSet = new Set(a); + let bSet = new Set(b); + let intersection = new Set( + [...aSet].filter(x => bSet.has(x)) + ); + + return Array.from(intersection); +} + +export { getData, getSanitizedData, intersect }; diff --git a/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js b/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js index 621cc21ceb..7e15c0b40f 100644 --- a/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js +++ b/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js @@ -1,11 +1,12 @@ import React from 'react'; import PropTypes from 'prop-types'; import { defineMessages, injectIntl } from 'react-intl'; -import { Picker, Emoji } from 'emoji-mart'; +import { EmojiPicker as EmojiPickerAsync } from '../../ui/util/async-components'; import { Overlay } from 'react-overlays'; import classNames from 'classnames'; import ImmutablePropTypes from 'react-immutable-proptypes'; import detectPassiveEvents from 'detect-passive-events'; +import { buildCustomEmojis } from '../../../emoji'; const messages = defineMessages({ emoji: { id: 'emoji_button.label', defaultMessage: 'Insert emoji' }, @@ -25,6 +26,8 @@ const messages = defineMessages({ }); const assetHost = process.env.CDN_HOST || ''; +let EmojiPicker, Emoji; // load asynchronously + const backgroundImageFn = () => `${assetHost}/emoji/sheet.png`; const listenerOptions = detectPassiveEvents.hasSupport ? { passive: true } : false; @@ -131,6 +134,7 @@ class EmojiPickerMenu extends React.PureComponent { static propTypes = { custom_emojis: ImmutablePropTypes.list, + loading: PropTypes.bool, onClose: PropTypes.func.isRequired, onPick: PropTypes.func.isRequired, style: PropTypes.object, @@ -142,6 +146,7 @@ class EmojiPickerMenu extends React.PureComponent { static defaultProps = { style: {}, + loading: true, placement: 'bottom', }; @@ -216,13 +221,18 @@ class EmojiPickerMenu extends React.PureComponent { } render () { - const { style, intl } = this.props; + const { loading, style, intl } = this.props; + + if (loading) { + return
; + } + const title = intl.formatMessage(messages.emoji); const { modifierOpen, modifier } = this.state; return (
- { @@ -268,6 +279,20 @@ export default class EmojiPickerDropdown extends React.PureComponent { onShowDropdown = () => { this.setState({ active: true }); + + if (!EmojiPicker) { + this.setState({ loading: true }); + + EmojiPickerAsync().then(EmojiMart => { + EmojiPicker = EmojiMart.Picker; + Emoji = EmojiMart.Emoji; + // populate custom emoji in search + EmojiMart.emojiIndex.search('', { custom: buildCustomEmojis(this.props.custom_emojis) }); + this.setState({ loading: false }); + }).catch(() => { + this.setState({ loading: false }); + }); + } } onHideDropdown = () => { @@ -275,7 +300,7 @@ export default class EmojiPickerDropdown extends React.PureComponent { } onToggle = (e) => { - if (!e.key || e.key === 'Enter') { + if (!this.state.loading && (!e.key || e.key === 'Enter')) { if (this.state.active) { this.onHideDropdown(); } else { @@ -301,13 +326,13 @@ export default class EmojiPickerDropdown extends React.PureComponent { render () { const { intl, onPickEmoji } = this.props; const title = intl.formatMessage(messages.emoji); - const { active } = this.state; + const { active, loading } = this.state; return (
🙂 @@ -316,6 +341,7 @@ export default class EmojiPickerDropdown extends React.PureComponent { diff --git a/app/javascript/mastodon/features/ui/util/async-components.js b/app/javascript/mastodon/features/ui/util/async-components.js index ad5493f8c5..6978da2f9e 100644 --- a/app/javascript/mastodon/features/ui/util/async-components.js +++ b/app/javascript/mastodon/features/ui/util/async-components.js @@ -1,3 +1,7 @@ +export function EmojiPicker () { + return import(/* webpackChunkName: "emoji_picker" */'emoji-mart'); +} + export function Compose () { return import(/* webpackChunkName: "features/compose" */'../../compose'); } diff --git a/app/javascript/mastodon/reducers/custom_emojis.js b/app/javascript/mastodon/reducers/custom_emojis.js index d80c0d156a..b7c9b1d7c3 100644 --- a/app/javascript/mastodon/reducers/custom_emojis.js +++ b/app/javascript/mastodon/reducers/custom_emojis.js @@ -1,6 +1,6 @@ import { List as ImmutableList } from 'immutable'; import { STORE_HYDRATE } from '../actions/store'; -import { emojiIndex } from 'emoji-mart'; +import { search as emojiSearch } from '../emoji_index_light'; import { buildCustomEmojis } from '../emoji'; const initialState = ImmutableList(); @@ -8,7 +8,7 @@ const initialState = ImmutableList(); export default function custom_emojis(state = initialState, action) { switch(action.type) { case STORE_HYDRATE: - emojiIndex.search('', { custom: buildCustomEmojis(action.state.get('custom_emojis', [])) }); + emojiSearch('', { custom: buildCustomEmojis(action.state.get('custom_emojis', [])) }); return action.state.get('custom_emojis'); default: return state;