UNPKG

stream-chat-react

Version:

React components to create chat conversations or livestream style chat

138 lines (137 loc) 5.68 kB
import mergeWith from 'lodash.mergewith'; import { BaseSearchSource, getTokenizedSuggestionDisplayName, getTriggerCharWithToken, insertItemWithTrigger, replaceWordWithEntity, } from 'stream-chat'; class EmojiSearchSource extends BaseSearchSource { constructor(emojiSearchIndex, options) { super(options); this.type = 'emoji'; this.emojiSearchIndex = emojiSearchIndex; } async query(searchQuery) { if (searchQuery.length === 0) { return { items: [], next: null }; } const emojis = (await this.emojiSearchIndex.search(searchQuery)) ?? []; // emojiIndex.search sometimes returns undefined values, so filter those out first return { items: emojis .filter(Boolean) .slice(0, 7) .map(({ emoticons = [], id, name, native, skins = [] }) => { const [firstSkin] = skins; return { emoticons, id, name, native: native ?? firstSkin.native, }; }), next: null, // todo: generate cursor }; } filterQueryResults(items) { return items.map((item) => ({ ...item, ...getTokenizedSuggestionDisplayName({ displayName: item.id, searchToken: this.searchQuery, }), })); } } const DEFAULT_OPTIONS = { minChars: 1, trigger: ':' }; /** * TextComposer middleware for mentions * Usage: * * const textComposer = new TextComposer(options); * * textComposer.use(new createTextComposerEmojiMiddleware(emojiSearchIndex, { * minChars: 2 * })); * * @param emojiSearchIndex * @param {{ * minChars: number; * trigger: string; * }} options * @returns */ export const createTextComposerEmojiMiddleware = (emojiSearchIndex, options) => { const finalOptions = mergeWith(DEFAULT_OPTIONS, options ?? {}); const emojiSearchSource = new EmojiSearchSource(emojiSearchIndex); emojiSearchSource.activate(); return { id: 'stream-io/emoji-middleware', // eslint-disable-next-line sort-keys handlers: { onChange: async ({ complete, forward, next, state }) => { if (!state.selection) return forward(); const triggerWithToken = getTriggerCharWithToken({ acceptTrailingSpaces: false, text: state.text.slice(0, state.selection.end), trigger: finalOptions.trigger, }); const triggerWasRemoved = !triggerWithToken || triggerWithToken.length < finalOptions.minChars; if (triggerWasRemoved) { const hasSuggestionsForTrigger = state.suggestions?.trigger === finalOptions.trigger; const newState = { ...state }; if (hasSuggestionsForTrigger && newState.suggestions) { delete newState.suggestions; } return next(newState); } const newSearchTriggerred = triggerWithToken && triggerWithToken === finalOptions.trigger; if (newSearchTriggerred) { emojiSearchSource.resetStateAndActivate(); } const textWithReplacedWord = await replaceWordWithEntity({ caretPosition: state.selection.end, getEntityString: async (word) => { const { items } = await emojiSearchSource.query(word); const emoji = items .filter(Boolean) .slice(0, 10) .find(({ emoticons }) => !!emoticons?.includes(word)); if (!emoji) return null; const [firstSkin] = emoji.skins ?? []; return emoji.native ?? firstSkin.native; }, text: state.text, }); if (textWithReplacedWord !== state.text) { return complete({ ...state, suggestions: undefined, // to prevent the TextComposerMiddlewareExecutor to run the first page query text: textWithReplacedWord, }); } return complete({ ...state, suggestions: { query: triggerWithToken.slice(1), searchSource: emojiSearchSource, trigger: finalOptions.trigger, }, }); }, onSuggestionItemSelect: ({ complete, forward, state }) => { const { selectedSuggestion } = state.change ?? {}; if (!selectedSuggestion || state.suggestions?.trigger !== finalOptions.trigger) return forward(); emojiSearchSource.resetStateAndActivate(); return complete({ ...state, ...insertItemWithTrigger({ insertText: `${'native' in selectedSuggestion ? selectedSuggestion.native : ''} `, selection: state.selection, text: state.text, trigger: finalOptions.trigger, }), suggestions: undefined, // Clear suggestions after selection }); }, }, }; };