UNPKG

stream-chat

Version:

JS SDK for the Stream Chat API

406 lines (350 loc) 13.1 kB
import { getTokenizedSuggestionDisplayName, getTriggerCharWithToken, insertItemWithTrigger, } from './textMiddlewareUtils'; import { BaseSearchSource, type SearchSourceOptions } from '../../../search'; import { mergeWith } from '../../../utils/mergeWith'; import type { TextComposerMiddlewareOptions, UserSuggestion } from './types'; import type { StreamChat } from '../../../client'; import type { MemberFilters, MemberSort, UserFilters, UserOptions, UserResponse, UserSort, } from '../../../types'; import type { Channel } from '../../../channel'; import { MAX_CHANNEL_MEMBER_COUNT_IN_CHANNEL_QUERY } from '../../../constants'; import type { Middleware } from '../../../middleware'; import type { TextComposerMiddlewareExecutorState } from './TextComposerMiddlewareExecutor'; // todo: the map is too small - Slavic letters with diacritics are missing for example export const accentsMap: { [key: string]: string } = { a: 'á|à|ã|â|À|Á|Ã|Â', c: 'ç|Ç', e: 'é|è|ê|É|È|Ê', i: 'í|ì|î|Í|Ì|Î', n: 'ñ|Ñ', o: 'ó|ò|ô|ő|õ|Ó|Ò|Ô|Õ', u: 'ú|ù|û|ü|Ú|Ù|Û|Ü', }; export const removeDiacritics = (text?: string) => { if (!text) return ''; return Object.keys(accentsMap).reduce( (acc, current) => acc.replace(new RegExp(accentsMap[current], 'g'), current), text, ); }; export const calculateLevenshtein = (query: string, name: string) => { if (query.length === 0) return name.length; if (name.length === 0) return query.length; const matrix = []; let i; for (i = 0; i <= name.length; i++) { matrix[i] = [i]; } let j; for (j = 0; j <= query.length; j++) { matrix[0][j] = j; } for (i = 1; i <= name.length; i++) { for (j = 1; j <= query.length; j++) { if (name.charAt(i - 1) === query.charAt(j - 1)) { matrix[i][j] = matrix[i - 1][j - 1]; } else { matrix[i][j] = Math.min( matrix[i - 1][j - 1] + 1, // substitution Math.min( matrix[i][j - 1] + 1, // insertion matrix[i - 1][j] + 1, ), ); // deletion } } } return matrix[name.length][query.length]; }; export type MentionsSearchSourceOptions = SearchSourceOptions & { mentionAllAppUsers?: boolean; textComposerText?: string; // todo: document that if you want transliteration, you need to provide the function, e.g. import {default: transliterate} from '@sindresorhus/transliterate'; // this is now replacing a parameter useMentionsTransliteration transliterate?: (text: string) => string; }; export class MentionsSearchSource extends BaseSearchSource<UserSuggestion> { readonly type = 'mentions'; protected client: StreamChat; protected channel: Channel; userFilters: UserFilters | undefined; memberFilters: MemberFilters | undefined; userSort: UserSort | undefined; memberSort: MemberSort | undefined; // todo: document there are filters and sort options for users and members searchOptions: Omit<UserOptions, 'limit' | 'offset'> | undefined; config: MentionsSearchSourceOptions; constructor(channel: Channel, options?: MentionsSearchSourceOptions) { const { mentionAllAppUsers, textComposerText, transliterate, ...restOptions } = options || {}; super(restOptions); this.client = channel.getClient(); this.channel = channel; this.config = { mentionAllAppUsers, textComposerText }; if (transliterate) { this.transliterate = transliterate; } } get allMembersLoadedWithInitialChannelQuery() { const countLoadedMembers = Object.keys(this.channel.state.members || {}).length; return countLoadedMembers < MAX_CHANNEL_MEMBER_COUNT_IN_CHANNEL_QUERY; } toUserSuggestion = (user: UserResponse): UserSuggestion => ({ ...user, ...getTokenizedSuggestionDisplayName({ displayName: user.name || user.id, searchToken: this.searchQuery, }), }); getStateBeforeFirstQuery(newSearchString: string) { const newState = super.getStateBeforeFirstQuery(newSearchString); const { items } = this.state.getLatestValue(); return { ...newState, items, // preserve items to avoid flickering }; } canExecuteQuery = (newSearchString?: string) => { const hasNewSearchQuery = typeof newSearchString !== 'undefined'; return this.isActive && !this.isLoading && (hasNewSearchQuery || this.hasNext); }; transliterate = (text: string) => text; getMembersAndWatchers = () => { const memberUsers = Object.values(this.channel.state.members ?? {}).map( ({ user }) => user, ); const watcherUsers = Object.values(this.channel.state.watchers ?? {}); const users = [...memberUsers, ...watcherUsers]; const uniqueUsers = {} as Record<string, UserResponse>; users.forEach((user) => { if (user && !uniqueUsers[user.id]) { uniqueUsers[user.id] = user; } }); return Object.values(uniqueUsers); }; searchMembersLocally = (searchQuery: string) => { const { textComposerText } = this.config; if (!textComposerText) return []; return this.getMembersAndWatchers() .filter((user) => { if (user.id === this.client.userID) return false; if (!searchQuery) return true; const updatedId = this.transliterate(removeDiacritics(user.id)).toLowerCase(); const updatedName = this.transliterate(removeDiacritics(user.name)).toLowerCase(); const updatedQuery = this.transliterate( removeDiacritics(searchQuery), ).toLowerCase(); const maxDistance = 3; const lastDigits = textComposerText.slice(-(maxDistance + 1)).includes('@'); if (updatedName) { const levenshtein = calculateLevenshtein(updatedQuery, updatedName); if ( updatedName.includes(updatedQuery) || (levenshtein <= maxDistance && lastDigits) ) { return true; } } const levenshtein = calculateLevenshtein(updatedQuery, updatedId); return ( updatedId.includes(updatedQuery) || (levenshtein <= maxDistance && lastDigits) ); }) .sort((a, b) => { if (!this.memberSort) return (a.name || '').localeCompare(b.name || ''); // Apply each sort criteria in order for (const [field, direction] of Object.entries(this.memberSort)) { const aValue = a[field as keyof UserResponse]; const bValue = b[field as keyof UserResponse]; if (aValue === bValue) continue; return direction === 1 ? String(aValue || '').localeCompare(String(bValue || '')) : String(bValue || '').localeCompare(String(aValue || '')); } return 0; }); }; prepareQueryUsersParams = (searchQuery: string) => ({ filters: { $or: [ { id: { $autocomplete: searchQuery } }, { name: { $autocomplete: searchQuery } }, ], ...this.userFilters, } as UserFilters, sort: this.userSort ?? ([{ name: 1 }, { id: 1 }] as UserSort), // todo: document the change - the sort is overridden, not merged options: { ...this.searchOptions, limit: this.pageSize, offset: this.offset }, }); prepareQueryMembersParams = (searchQuery: string) => { // QueryMembers failed with error: \"sort must contain at maximum 1 item\" const maxSortParamsCount = 1; let sort: MemberSort = [{ user_id: 1 }]; if (!this.memberSort) { sort = [{ user_id: 1 }]; } else if (Array.isArray(this.memberSort)) { sort = this.memberSort[0]; } else if (Object.keys(this.memberSort).length === maxSortParamsCount) { sort = this.memberSort; } // todo: document the change - the sort is overridden, not merged return { // todo: document the change - the filter is overridden, not merged filters: this.memberFilters ?? ({ name: { $autocomplete: searchQuery } } as MemberFilters), // autocomplete possible only for name sort, options: { ...this.searchOptions, limit: this.pageSize, offset: this.offset }, }; }; queryUsers = async (searchQuery: string) => { const { filters, sort, options } = this.prepareQueryUsersParams(searchQuery); const { users } = await this.client.queryUsers(filters, sort, options); return users; }; queryMembers = async (searchQuery: string) => { const { filters, sort, options } = this.prepareQueryMembersParams(searchQuery); const response = await this.channel.queryMembers(filters, sort, options); return response.members.map((member) => member.user) as UserResponse[]; }; async query(searchQuery: string) { let users: UserResponse[]; const shouldSearchLocally = this.allMembersLoadedWithInitialChannelQuery || !searchQuery; if (this.config.mentionAllAppUsers) { users = await this.queryUsers(searchQuery); } else if (shouldSearchLocally) { users = this.searchMembersLocally(searchQuery); } else { users = await this.queryMembers(searchQuery); } return { items: users.map(this.toUserSuggestion), }; } filterMutes = (data: UserSuggestion[]) => { const { textComposerText } = this.config; if (!textComposerText) return []; const { mutedUsers } = this.client; if (textComposerText.includes('/unmute') && !mutedUsers.length) { return []; } if (!mutedUsers.length) return data; if (textComposerText.includes('/unmute')) { return data.filter((suggestion) => mutedUsers.some((mute) => mute.target.id === suggestion.id), ); } return data.filter((suggestion) => mutedUsers.every((mute) => mute.target.id !== suggestion.id), ); }; filterQueryResults(items: UserSuggestion[]) { return this.filterMutes(items); } } const DEFAULT_OPTIONS: TextComposerMiddlewareOptions = { minChars: 1, trigger: '@' }; const userSuggestionToUserResponse = (suggestion: UserSuggestion): UserResponse => { // eslint-disable-next-line @typescript-eslint/no-unused-vars const { tokenizedDisplayName, ...userResponse } = suggestion; return userResponse; }; /** * TextComposer middleware for mentions * Usage: * * const textComposer = new TextComposer(options); * * textComposer.use(createMentionsMiddleware(channel, { * trigger: '$', * minChars: 2 * })); * * @param channel * @param {{ * minChars: number; * trigger: string; * }} options * @returns */ export type MentionsMiddleware = Middleware< TextComposerMiddlewareExecutorState<UserSuggestion>, 'onChange' | 'onSuggestionItemSelect' >; export const createMentionsMiddleware = ( channel: Channel, options?: Partial<TextComposerMiddlewareOptions> & { searchSource?: MentionsSearchSource; }, ): MentionsMiddleware => { const finalOptions = mergeWith(DEFAULT_OPTIONS, options ?? {}); let searchSource: MentionsSearchSource; if (options?.searchSource) { searchSource = options.searchSource; searchSource.resetState(); } else { searchSource = new MentionsSearchSource(channel); } searchSource.activate(); return { id: 'stream-io/text-composer/mentions-middleware', handlers: { onChange: ({ state, next, complete, forward }) => { if (!state.selection) return forward(); const triggerWithToken = getTriggerCharWithToken({ trigger: finalOptions.trigger, text: state.text.slice(0, state.selection.end), }); const newSearchTriggered = triggerWithToken && triggerWithToken.length === finalOptions.minChars; if (newSearchTriggered) { searchSource.resetStateAndActivate(); } const triggerWasRemoved = !triggerWithToken || triggerWithToken.length < finalOptions.minChars; if (triggerWasRemoved) { const hasStaleSuggestions = state.suggestions?.trigger === finalOptions.trigger; const newState = { ...state }; if (hasStaleSuggestions) { delete newState.suggestions; } return next(newState); } searchSource.config.textComposerText = state.text; return complete({ ...state, suggestions: { query: triggerWithToken.slice(1), searchSource, trigger: finalOptions.trigger, }, }); }, onSuggestionItemSelect: ({ state, complete, forward }) => { const { selectedSuggestion } = state.change ?? {}; if (!selectedSuggestion || state.suggestions?.trigger !== finalOptions.trigger) return forward(); searchSource.resetStateAndActivate(); return complete({ ...state, ...insertItemWithTrigger({ insertText: `@${selectedSuggestion.name || selectedSuggestion.id} `, selection: state.selection, text: state.text, trigger: finalOptions.trigger, }), mentionedUsers: state.mentionedUsers.concat( userSuggestionToUserResponse(selectedSuggestion), ), suggestions: undefined, }); }, }, }; };