/** * 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. * (useEmojiStore().standardEmojiList + state.instance.customEmoji) * data.users - optional, an array of all known users * updateUsersList - optional, a function to search and append to users * * Depending on data present one or both (or none) can be present, so if field * doesn't support user linking you can just provide only emoji. */ export default (data) => { const emojiCurry = suggestEmoji(data.emoji) const usersCurry = data.store && suggestUsers(data.store) return (input, nameKeywordLocalizer) => { const firstChar = input[0] if (firstChar === ':' && data.emoji) { return emojiCurry(input, nameKeywordLocalizer) } if (firstChar === '@' && usersCurry) { return usersCurry(input) } return [] } } export const suggestEmoji = (emojis) => (input, nameKeywordLocalizer) => { const noPrefix = input.toLowerCase().substr(1) return emojis .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 score += Math.max( ...k.names.map((name) => (name.toLowerCase() === noPrefix ? 200 : 0)), 0, ) // Prioritize custom emoji a lot score += k.imageUrl ? 100 : 0 // Prioritize prefix matches somewhat score += Math.max( ...k.names.map((kw) => kw.toLowerCase().startsWith(noPrefix) ? 10 : 0, ), 0, ) // Sort by 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 b.score - a.score + alphabetically }) } export const suggestUsers = ({ dispatch, state }) => { // Keep some persistent values in closure, most importantly for the // custom debounce to work. Lodash debounce does not return a promise. let suggestions = [] let previousQuery = '' let timeout = null let cancelUserSearch = null const userSearch = (query) => dispatch('searchUsers', { query }) const debounceUserSearch = (query) => { cancelUserSearch && cancelUserSearch() return new Promise((resolve, reject) => { timeout = setTimeout(() => { userSearch(query).then(resolve).catch(reject) }, 300) cancelUserSearch = () => { clearTimeout(timeout) resolve([]) } }) } return async (input) => { const noPrefix = input.toLowerCase().substr(1) if (previousQuery === noPrefix) return suggestions suggestions = [] previousQuery = noPrefix // Fetch more and wait, don't fetch if there's the 2nd @ because // the backend user search can't deal with it. // Reference semantics make it so that we get the updated data after // the await. if (!noPrefix.includes('@')) { await debounceUserSearch(noPrefix) } const newSuggestions = state.users.users .filter( (user) => user.screen_name && user.name && (user.screen_name.toLowerCase().startsWith(noPrefix) || user.name.toLowerCase().startsWith(noPrefix)), ) .slice(0, 20) .sort((a, b) => { let aScore = 0 let bScore = 0 // Matches on screen name (i.e. user@instance) makes a priority aScore += a.screen_name.toLowerCase().startsWith(noPrefix) ? 2 : 0 bScore += b.screen_name.toLowerCase().startsWith(noPrefix) ? 2 : 0 // Matches on name takes second priority aScore += a.name.toLowerCase().startsWith(noPrefix) ? 1 : 0 bScore += b.name.toLowerCase().startsWith(noPrefix) ? 1 : 0 const diff = (bScore - aScore) * 10 // Then sort alphabetically const nameAlphabetically = a.name > b.name ? 1 : -1 const screenNameAlphabetically = a.screen_name > b.screen_name ? 1 : -1 return diff + nameAlphabetically + screenNameAlphabetically }) .map((user) => ({ user, displayText: user.screen_name_ui, detailText: user.name, imageUrl: user.profile_image_url_original, replacement: '@' + user.screen_name + ' ', })) suggestions = newSuggestions || [] return suggestions } }