stream-chat-react
Version:
React components to create chat conversations or livestream style chat
138 lines (137 loc) • 5.68 kB
JavaScript
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
});
},
},
};
};