diff --git a/src/client.ts b/src/client.ts index 50d535f68e..952b19609d 100644 --- a/src/client.ts +++ b/src/client.ts @@ -34,6 +34,8 @@ import { import type { ActiveLiveLocationsAPIResponse, + AddUserGroupMembersOptions, + AddUserGroupMembersResponse, APIErrorResponse, APIResponse, AppSettings, @@ -78,12 +80,15 @@ import type { CreatePollOptionAPIResponse, CreatePredefinedFilterOptions, CreateReminderOptions, + CreateUserGroupOptions, + CreateUserGroupResponse, CustomPermissionOptions, DeactivateUsersOptions, DeleteChannelsResponse, DeleteCommandResponse, DeleteMessageOptions, DeleteRetentionPolicyResponse, + DeleteUserGroupOptions, DeleteUserOptions, Device, DeviceIdentifier, @@ -126,6 +131,8 @@ import type { GetThreadOptions, GetUnreadCountAPIResponse, GetUnreadCountBatchAPIResponse, + GetUserGroupOptions, + GetUserGroupResponse, ListChannelResponse, ListCommandsResponse, ListImportsPaginationOptions, @@ -189,6 +196,8 @@ import type { QueryTeamUsageStatsResponse, QueryThreadsAPIResponse, QueryThreadsOptions, + QueryUserGroupsOptions, + QueryUserGroupsResponse, QueryVotesFilters, QueryVotesOptions, ReactionFilters, @@ -197,6 +206,8 @@ import type { ReactivateUserOptions, ReactivateUsersOptions, ReminderAPIResponse, + RemoveUserGroupMembersOptions, + RemoveUserGroupMembersResponse, ReviewFlagReportOptions, ReviewFlagReportResponse, SdkIdentifier, @@ -204,6 +215,8 @@ import type { SearchMessageSortBase, SearchOptions, SearchPayload, + SearchUserGroupsOptions, + SearchUserGroupsResponse, SegmentData, SegmentResponse, SegmentTargetsResponse, @@ -237,6 +250,8 @@ import type { UpdatePredefinedFilterOptions, UpdateReminderOptions, UpdateSegmentData, + UpdateUserGroupOptions, + UpdateUserGroupResponse, UpdateUsersAPIResponse, UpsertPushPreferencesResponse, UserCustomEvent, @@ -1819,6 +1834,120 @@ export class StreamChat { return data; } + /** + * queryUserGroups - List user groups with cursor-based pagination. + * + * @param {QueryUserGroupsOptions} options The query options + * + * @return {Promise} User Group Query Response + */ + async queryUserGroups(options: QueryUserGroupsOptions = {}) { + return await this.get(this.baseURL + '/usergroups', options); + } + + /** + * createUserGroup - Create a user group + * + * @param {CreateUserGroupOptions} options The create options + * + * @return {Promise} User Group Create Response + */ + async createUserGroup(options: CreateUserGroupOptions) { + return await this.post( + this.baseURL + '/usergroups', + options, + ); + } + + /** + * getUserGroup - Get a user group by ID + * + * @param {string} id The user group ID + * @param {GetUserGroupOptions} options Optional query options + * + * @return {Promise} User Group Get Response + */ + async getUserGroup(id: string, options: GetUserGroupOptions = {}) { + return await this.get( + `${this.baseURL}/usergroups/${encodeURIComponent(id)}`, + options, + ); + } + + /** + * searchUserGroups - Search user groups by prefix for autocomplete + * + * @param {SearchUserGroupsOptions} options The search options + * + * @return {Promise} User Group Search Response + */ + async searchUserGroups(options: SearchUserGroupsOptions) { + return await this.get( + this.baseURL + '/usergroups/search', + options, + ); + } + + /** + * updateUserGroup - Update a user group by ID + * + * @param {string} id The user group ID + * @param {UpdateUserGroupOptions} options The update options + * + * @return {Promise} User Group Update Response + */ + async updateUserGroup(id: string, options: UpdateUserGroupOptions) { + return await this.put( + `${this.baseURL}/usergroups/${encodeURIComponent(id)}`, + options, + ); + } + + /** + * deleteUserGroup - Delete a user group by ID + * + * @param {string} id The user group ID + * @param {DeleteUserGroupOptions} options Optional query options + * + * @return {Promise} User Group Delete Response + */ + async deleteUserGroup(id: string, options: DeleteUserGroupOptions = {}) { + return await this.delete( + `${this.baseURL}/usergroups/${encodeURIComponent(id)}`, + options, + ); + } + + /** + * addUserGroupMembers - Add members to a user group + * + * @param {string} id The user group ID + * @param {AddUserGroupMembersOptions} options The add-members options + * + * @return {Promise} User Group Add Members Response + */ + async addUserGroupMembers(id: string, options: AddUserGroupMembersOptions) { + return await this.post( + `${this.baseURL}/usergroups/${encodeURIComponent(id)}/members`, + options, + ); + } + + /** + * removeUserGroupMembers - Remove members from a user group + * + * @param {string} id The user group ID + * @param {RemoveUserGroupMembersOptions} options The remove-members options + * + * @return {Promise} User Group Remove Members Response + */ + async removeUserGroupMembers(id: string, options: RemoveUserGroupMembersOptions) { + return await this.post( + `${this.baseURL}/usergroups/${encodeURIComponent(id)}/members/delete`, + options, + ); + } + /** * queryBannedUsers - Query user bans * diff --git a/src/messageComposer/MessageComposerEffectHandlers.ts b/src/messageComposer/MessageComposerEffectHandlers.ts index 70e020f261..ae494c5677 100644 --- a/src/messageComposer/MessageComposerEffectHandlers.ts +++ b/src/messageComposer/MessageComposerEffectHandlers.ts @@ -30,6 +30,7 @@ const applyCommandActivationEffect: MessageComposerEffectHandler< composer.textComposer.state.next({ command: effect.command, mentionedUsers: [], + mentions: [], suggestions: undefined, selection: { start: 0, end: 0 }, text: '', diff --git a/src/messageComposer/middleware/messageComposer/textComposer.ts b/src/messageComposer/middleware/messageComposer/textComposer.ts index 5a702515b1..054dcfe1c8 100644 --- a/src/messageComposer/middleware/messageComposer/textComposer.ts +++ b/src/messageComposer/middleware/messageComposer/textComposer.ts @@ -1,11 +1,114 @@ +import type { MiddlewareHandlerParams } from '../../../middleware'; +import type { DraftMessage, LocalMessage, UserResponse } from '../../../types'; +import type { MessageComposer } from '../../messageComposer'; +import { mentionEntityToUserResponse } from '../textComposer/mentionUtils'; +import type { MentionEntity } from '../textComposer/types'; import type { MessageComposerMiddlewareState, MessageCompositionMiddleware, MessageDraftComposerMiddlewareValueState, MessageDraftCompositionMiddleware, } from './types'; -import type { MessageComposer } from '../../messageComposer'; -import type { MiddlewareHandlerParams } from '../../../middleware'; + +type MentionPayloadBase = Pick< + LocalMessage, + 'mentioned_channel' | 'mentioned_group_ids' | 'mentioned_here' | 'mentioned_roles' +>; + +type MentionCompositionMetadata = Omit< + Required, + 'mentioned_channel' | 'mentioned_here' +> & { + mentioned_channel: boolean; + mentioned_here: boolean; + mentioned_users: UserResponse[]; +}; + +type BuildMentionCompositionMetadataParams = { + mentions: MentionEntity[]; + text: string; +}; + +type DraftMentionPayload = Pick< + DraftMessage, + | 'mentioned_channel' + | 'mentioned_group_ids' + | 'mentioned_here' + | 'mentioned_roles' + | 'mentioned_users' +>; + +const textIncludesMentionToken = (text: string, token: string) => + text.includes(`@${token}`); +const isDefined = (value: TValue | undefined): value is TValue => + value !== undefined; + +const getMentionEntityTextCandidates = (entity: MentionEntity) => { + if (entity.mentionType === 'channel') return ['channel']; + if (entity.mentionType === 'here') return ['here']; + if (entity.mentionType === 'user') return [entity.id, entity.name].filter(isDefined); + if (entity.mentionType === 'role') return [entity.name, entity.id].filter(isDefined); + if (entity.mentionType === 'user_group') { + return entity.name ? [entity.name, entity.id].filter(isDefined) : []; + } + + return []; +}; + +const isMentionEntityPresentInText = (entity: MentionEntity, text: string) => { + const textCandidates = getMentionEntityTextCandidates(entity); + if (!textCandidates.length) return true; + + return textCandidates.some((candidate) => textIncludesMentionToken(text, candidate)); +}; + +const dedupeBy = ( + items: TItem[], + getKey: (item: TItem) => TKey, +) => { + const uniqueItems = new Map(); + + items.forEach((item) => { + uniqueItems.set(getKey(item), item); + }); + + return [...uniqueItems.values()]; +}; + +const buildMentionCompositionMetadata = ({ + mentions, + text, +}: BuildMentionCompositionMetadataParams): MentionCompositionMetadata => { + const presentMentions = dedupeBy( + mentions.filter((entity) => isMentionEntityPresentInText(entity, text)), + (entity) => `${entity.mentionType}:${entity.id}`, + ); + + return presentMentions.reduce( + (acc, entity) => { + if (entity.mentionType === 'user') { + acc.mentioned_users.push(mentionEntityToUserResponse(entity)); + } else if (entity.mentionType === 'channel') { + acc.mentioned_channel = true; + } else if (entity.mentionType === 'here') { + acc.mentioned_here = true; + } else if (entity.mentionType === 'role') { + acc.mentioned_roles.push(entity.id); + } else if (entity.mentionType === 'user_group') { + acc.mentioned_group_ids.push(entity.id); + } + + return acc; + }, + { + mentioned_channel: false, + mentioned_group_ids: [], + mentioned_here: false, + mentioned_roles: [], + mentioned_users: [], + }, + ); +}; export const createTextComposerCompositionMiddleware = ( composer: MessageComposer, @@ -18,30 +121,44 @@ export const createTextComposerCompositionMiddleware = ( forward, }: MiddlewareHandlerParams) => { if (!composer.textComposer) return forward(); - const { mentionedUsers, text } = composer.textComposer; - // Instead of checking if a user is still mentioned every time the text changes, - // just filter out non-mentioned users before submit, which is cheaper - // and allows users to easily undo any accidental deletion - const mentioned_users = Array.from( - new Set( - mentionedUsers.filter( - ({ id, name }) => text.includes(`@${id}`) || text.includes(`@${name}`), - ), - ), - ); - - // prevent introducing text and mentioned_users array into the payload sent to the server - if (!text && mentioned_users.length === 0) return forward(); + const { mentions, text } = composer.textComposer; + const { + mentioned_channel, + mentioned_group_ids, + mentioned_here, + mentioned_roles, + mentioned_users, + } = buildMentionCompositionMetadata({ mentions, text }); + + // prevent introducing text and mention metadata into the payload sent to the server + if ( + !text && + !mentioned_channel && + !mentioned_here && + mentioned_group_ids.length === 0 && + mentioned_roles.length === 0 && + mentioned_users.length === 0 + ) { + return forward(); + } return next({ ...state, localMessage: { ...state.localMessage, + mentioned_channel, + mentioned_group_ids, + mentioned_here, + mentioned_roles, mentioned_users, text, }, message: { ...state.message, + mentioned_channel, + mentioned_group_ids, + mentioned_here, + mentioned_roles, mentioned_users: mentioned_users.map((u) => u.id), text, }, @@ -62,31 +179,34 @@ export const createDraftTextComposerCompositionMiddleware = ( }: MiddlewareHandlerParams) => { if (!composer.textComposer) return forward(); const { maxLengthOnSend } = composer.config.text ?? {}; - const { mentionedUsers, text: inputText } = composer.textComposer; - // Instead of checking if a user is still mentioned every time the text changes, - // just filter out non-mentioned users before submit, which is cheaper - // and allows users to easily undo any accidental deletion - const mentioned_users = mentionedUsers.length - ? Array.from( - new Set( - mentionedUsers.filter( - ({ id, name }) => - inputText.includes(`@${id}`) || inputText.includes(`@${name}`), - ), - ), - ) - : undefined; + const { mentions, text: inputText } = composer.textComposer; const text = typeof maxLengthOnSend === 'number' && inputText.length > maxLengthOnSend ? inputText.slice(0, maxLengthOnSend) : inputText; + const { + mentioned_channel, + mentioned_group_ids, + mentioned_here, + mentioned_roles, + mentioned_users, + } = buildMentionCompositionMetadata({ mentions, text }); + const draftMentionPayload: DraftMentionPayload = { + ...(mentioned_channel ? { mentioned_channel: true } : {}), + ...(mentioned_group_ids.length ? { mentioned_group_ids } : {}), + ...(mentioned_here ? { mentioned_here: true } : {}), + ...(mentioned_roles.length ? { mentioned_roles } : {}), + ...(mentioned_users.length + ? { mentioned_users: mentioned_users.map((u) => u.id) } + : {}), + }; return next({ ...state, draft: { ...state.draft, - mentioned_users: mentioned_users?.map((u) => u.id), + ...draftMentionPayload, text, }, }); diff --git a/src/messageComposer/middleware/textComposer/mentionUtils.ts b/src/messageComposer/middleware/textComposer/mentionUtils.ts new file mode 100644 index 0000000000..0b0af1fa22 --- /dev/null +++ b/src/messageComposer/middleware/textComposer/mentionUtils.ts @@ -0,0 +1,33 @@ +import type { UserResponse } from '../../../types'; +import type { MentionEntity, UserMentionEntity, UserSuggestion } from './types'; + +export const isUserMentionEntity = (entity: MentionEntity): entity is UserMentionEntity => + entity.mentionType === 'user'; + +export const userResponseToMentionEntity = (user: UserResponse): UserMentionEntity => ({ + ...user, + mentionType: 'user', +}); + +export const userResponsesToMentionEntities = (users: UserResponse[]) => + users.map(userResponseToMentionEntity); + +export const mentionEntityToUserResponse = (entity: UserMentionEntity): UserResponse => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { mentionType, ...user } = entity; + return user; +}; + +export const userSuggestionToUserResponse = ( + suggestion: UserSuggestion, +): UserResponse => { + const { mentionType, tokenizedDisplayName, ...userResponse } = suggestion; + void mentionType; + void tokenizedDisplayName; + return userResponse; +}; + +export const userSuggestionToMentionEntity = ( + suggestion: UserSuggestion, +): UserMentionEntity => + userResponseToMentionEntity(userSuggestionToUserResponse(suggestion)); diff --git a/src/messageComposer/middleware/textComposer/mentions.ts b/src/messageComposer/middleware/textComposer/mentions.ts index 2007012317..9501cd3745 100644 --- a/src/messageComposer/middleware/textComposer/mentions.ts +++ b/src/messageComposer/middleware/textComposer/mentions.ts @@ -3,14 +3,30 @@ import { getTriggerCharWithToken, insertItemWithTrigger, } from './textMiddlewareUtils'; +import { + userResponsesToMentionEntities, + userSuggestionToMentionEntity, + userSuggestionToUserResponse, +} from './mentionUtils'; import { BaseSearchSource, type SearchSourceOptions } from '../../../search'; import { mergeWith } from '../../../utils/mergeWith'; -import type { TextComposerMiddlewareOptions, UserSuggestion } from './types'; +import type { + ChannelMentionSuggestion, + HereMentionSuggestion, + MentionEntity, + MentionSuggestion, + RoleMentionSuggestion, + TextComposerMiddlewareOptions, + UserGroupMentionSuggestion, + UserSuggestion, +} from './types'; import type { StreamChat } from '../../../client'; import type { MemberFilters, MemberSort, + SearchUserGroupsOptions, UserFilters, + UserGroupResponse, UserOptions, UserResponse, UserSort, @@ -75,17 +91,203 @@ export const calculateLevenshtein = (query: string, name: string) => { }; export type MentionsSearchSourceOptions = SearchSourceOptions & { + allowedMentionTypes?: Partial>; mentionAllAppUsers?: boolean; + suggestionFactoryMappers?: MentionSuggestionFactoryMapperOverrides; 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 { +type MentionType = MentionSuggestion['mentionType']; +type MentionSuggestionFactoryInputByType = { + channel: 'channel'; + here: 'here'; + role: string; + user: UserResponse; + user_group: UserGroupResponse; +}; +type MentionSuggestionByType = { + channel: ChannelMentionSuggestion; + here: HereMentionSuggestion; + role: RoleMentionSuggestion; + user: UserSuggestion; + user_group: UserGroupMentionSuggestion; +}; + +export type MentionSuggestionFactoryMapperContext = { + searchToken: string; + source: MentionsSearchSource; +}; + +export type MentionSuggestionFactoryMapper< + TMentionType extends MentionType = MentionType, +> = ( + value: MentionSuggestionFactoryInputByType[TMentionType], + context: MentionSuggestionFactoryMapperContext, +) => MentionSuggestionByType[TMentionType]; + +export type MentionSuggestionFactoryMapperOverrides = { + [TMentionType in MentionType]?: MentionSuggestionFactoryMapper; +}; + +const DEFAULT_ALLOWED_MENTION_TYPES: Record = { + channel: true, + here: true, + role: true, + user: true, + user_group: true, +}; + +type UserGroupSearchCursor = Pick; +type UserPaginationState = { + itemCount: number; + nextOffset?: number; +}; + +const decodeUserGroupCursor = (cursor?: string | null) => { + if (!cursor) return undefined; + + try { + return JSON.parse(cursor) as TCursor; + } catch { + return undefined; + } +}; + +const upsertUserResponse = (users: UserResponse[], user: UserResponse) => { + const existingIndex = users.findIndex((currentUser) => currentUser.id === user.id); + if (existingIndex === -1) return users.concat(user); + + const nextUsers = [...users]; + nextUsers.splice(existingIndex, 1, user); + return nextUsers; +}; + +const upsertMentionEntity = (mentions: MentionEntity[], entity: MentionEntity) => { + const existingIndex = mentions.findIndex( + (currentEntity) => + currentEntity.id === entity.id && currentEntity.mentionType === entity.mentionType, + ); + if (existingIndex === -1) return mentions.concat(entity); + + const nextMentions = [...mentions]; + nextMentions.splice(existingIndex, 1, entity); + return nextMentions; +}; + +const mentionSuggestionToEntity = (suggestion: MentionSuggestion): MentionEntity => { + if (suggestion.mentionType === 'user') { + return userSuggestionToMentionEntity(suggestion); + } else if (suggestion.mentionType === 'channel') { + return { + id: 'channel', + mentionType: 'channel', + name: 'channel', + }; + } else if (suggestion.mentionType === 'here') { + return { + id: 'here', + mentionType: 'here', + name: 'here', + }; + } else if (suggestion.mentionType === 'role') { + return { + id: suggestion.id, + mentionType: 'role', + name: suggestion.name, + }; + } else if (suggestion.mentionType === 'user_group') { + return { + id: suggestion.id, + mentionType: 'user_group', + name: suggestion.name, + }; + } + + throw new Error(`Unsupported mention suggestion type: ${JSON.stringify(suggestion)}`); +}; + +const mentionSuggestionToInsertText = (suggestion: MentionSuggestion) => + `@${suggestion.name || suggestion.id} `; + +const DEFAULT_SUGGESTION_FACTORY_MAPPERS: { + [TMentionType in MentionType]: MentionSuggestionFactoryMapper; +} = { + channel: (value, { searchToken }) => { + const name = String(value); + return { + id: name, + mentionType: 'channel', + name: 'channel', + ...getTokenizedSuggestionDisplayName({ + displayName: name, + searchToken, + }), + } satisfies ChannelMentionSuggestion; + }, + here: (value, { searchToken }) => { + const name = String(value); + return { + id: name, + mentionType: 'here', + name: 'here', + ...getTokenizedSuggestionDisplayName({ + displayName: name, + searchToken, + }), + } satisfies HereMentionSuggestion; + }, + role: (value, { searchToken }) => { + const role = String(value); + return { + id: role, + mentionType: 'role', + name: role, + ...getTokenizedSuggestionDisplayName({ + displayName: role, + searchToken, + }), + } satisfies RoleMentionSuggestion; + }, + user: (value, { searchToken }) => { + const user = value as UserResponse; + return { + ...user, + mentionType: 'user', + ...getTokenizedSuggestionDisplayName({ + displayName: user.name || user.id, + searchToken, + }), + } satisfies UserSuggestion; + }, + user_group: (value, { searchToken }) => { + const userGroup = value as UserGroupResponse; + return { + id: userGroup.id, + /* + Currently, all members of the group are always returned. Groups are limited to 100 members. + The memberCount == len(members) will always be true unless we add pagination here in the future + */ + memberCount: userGroup.members?.length, + mentionType: 'user_group', + name: userGroup.name, + ...getTokenizedSuggestionDisplayName({ + displayName: userGroup.name || userGroup.id, + searchToken, + }), + } satisfies UserGroupMentionSuggestion; + }, +}; + +export class MentionsSearchSource extends BaseSearchSource { readonly type = 'mentions'; protected client: StreamChat; protected channel: Channel; + protected latestUserPaginationState?: UserPaginationState; + protected roleNames?: string[]; + protected userGroupCursor?: string; userFilters: UserFilters | undefined; memberFilters: MemberFilters | undefined; userSort: UserSort | undefined; @@ -94,12 +296,26 @@ export class MentionsSearchSource extends BaseSearchSource { config: MentionsSearchSourceOptions; constructor(channel: Channel, options?: MentionsSearchSourceOptions) { - const { mentionAllAppUsers, textComposerText, transliterate, ...restOptions } = - options || {}; + const { + allowedMentionTypes, + mentionAllAppUsers, + suggestionFactoryMappers, + textComposerText, + transliterate, + ...restOptions + } = options || {}; super(restOptions); this.client = channel.getClient(); this.channel = channel; - this.config = { mentionAllAppUsers, textComposerText }; + this.config = { + allowedMentionTypes: { + ...DEFAULT_ALLOWED_MENTION_TYPES, + ...allowedMentionTypes, + }, + mentionAllAppUsers, + suggestionFactoryMappers, + textComposerText, + }; if (transliterate) { this.transliterate = transliterate; @@ -111,15 +327,62 @@ export class MentionsSearchSource extends BaseSearchSource { return countLoadedMembers < MAX_CHANNEL_MEMBER_COUNT_IN_CHANNEL_QUERY; } - toUserSuggestion = (user: UserResponse): UserSuggestion => ({ - ...user, - ...getTokenizedSuggestionDisplayName({ - displayName: user.name || user.id, - searchToken: this.searchQuery, - }), - }); + normalizeSearchValue = (value?: string) => + this.transliterate(removeDiacritics(value)).toLowerCase(); + + matchesSearchQuery = (value: string | undefined, searchQuery: string) => { + if (!searchQuery) return true; + return this.normalizeSearchValue(value).includes( + this.normalizeSearchValue(searchQuery), + ); + }; + + isMentionTypeAllowed = (mentionType: MentionType) => + this.config.allowedMentionTypes?.[mentionType] ?? true; + + protected mapMentionSuggestion = ( + mentionType: TMentionType, + value: MentionSuggestionFactoryInputByType[TMentionType], + searchToken = this.searchQuery, + ) => { + const mapper = + this.config.suggestionFactoryMappers?.[mentionType] ?? + DEFAULT_SUGGESTION_FACTORY_MAPPERS[mentionType]; + + return mapper(value, { + searchToken, + source: this, + }) as MentionSuggestionByType[TMentionType]; + }; + + getChannelTeam = () => this.channel.data?.team; + + toUserSuggestion = ( + user: UserResponse, + searchToken = this.searchQuery, + ): UserSuggestion => this.mapMentionSuggestion('user', user, searchToken); + + toChannelMentionSuggestion = ( + searchToken = this.searchQuery, + ): ChannelMentionSuggestion => + this.mapMentionSuggestion('channel', 'channel', searchToken); + + toHereMentionSuggestion = (searchToken = this.searchQuery): HereMentionSuggestion => + this.mapMentionSuggestion('here', 'here', searchToken); + + toRoleMentionSuggestion = ( + role: string, + searchToken = this.searchQuery, + ): RoleMentionSuggestion => this.mapMentionSuggestion('role', role, searchToken); + + toUserGroupMentionSuggestion = ( + userGroup: UserGroupResponse, + searchToken = this.searchQuery, + ): UserGroupMentionSuggestion => + this.mapMentionSuggestion('user_group', userGroup, searchToken); getStateBeforeFirstQuery(newSearchString: string) { + this.userGroupCursor = undefined; const newState = super.getStateBeforeFirstQuery(newSearchString); const { items } = this.state.getLatestValue(); return { @@ -133,6 +396,18 @@ export class MentionsSearchSource extends BaseSearchSource { return this.isActive && !this.isLoading && (hasNewSearchQuery || this.hasNext); }; + protected updatePaginationStateFromQuery() { + const userPaginationState = this.latestUserPaginationState ?? { itemCount: 0 }; + + return { + hasNext: + typeof userPaginationState.nextOffset !== 'undefined' || + typeof this.userGroupCursor !== 'undefined', + next: undefined, + offset: (this.offset ?? 0) + userPaginationState.itemCount, + }; + } + transliterate = (text: string) => text; getMembersAndWatchers = () => { @@ -153,6 +428,35 @@ export class MentionsSearchSource extends BaseSearchSource { return Object.values(uniqueUsers); }; + getAvailableRoles = async () => { + if (this.roleNames) return this.roleNames; + + const response = (await this.client.listRoles()) as { roles?: string[] }; + this.roleNames = [...(response.roles ?? [])].sort((left, right) => + left.localeCompare(right), + ); + return this.roleNames; + }; + + getBuiltinMentionSuggestions = (searchQuery: string): MentionSuggestion[] => + [ + ...(this.isMentionTypeAllowed('channel') + ? [this.toChannelMentionSuggestion(searchQuery)] + : []), + ...(this.isMentionTypeAllowed('here') + ? [this.toHereMentionSuggestion(searchQuery)] + : []), + ].filter(({ name }) => this.matchesSearchQuery(name, searchQuery)); + + getRoleMentionSuggestions = async ( + searchQuery: string, + ): Promise => + !this.isMentionTypeAllowed('role') + ? [] + : (await this.getAvailableRoles()) + .filter((role) => this.matchesSearchQuery(role, searchQuery)) + .map((role) => this.toRoleMentionSuggestion(role, searchQuery)); + searchMembersLocally = (searchQuery: string) => { const { textComposerText } = this.config; if (!textComposerText) return []; @@ -204,7 +508,7 @@ export class MentionsSearchSource extends BaseSearchSource { }); }; - prepareQueryUsersParams = (searchQuery: string) => ({ + prepareQueryUsersParams = (searchQuery: string, offset = 0) => ({ filters: { $or: [ { id: { $autocomplete: searchQuery } }, @@ -213,10 +517,10 @@ export class MentionsSearchSource extends BaseSearchSource { ...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 }, + options: { ...this.searchOptions, limit: this.pageSize, offset }, }); - prepareQueryMembersParams = (searchQuery: string) => { + prepareQueryMembersParams = (searchQuery: string, offset = 0) => { // QueryMembers failed with error: \"sort must contain at maximum 1 item\" const maxSortParamsCount = 1; let sort: MemberSort = [{ user_id: 1 }]; @@ -232,42 +536,160 @@ export class MentionsSearchSource extends BaseSearchSource { filters: this.memberFilters ?? ({ name: { $autocomplete: searchQuery } } as MemberFilters), // autocomplete possible only for name sort, - options: { ...this.searchOptions, limit: this.pageSize, offset: this.offset }, + options: { ...this.searchOptions, limit: this.pageSize, offset }, }; }; - queryUsers = async (searchQuery: string) => { - const { filters, sort, options } = this.prepareQueryUsersParams(searchQuery); + queryUsers = async (searchQuery: string, offset = 0) => { + const { filters, sort, options } = this.prepareQueryUsersParams(searchQuery, offset); const { users } = await this.client.queryUsers(filters, sort, options); return users; }; - queryMembers = async (searchQuery: string) => { - const { filters, sort, options } = this.prepareQueryMembersParams(searchQuery); + queryMembers = async (searchQuery: string, offset = 0) => { + const { filters, sort, options } = this.prepareQueryMembersParams( + searchQuery, + offset, + ); const response = await this.channel.queryMembers(filters, sort, options); return response.members.map((member) => member.user) as UserResponse[]; }; - async query(searchQuery: string) { + getUserSuggestionsPage = async (searchQuery: string, userOffset = 0) => { + if (!this.isMentionTypeAllowed('user')) { + return { + items: [], + nextOffset: undefined, + }; + } + let users: UserResponse[]; const shouldSearchLocally = this.allMembersLoadedWithInitialChannelQuery || !searchQuery; if (this.config.mentionAllAppUsers) { - users = await this.queryUsers(searchQuery); + users = await this.queryUsers(searchQuery, userOffset); } else if (shouldSearchLocally) { - users = this.searchMembersLocally(searchQuery); + const localUsers = this.searchMembersLocally(searchQuery); + const items = localUsers + .slice(userOffset, userOffset + this.pageSize) + .map((user) => this.toUserSuggestion(user, searchQuery)); + return { + items, + nextOffset: + localUsers.length > userOffset + this.pageSize + ? userOffset + items.length + : undefined, + }; } else { - users = await this.queryMembers(searchQuery); + users = await this.queryMembers(searchQuery, userOffset); } + const items = users.map((user) => this.toUserSuggestion(user, searchQuery)); return { - items: users.map(this.toUserSuggestion), + items, + nextOffset: users.length === this.pageSize ? userOffset + users.length : undefined, + }; + }; + + buildUserGroupSearchCursor = (items: UserGroupResponse[]) => { + if (items.length < this.pageSize) return undefined; + + const lastItem = items[items.length - 1]; + if (!lastItem?.name) return undefined; + + return JSON.stringify({ + id_gt: lastItem.id, + name_gt: lastItem.name, + } satisfies UserGroupSearchCursor); + }; + + getUserGroupSuggestionsPage = async (searchQuery: string, cursor?: string) => { + if (!this.isMentionTypeAllowed('user_group')) { + return { + items: [], + next: undefined, + }; + } + + if (!searchQuery) { + return { + items: [], + next: undefined, + }; + } + + const teamId = this.getChannelTeam(); + const userGroupCursor = decodeUserGroupCursor(cursor); + const options: SearchUserGroupsOptions = { + query: searchQuery, + limit: this.pageSize, + ...(teamId ? { team_id: teamId } : {}), + ...(userGroupCursor?.id_gt ? { id_gt: userGroupCursor.id_gt } : {}), + ...(userGroupCursor?.name_gt ? { name_gt: userGroupCursor.name_gt } : {}), + }; + const { user_groups } = await this.client.searchUserGroups(options); + + return { + items: user_groups.map((userGroup) => + this.toUserGroupMentionSuggestion(userGroup, searchQuery), + ), + next: this.buildUserGroupSearchCursor(user_groups), + }; + }; + + async query(searchQuery: string) { + const userOffset = this.offset ?? 0; + const isFirstPage = userOffset === 0 && typeof this.userGroupCursor === 'undefined'; + const previousUserPaginationState = this.latestUserPaginationState; + const previousUserGroupCursor = this.userGroupCursor; + const [userResultsState, userGroupResultsState, roleSuggestionsState] = + await Promise.allSettled([ + this.getUserSuggestionsPage(searchQuery, userOffset), + this.getUserGroupSuggestionsPage(searchQuery, previousUserGroupCursor), + isFirstPage + ? this.getRoleMentionSuggestions(searchQuery) + : Promise.resolve([] as RoleMentionSuggestion[]), + ]); + + const userResults = + userResultsState.status === 'fulfilled' + ? userResultsState.value + : { + items: [], + nextOffset: isFirstPage ? undefined : previousUserPaginationState?.nextOffset, + }; + const userGroupResults = + userGroupResultsState.status === 'fulfilled' + ? userGroupResultsState.value + : { + items: [], + next: isFirstPage ? undefined : previousUserGroupCursor, + }; + const roleSuggestions = + roleSuggestionsState.status === 'fulfilled' ? roleSuggestionsState.value : []; + const items = [ + ...(isFirstPage ? this.getBuiltinMentionSuggestions(searchQuery) : []), + ...roleSuggestions, + ...userGroupResults.items, + ...userResults.items, + ]; + + this.latestUserPaginationState = { + itemCount: userResults.items.length, + nextOffset: userResults.nextOffset, + }; + this.userGroupCursor = userGroupResults.next; + + return { + items, }; } - filterMutes = (data: UserSuggestion[]) => { + filterMutes(data: UserSuggestion[]): UserSuggestion[]; + filterMutes(data: MentionSuggestion[]): MentionSuggestion[]; + filterMutes(data: MentionSuggestion[]) { const { textComposerText } = this.config; if (!textComposerText) return []; @@ -278,28 +700,32 @@ export class MentionsSearchSource extends BaseSearchSource { 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) => + suggestion.mentionType === 'user' && + mutedUsers.some((mute) => mute.target.id === suggestion.id), ); } - return data.filter((suggestion) => - mutedUsers.every((mute) => mute.target.id !== suggestion.id), + return data.filter( + (suggestion) => + suggestion.mentionType !== 'user' || + mutedUsers.every((mute) => mute.target.id !== suggestion.id), ); - }; + } - filterQueryResults(items: UserSuggestion[]) { + filterQueryResults(items: MentionSuggestion[]) { return this.filterMutes(items); } + + resetState() { + this.latestUserPaginationState = undefined; + this.userGroupCursor = undefined; + super.resetState(); + } } 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: @@ -320,7 +746,7 @@ const userSuggestionToUserResponse = (suggestion: UserSuggestion): UserResponse */ export type MentionsMiddleware = Middleware< - TextComposerMiddlewareExecutorState, + TextComposerMiddlewareExecutorState, 'onChange' | 'onSuggestionItemSelect' >; @@ -386,17 +812,27 @@ export const createMentionsMiddleware = ( return forward(); searchSource.resetStateAndActivate(); + const mentionEntity = mentionSuggestionToEntity(selectedSuggestion); + const mentions = upsertMentionEntity( + state.mentions ?? userResponsesToMentionEntities(state.mentionedUsers), + mentionEntity, + ); return complete({ ...state, ...insertItemWithTrigger({ - insertText: `@${selectedSuggestion.name || selectedSuggestion.id} `, + insertText: mentionSuggestionToInsertText(selectedSuggestion), selection: state.selection, text: state.text, trigger: finalOptions.trigger, }), - mentionedUsers: state.mentionedUsers.concat( - userSuggestionToUserResponse(selectedSuggestion), - ), + mentionedUsers: + selectedSuggestion.mentionType === 'user' + ? upsertUserResponse( + state.mentionedUsers, + userSuggestionToUserResponse(selectedSuggestion), + ) + : state.mentionedUsers, + mentions, suggestions: undefined, }); }, diff --git a/src/messageComposer/middleware/textComposer/types.ts b/src/messageComposer/middleware/textComposer/types.ts index 0b9e21ef15..5853a3f52a 100644 --- a/src/messageComposer/middleware/textComposer/types.ts +++ b/src/messageComposer/middleware/textComposer/types.ts @@ -16,9 +16,72 @@ export type BaseSuggestion = { export type CommandSuggestionDisabledReason = 'editing' | 'quoted_message'; export type CommandSuggestion = BaseSuggestion & CommandResponse; -export type UserSuggestion = BaseSuggestion & UserResponse & TokenizationPayload; +export type UserSuggestion = BaseSuggestion & + UserResponse & + TokenizationPayload & { + mentionType: 'user'; + }; +export type ChannelMentionSuggestion = BaseSuggestion & + TokenizationPayload & { + mentionType: 'channel'; + name: 'channel'; + }; +export type HereMentionSuggestion = BaseSuggestion & + TokenizationPayload & { + mentionType: 'here'; + name: 'here'; + }; +export type RoleMentionSuggestion = BaseSuggestion & + TokenizationPayload & { + mentionType: 'role'; + name: string; + }; +export type UserGroupMentionSuggestion = BaseSuggestion & + TokenizationPayload & { + memberCount?: number; + mentionType: 'user_group'; + name: string; + }; +export type MentionSuggestion = + | UserSuggestion + | ChannelMentionSuggestion + | HereMentionSuggestion + | RoleMentionSuggestion + | UserGroupMentionSuggestion; + export type CustomValidSuggestion = BaseSuggestion & CustomTextComposerSuggestion; -export type Suggestion = CommandSuggestion | UserSuggestion | CustomValidSuggestion; +export type Suggestion = CommandSuggestion | MentionSuggestion | CustomValidSuggestion; + +export type UserMentionEntity = UserResponse & { + mentionType: 'user'; +}; +export type ChannelMentionEntity = { + id: 'channel'; + mentionType: 'channel'; + name: 'channel'; +}; +export type HereMentionEntity = { + id: 'here'; + mentionType: 'here'; + name: 'here'; +}; +export type RoleMentionEntity = { + id: string; + mentionType: 'role'; + name?: string; +}; +export type UserGroupMentionEntity = { + id: string; + mentionType: 'user_group'; + name?: string; +}; + +export type MentionEntity = + | UserMentionEntity + | ChannelMentionEntity + | HereMentionEntity + | RoleMentionEntity + | UserGroupMentionEntity; export type TextComposerStateSnapshot = TextComposerState; @@ -55,7 +118,11 @@ export type Suggestions = { export type TextSelection = { end: number; start: number }; export type TextComposerState = { + /** + * @deprecated Use `mentions` instead. + */ mentionedUsers: UserResponse[]; + mentions?: MentionEntity[]; selection: TextSelection; text: string; command?: CommandResponse | null; diff --git a/src/messageComposer/textComposer.ts b/src/messageComposer/textComposer.ts index c2bbe2d1d9..fc53728bae 100644 --- a/src/messageComposer/textComposer.ts +++ b/src/messageComposer/textComposer.ts @@ -5,11 +5,18 @@ import type { TextComposerMiddlewareExecutorState } from './middleware'; import type { TextComposerSuggestion } from './middleware/textComposer/types'; import type { TextSelection } from './middleware/textComposer/types'; import type { + MentionEntity, TextComposerCommandActivationEffect, TextComposerState, TextComposerStateSnapshot, + UserMentionEntity, } from './middleware/textComposer/types'; import type { Suggestions } from './middleware/textComposer/types'; +import { + isUserMentionEntity, + mentionEntityToUserResponse, + userResponseToMentionEntity, +} from './middleware/textComposer/mentionUtils'; import type { MessageComposer } from './messageComposer'; import type { CommandResponse, DraftMessage, LocalMessage, UserResponse } from '../types'; @@ -34,6 +41,60 @@ export const textIsEmpty = (text: string) => { ); }; +const getInitialMentions = (message?: DraftMessage | LocalMessage): MentionEntity[] => { + if (!message) return []; + + const mentions: MentionEntity[] = (message.mentioned_users ?? []).map( + (item: string | UserResponse) => + typeof item === 'string' + ? ({ id: item, mentionType: 'user' } as UserMentionEntity) + : { ...item, mentionType: 'user' }, + ); + + if (message.mentioned_channel) { + mentions.push({ + id: 'channel', + mentionType: 'channel', + name: 'channel', + }); + } + + if (message.mentioned_here) { + mentions.push({ + id: 'here', + mentionType: 'here', + name: 'here', + }); + } + + if (message.mentioned_roles?.length) { + mentions.push( + ...message.mentioned_roles.map((role) => ({ + id: role, + mentionType: 'role' as const, + name: role, + })), + ); + } + + if (message.mentioned_group_ids?.length) { + mentions.push( + ...message.mentioned_group_ids.map((groupId) => ({ + id: groupId, + mentionType: 'user_group' as const, + })), + ); + } + + return mentions; +}; + +const isSameMentionEntity = (first: MentionEntity, second: MentionEntity) => + first.id === second.id && first.mentionType === second.mentionType; + +const getMentionsFromState = (state: TextComposerState) => + state.mentions ?? state.mentionedUsers.map(userResponseToMentionEntity); + const initState = ({ composer, message, @@ -46,15 +107,20 @@ const initState = ({ return { command: null, mentionedUsers: [], + mentions: [], text, selection: { start: text.length, end: text.length }, }; } const text = message.text ?? ''; - return { - mentionedUsers: (message.mentioned_users ?? []).map((item: string | UserResponse) => + const mentions = getInitialMentions(message); + const mentionedUsers = (message.mentioned_users ?? []).map( + (item: string | UserResponse) => typeof item === 'string' ? ({ id: item } as UserResponse) : item, - ), + ); + return { + mentionedUsers, + mentions, text, selection: { start: text.length, end: text.length }, }; @@ -134,6 +200,10 @@ export class TextComposer { return this.state.getLatestValue().mentionedUsers; } + get mentions() { + return getMentionsFromState(this.state.getLatestValue()); + } + get selection() { return this.state.getLatestValue().selection; } @@ -161,7 +231,22 @@ export class TextComposer { }; setMentionedUsers(users: UserResponse[]) { - this.state.partialNext({ mentionedUsers: users }); + const nonUserMentions = this.mentions.filter( + (entity) => !isUserMentionEntity(entity), + ); + this.state.partialNext({ + mentionedUsers: users, + mentions: [...nonUserMentions, ...users.map(userResponseToMentionEntity)], + }); + } + + setMentions(entities: MentionEntity[]) { + this.state.partialNext({ + mentionedUsers: entities + .filter(isUserMentionEntity) + .map(mentionEntityToUserResponse), + mentions: entities, + }); } clearCommand() { @@ -174,27 +259,75 @@ export class TextComposer { }); } + /** + * @deprecated Use `upsertMentionEntity({ ...user, mentionType: 'user' })` instead. + */ upsertMentionedUser = (user: UserResponse) => { const mentionedUsers = [...this.mentionedUsers]; - const existingUserIndex = mentionedUsers.findIndex((u) => u.id === user.id); + const existingUserIndex = mentionedUsers.findIndex((entity) => entity.id === user.id); if (existingUserIndex >= 0) { mentionedUsers.splice(existingUserIndex, 1, user); - this.state.partialNext({ mentionedUsers }); + this.setMentionedUsers(mentionedUsers); } else { mentionedUsers.push(user); - this.state.partialNext({ mentionedUsers }); + this.setMentionedUsers(mentionedUsers); } }; + /** + * @deprecated Use `getMentionEntity('user', userId)` instead. + */ getMentionedUser = (userId: string) => - this.state.getLatestValue().mentionedUsers.find((u: UserResponse) => u.id === userId); + this.mentionedUsers.find((user) => user.id === userId); + /** + * @deprecated Use `removeMentionEntity('user', userId)` instead. + */ removeMentionedUser = (userId: string) => { - const existingUserIndex = this.mentionedUsers.findIndex((u) => u.id === userId); + const existingUserIndex = this.mentionedUsers.findIndex( + (entity) => entity.id === userId, + ); if (existingUserIndex === -1) return; const mentionedUsers = [...this.mentionedUsers]; mentionedUsers.splice(existingUserIndex, 1); - this.state.partialNext({ mentionedUsers }); + this.setMentionedUsers(mentionedUsers); + }; + + upsertMentionEntity = (entity: MentionEntity) => { + const mentions = [...this.mentions]; + const existingEntityIndex = mentions.findIndex((currentEntity) => + isSameMentionEntity(currentEntity, entity), + ); + + if (existingEntityIndex >= 0) { + mentions.splice(existingEntityIndex, 1, entity); + } else { + mentions.push(entity); + } + + this.setMentions(mentions); + }; + + getMentionEntity = ( + mentionType: MentionEntity['mentionType'], + id: MentionEntity['id'], + ) => + this.mentions.find( + (entity) => entity.mentionType === mentionType && entity.id === id, + ); + + removeMentionEntity = ( + mentionType: MentionEntity['mentionType'], + id: MentionEntity['id'], + ) => { + const existingEntityIndex = this.mentions.findIndex( + (entity) => entity.mentionType === mentionType && entity.id === id, + ); + if (existingEntityIndex === -1) return; + + const mentions = [...this.mentions]; + mentions.splice(existingEntityIndex, 1); + this.setMentions(mentions); }; setCommand = (command: CommandResponse | null) => { diff --git a/src/pagination/UserGroupPaginator.ts b/src/pagination/UserGroupPaginator.ts new file mode 100644 index 0000000000..9eee5fe862 --- /dev/null +++ b/src/pagination/UserGroupPaginator.ts @@ -0,0 +1,93 @@ +import { BasePaginator } from './BasePaginator'; +import type { + PaginationQueryParams, + PaginationQueryReturnValue, + PaginatorOptions, + PaginatorState, +} from './BasePaginator'; +import type { QueryUserGroupsOptions, UserGroupResponse } from '../types'; +import type { StreamChat } from '../client'; + +type UserGroupListCursor = { + created_at_gt: string; + id_gt: string; +}; + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + +const decodeCursor = (cursor: string | null | undefined) => { + if (!cursor) return undefined; + + try { + const parsed = JSON.parse(cursor); + return isRecord(parsed) ? (parsed as TCursor) : undefined; + } catch { + return undefined; + } +}; + +/** + * Paginates user-group listing through `/usergroups`. + * + * This entity only supports forward cursor pagination via `created_at_gt` and `id_gt`. + * Previous-page pagination is not available because the API does not expose a backward cursor. + */ +export class UserGroupPaginator extends BasePaginator { + private client: StreamChat; + protected _teamId: string | undefined; + + constructor(client: StreamChat, options?: PaginatorOptions) { + super(options); + this.client = client; + } + + get initialState(): PaginatorState { + return { + ...super.initialState, + hasPrev: false, + }; + } + + get teamId() { + return this._teamId; + } + + set teamId(teamId: string | undefined) { + if (teamId === this._teamId) return; + this._teamId = teamId; + this.resetState(); + } + + private buildNextCursor = (items: UserGroupResponse[]) => { + if (items.length < this.pageSize) return undefined; + const lastItem = items[items.length - 1]; + if (!lastItem) return undefined; + + return JSON.stringify({ + created_at_gt: lastItem.created_at, + id_gt: lastItem.id, + } satisfies UserGroupListCursor); + }; + + query = async ({ + direction, + }: PaginationQueryParams): Promise> => { + if (direction === 'prev') { + return { items: [] }; + } + + const cursor = decodeCursor(this.cursor?.next); + const options: QueryUserGroupsOptions = { + limit: this.pageSize, + ...(this.teamId ? { team_id: this.teamId } : {}), + ...(cursor?.id_gt ? { id_gt: cursor.id_gt } : {}), + ...(cursor?.created_at_gt ? { created_at_gt: cursor.created_at_gt } : {}), + }; + + const { user_groups: items } = await this.client.queryUserGroups(options); + return { items, next: this.buildNextCursor(items) }; + }; + + filterQueryResults = (items: UserGroupResponse[]) => items; +} diff --git a/src/pagination/index.ts b/src/pagination/index.ts index 19e2a53b80..6334e20f8a 100644 --- a/src/pagination/index.ts +++ b/src/pagination/index.ts @@ -1,3 +1,4 @@ export * from './BasePaginator'; export * from './FilterBuilder'; export * from './ReminderPaginator'; +export * from './UserGroupPaginator'; diff --git a/src/types.ts b/src/types.ts index 8d47ba55c4..2d8aaac7bf 100644 --- a/src/types.ts +++ b/src/types.ts @@ -754,6 +754,7 @@ export type MessageResponseBase = MessageBase & { mentioned_users?: UserResponse[]; mentioned_channel?: boolean; mentioned_here?: boolean; + mentioned_group_ids?: string[]; mentioned_roles?: string[]; message_text_updated_at?: string; moderation?: ModerationResponse; // present only with Moderation v2 @@ -2876,6 +2877,9 @@ export type Message = Partial< mentioned_users: string[]; shared_location?: StaticLocationPayload | LiveLocationPayload; mentioned_channel?: boolean; + mentioned_here?: boolean; + mentioned_group_ids?: string[]; + mentioned_roles?: string[]; } >; @@ -3148,6 +3152,10 @@ export type ReservedUpdatedMessageFields = keyof typeof RESERVED_UPDATED_MESSAGE export type UpdatedMessage = Omit & { mentioned_users?: string[]; + mentioned_channel?: boolean; + mentioned_here?: boolean; + mentioned_group_ids?: string[]; + mentioned_roles?: string[]; type?: MessageLabel; }; @@ -4454,6 +4462,10 @@ export type DraftMessage = { custom?: {}; html?: string; mentioned_users?: string[]; + mentioned_channel?: boolean; + mentioned_here?: boolean; + mentioned_group_ids?: string[]; + mentioned_roles?: string[]; mml?: string; parent_id?: string; poll_id?: string; @@ -4671,6 +4683,105 @@ export type QueryRemindersResponse = { next?: string; }; +export type UserGroupMemberResponse = { + group_id: string; + user_id: string; + is_admin: boolean; + created_at: string; +}; + +export type UserGroupResponse = { + id: string; + name: string; + created_at: string; + updated_at: string; + description?: string; + team_id?: string; + members?: UserGroupMemberResponse[]; + created_by?: string; +}; + +export type CreateUserGroupOptions = { + /** Human-readable user group name */ + name: string; + /** Optional user group description shown to members */ + description?: string; + /** Optional custom user group ID. If omitted, the backend generates one */ + id?: string; + /** Optional list of user IDs to add as members when the group is created */ + member_ids?: string[]; + /** Optional team ID that scopes the user group to a specific team */ + team_id?: string; +}; + +export type CreateUserGroupResponse = APIResponse & { + user_group: UserGroupResponse; +}; + +export type GetUserGroupOptions = { + team_id?: string; +}; + +export type GetUserGroupResponse = APIResponse & { + user_group: UserGroupResponse; +}; + +export type QueryUserGroupsOptions = { + limit?: number; + id_gt?: string; + created_at_gt?: string; + team_id?: string; +}; + +export type QueryUserGroupsResponse = APIResponse & { + user_groups: UserGroupResponse[]; +}; + +export type SearchUserGroupsOptions = { + query: string; + limit?: number; + id_gt?: string; + name_gt?: string; + team_id?: string; +}; + +export type SearchUserGroupsResponse = APIResponse & { + user_groups: UserGroupResponse[]; +}; + +export type UpdateUserGroupOptions = { + description?: string; + name?: string; + team_id?: string; +}; + +export type UpdateUserGroupResponse = APIResponse & { + user_group: UserGroupResponse; +}; + +export type DeleteUserGroupOptions = { + team_id?: string; +}; + +export type AddUserGroupMembersOptions = { + member_ids: string[]; + as_admin?: boolean; + team_id?: string; +}; + +export type AddUserGroupMembersResponse = APIResponse & { + user_group: UserGroupResponse; +}; + +export type RemoveUserGroupMembersOptions = { + member_ids: string[]; + team_id?: string; +}; + +export type RemoveUserGroupMembersResponse = APIResponse & { + user_group: UserGroupResponse; +}; + export type HookType = 'webhook' | 'sqs' | 'sns' | 'pending_message'; export type EventHook = { diff --git a/test/unit/MessageComposer/messageComposer.test.ts b/test/unit/MessageComposer/messageComposer.test.ts index 928083197b..ccad16a24b 100644 --- a/test/unit/MessageComposer/messageComposer.test.ts +++ b/test/unit/MessageComposer/messageComposer.test.ts @@ -520,6 +520,7 @@ describe('MessageComposer', () => { messageComposer.textComposer.state.partialNext({ text: '', mentionedUsers: [], + mentions: [], selection: { start: 0, end: 0 }, }); expect(messageComposer.hasSendableData).toBe(false); @@ -579,6 +580,7 @@ describe('MessageComposer', () => { messageComposer.textComposer.state.partialNext({ text: '', mentionedUsers: [], + mentions: [], selection: { start: 0, end: 0 }, }); expect(messageComposer.compositionIsEmpty).toBe(true); @@ -631,6 +633,7 @@ describe('MessageComposer', () => { messageComposer.textComposer.state.partialNext({ text: '', mentionedUsers: [], + mentions: [], selection: { start: 0, end: 0 }, }); expect(messageComposer.contentIsEmpty).toBe(true); @@ -641,6 +644,7 @@ describe('MessageComposer', () => { messageComposer.textComposer.state.partialNext({ text: '', mentionedUsers: [], + mentions: [], selection: { start: 0, end: 0 }, }); @@ -670,6 +674,7 @@ describe('MessageComposer', () => { messageComposer.textComposer.state.partialNext({ text: '', mentionedUsers: [], + mentions: [], selection: { start: 0, end: 0 }, }); messageComposer.setQuotedMessage({ @@ -1042,6 +1047,10 @@ describe('MessageComposer', () => { deleted_at: null, error: null, id: 'test-uuid', + mentioned_channel: false, + mentioned_group_ids: [], + mentioned_here: false, + mentioned_roles: [], mentioned_users: [], parent_id: undefined, pinned_at: null, @@ -1059,6 +1068,10 @@ describe('MessageComposer', () => { }, message: { id: 'test-uuid', + mentioned_channel: false, + mentioned_group_ids: [], + mentioned_here: false, + mentioned_roles: [], mentioned_users: [], parent_id: undefined, text: 'Test message', @@ -1109,6 +1122,10 @@ describe('MessageComposer', () => { deleted_at: null, error: null, id: 'test-uuid', + mentioned_channel: false, + mentioned_group_ids: [], + mentioned_here: false, + mentioned_roles: [], mentioned_users: [], parent_id: undefined, pinned: true, @@ -1138,6 +1155,10 @@ describe('MessageComposer', () => { }, ], id: 'test-uuid', + mentioned_channel: false, + mentioned_group_ids: [], + mentioned_here: false, + mentioned_roles: [], mentioned_users: [], parent_id: undefined, pinned: true, @@ -1159,6 +1180,42 @@ describe('MessageComposer', () => { }); }); + it('should compose enhanced mention payload fields', async () => { + const { messageComposer } = setup(); + messageComposer.textComposer.setText( + '@user-id @channel @here @admin @Backend Team', + ); + messageComposer.textComposer.setMentions([ + { id: 'user-id', mentionType: 'user', name: 'User Name' }, + { id: 'channel', mentionType: 'channel', name: 'channel' }, + { id: 'here', mentionType: 'here', name: 'here' }, + { id: 'admin', mentionType: 'role', name: 'admin' }, + { id: 'backend-team', mentionType: 'user_group', name: 'Backend Team' }, + ]); + + const result = await messageComposer.compose(); + + expect(result?.message).toEqual( + expect.objectContaining({ + mentioned_channel: true, + mentioned_group_ids: ['backend-team'], + mentioned_here: true, + mentioned_roles: ['admin'], + mentioned_users: ['user-id'], + text: '@user-id @channel @here @admin @Backend Team', + }), + ); + expect(result?.localMessage).toEqual( + expect.objectContaining({ + mentioned_channel: true, + mentioned_group_ids: ['backend-team'], + mentioned_here: true, + mentioned_roles: ['admin'], + mentioned_users: [{ id: 'user-id', name: 'User Name' }], + }), + ); + }); + it('should return undefined when compose middleware returns discard status', async () => { const { messageComposer } = setup(); const mockResult = { @@ -1208,6 +1265,35 @@ describe('MessageComposer', () => { expect(result).toEqual(mockResult.state); }); + it('should compose draft with enhanced mention payload fields', async () => { + const { messageComposer } = setup(); + messageComposer.textComposer.setText( + '@user-id @channel @here @admin @Backend Team', + ); + messageComposer.textComposer.setMentions([ + { id: 'user-id', mentionType: 'user', name: 'User Name' }, + { id: 'channel', mentionType: 'channel', name: 'channel' }, + { id: 'here', mentionType: 'here', name: 'here' }, + { id: 'admin', mentionType: 'role', name: 'admin' }, + { id: 'backend-team', mentionType: 'user_group', name: 'Backend Team' }, + ]); + + const result = await messageComposer.composeDraft(); + + expect(result?.draft).toEqual( + expect.objectContaining({ + id: 'test-uuid', + mentioned_channel: true, + mentioned_group_ids: ['backend-team'], + mentioned_here: true, + mentioned_roles: ['admin'], + mentioned_users: ['user-id'], + parent_id: undefined, + text: '@user-id @channel @here @admin @Backend Team', + }), + ); + }); + it('should return undefined when draft compose middleware returns discard status', async () => { const { messageComposer } = setup(); const mockResult = { @@ -2055,6 +2141,7 @@ describe('MessageComposer', () => { messageComposer.textComposer.state.next({ text: 'New text', mentionedUsers: [], + mentions: [], selection: { start: 0, end: 0 }, }); @@ -2077,6 +2164,7 @@ describe('MessageComposer', () => { messageComposer.textComposer.state.next({ text: 'https://example.com', mentionedUsers: [], + mentions: [], selection: { start: 0, end: 0 }, }); @@ -2199,6 +2287,7 @@ describe('MessageComposer', () => { messageComposer.textComposer.state.next({ text: 'Hello world', mentionedUsers: [], + mentions: [], selection: { start: 0, end: 0 }, }); expect(spy).not.toHaveBeenCalled(); diff --git a/test/unit/MessageComposer/middleware/messageComposer/textComposer.test.ts b/test/unit/MessageComposer/middleware/messageComposer/textComposer.test.ts index 610b04146b..362abaad20 100644 --- a/test/unit/MessageComposer/middleware/messageComposer/textComposer.test.ts +++ b/test/unit/MessageComposer/middleware/messageComposer/textComposer.test.ts @@ -58,6 +58,9 @@ describe('stream-io/message-composer-middleware/text-composition', () => { } as any; const textComposer = { + get mentions() { + return []; + }, get text() { return ''; }, @@ -196,9 +199,9 @@ describe('stream-io/message-composer-middleware/text-composition', () => { vi.spyOn(messageComposer.textComposer, 'text', 'get').mockReturnValue( '@user1 @user2', ); - vi.spyOn(messageComposer.textComposer, 'mentionedUsers', 'get').mockReturnValue([ - { id: 'user1', name: 'User 1' }, - { id: 'user2', name: 'User 2' }, + vi.spyOn(messageComposer.textComposer, 'mentions', 'get').mockReturnValue([ + { id: 'user1', mentionType: 'user', name: 'User 1' }, + { id: 'user2', mentionType: 'user', name: 'User 2' }, ]); const result = await textComposerMiddleware.handlers.compose( @@ -240,9 +243,9 @@ describe('stream-io/message-composer-middleware/text-composition', () => { it('should remove stale mentions', async () => { vi.spyOn(messageComposer.textComposer, 'text', 'get').mockReturnValue('@user1'); - vi.spyOn(messageComposer.textComposer, 'mentionedUsers', 'get').mockReturnValue([ - { id: 'user1', name: 'User 1' }, - { id: 'user2', name: 'User 2' }, + vi.spyOn(messageComposer.textComposer, 'mentions', 'get').mockReturnValue([ + { id: 'user1', mentionType: 'user', name: 'User 1' }, + { id: 'user2', mentionType: 'user', name: 'User 2' }, ]); const result = await textComposerMiddleware.handlers.compose( @@ -281,6 +284,103 @@ describe('stream-io/message-composer-middleware/text-composition', () => { expect(result.state.localMessage.mentioned_users?.[0]?.id).toBe('user1'); }); + it('should map mixed enhanced mentions into message payloads', async () => { + vi.spyOn(messageComposer.textComposer, 'text', 'get').mockReturnValue( + '@user1 @channel @here @admin @Backend Team', + ); + vi.spyOn(messageComposer.textComposer, 'mentions', 'get').mockReturnValue([ + { id: 'user1', mentionType: 'user', name: 'User 1' }, + { id: 'channel', mentionType: 'channel', name: 'channel' }, + { id: 'here', mentionType: 'here', name: 'here' }, + { id: 'admin', mentionType: 'role', name: 'admin' }, + { id: 'backend-team', mentionType: 'user_group', name: 'Backend Team' }, + ]); + + const result = await textComposerMiddleware.handlers.compose( + setup({ + message: { + id: 'test-id', + parent_id: undefined, + type: 'regular', + mentioned_users: [] as string[], + }, + localMessage: { + attachments: [], + created_at: new Date(), + deleted_at: null, + error: undefined, + id: 'test-id', + mentioned_users: [] as Array<{ id: string; name: string }>, + parent_id: undefined, + pinned_at: null, + reaction_groups: null, + status: 'sending', + text: '', + type: 'regular', + updated_at: new Date(), + }, + sendOptions: {}, + }), + ); + + expect(result.state.message.mentioned_users).toEqual(['user1']); + expect(result.state.message.mentioned_channel).toBe(true); + expect(result.state.message.mentioned_here).toBe(true); + expect(result.state.message.mentioned_roles).toEqual(['admin']); + expect(result.state.message.mentioned_group_ids).toEqual(['backend-team']); + expect(result.state.localMessage.mentioned_users?.[0]?.id).toBe('user1'); + expect(result.state.localMessage.mentioned_channel).toBe(true); + expect(result.state.localMessage.mentioned_here).toBe(true); + expect(result.state.localMessage.mentioned_roles).toEqual(['admin']); + expect(result.state.localMessage.mentioned_group_ids).toEqual(['backend-team']); + }); + + it('should remove stale enhanced mentions and keep user groups without display names', async () => { + vi.spyOn(messageComposer.textComposer, 'text', 'get').mockReturnValue( + '@user1 @channel @Backend Team', + ); + vi.spyOn(messageComposer.textComposer, 'mentions', 'get').mockReturnValue([ + { id: 'user1', mentionType: 'user', name: 'User 1' }, + { id: 'channel', mentionType: 'channel', name: 'channel' }, + { id: 'here', mentionType: 'here', name: 'here' }, + { id: 'admin', mentionType: 'role', name: 'admin' }, + { id: 'backend-team', mentionType: 'user_group' }, + ]); + + const result = await textComposerMiddleware.handlers.compose( + setup({ + message: { + id: 'test-id', + parent_id: undefined, + type: 'regular', + mentioned_users: [] as string[], + }, + localMessage: { + attachments: [], + created_at: new Date(), + deleted_at: null, + error: undefined, + id: 'test-id', + mentioned_users: [] as Array<{ id: string; name: string }>, + parent_id: undefined, + pinned_at: null, + reaction_groups: null, + status: 'sending', + text: '', + type: 'regular', + updated_at: new Date(), + }, + sendOptions: {}, + }), + ); + + expect(result.state.message.mentioned_users).toEqual(['user1']); + expect(result.state.message.mentioned_channel).toBe(true); + expect(result.state.message.mentioned_here).toBe(false); + expect(result.state.message.mentioned_roles).toEqual([]); + expect(result.state.message.mentioned_group_ids).toEqual(['backend-team']); + }); + it('should handle message with commands', async () => { vi.spyOn(messageComposer.textComposer, 'text', 'get').mockReturnValue('/giphy hello'); @@ -374,6 +474,9 @@ describe('stream-io/message-composer-middleware/draft-text-composition', () => { } as any; const textComposer = { + get mentions() { + return []; + }, get text() { return ''; }, @@ -482,9 +585,9 @@ describe('stream-io/message-composer-middleware/draft-text-composition', () => { vi.spyOn(messageComposer.textComposer, 'text', 'get').mockReturnValue( '@user1 @user2', ); - vi.spyOn(messageComposer.textComposer, 'mentionedUsers', 'get').mockReturnValue([ - { id: 'user1', name: 'User 1' }, - { id: 'user2', name: 'User 2' }, + vi.spyOn(messageComposer.textComposer, 'mentions', 'get').mockReturnValue([ + { id: 'user1', mentionType: 'user', name: 'User 1' }, + { id: 'user2', mentionType: 'user', name: 'User 2' }, ]); const result = await draftTextComposerMiddleware.handlers.compose( @@ -504,9 +607,9 @@ describe('stream-io/message-composer-middleware/draft-text-composition', () => { it('should remove stale mentions', async () => { vi.spyOn(messageComposer.textComposer, 'text', 'get').mockReturnValue('@user1'); - vi.spyOn(messageComposer.textComposer, 'mentionedUsers', 'get').mockReturnValue([ - { id: 'user1', name: 'User 1' }, - { id: 'user2', name: 'User 2' }, + vi.spyOn(messageComposer.textComposer, 'mentions', 'get').mockReturnValue([ + { id: 'user1', mentionType: 'user', name: 'User 1' }, + { id: 'user2', mentionType: 'user', name: 'User 2' }, ]); const result = await draftTextComposerMiddleware.handlers.compose( @@ -524,6 +627,64 @@ describe('stream-io/message-composer-middleware/draft-text-composition', () => { expect(result.state.draft.mentioned_users).toEqual(['user1']); }); + it('should map mixed enhanced mentions into draft payloads', async () => { + vi.spyOn(messageComposer.textComposer, 'text', 'get').mockReturnValue( + '@user1 @channel @here @admin @Backend Team', + ); + vi.spyOn(messageComposer.textComposer, 'mentions', 'get').mockReturnValue([ + { id: 'user1', mentionType: 'user', name: 'User 1' }, + { id: 'channel', mentionType: 'channel', name: 'channel' }, + { id: 'here', mentionType: 'here', name: 'here' }, + { id: 'admin', mentionType: 'role', name: 'admin' }, + { id: 'backend-team', mentionType: 'user_group', name: 'Backend Team' }, + ]); + + const result = await draftTextComposerMiddleware.handlers.compose( + setupDraft({ + draft: { + id: 'test-id', + parent_id: undefined, + text: '', + }, + }), + ); + + expect(result.state.draft.mentioned_users).toEqual(['user1']); + expect(result.state.draft.mentioned_channel).toBe(true); + expect(result.state.draft.mentioned_here).toBe(true); + expect(result.state.draft.mentioned_roles).toEqual(['admin']); + expect(result.state.draft.mentioned_group_ids).toEqual(['backend-team']); + }); + + it('should remove stale enhanced mentions from drafts', async () => { + vi.spyOn(messageComposer.textComposer, 'text', 'get').mockReturnValue( + '@user1 @channel', + ); + vi.spyOn(messageComposer.textComposer, 'mentions', 'get').mockReturnValue([ + { id: 'user1', mentionType: 'user', name: 'User 1' }, + { id: 'channel', mentionType: 'channel', name: 'channel' }, + { id: 'here', mentionType: 'here', name: 'here' }, + { id: 'admin', mentionType: 'role', name: 'admin' }, + { id: 'backend-team', mentionType: 'user_group', name: 'Backend Team' }, + ]); + + const result = await draftTextComposerMiddleware.handlers.compose( + setupDraft({ + draft: { + id: 'test-id', + parent_id: undefined, + text: '', + }, + }), + ); + + expect(result.state.draft.mentioned_users).toEqual(['user1']); + expect(result.state.draft.mentioned_channel).toBe(true); + expect(result.state.draft.mentioned_here).toBeUndefined(); + expect(result.state.draft.mentioned_roles).toBeUndefined(); + expect(result.state.draft.mentioned_group_ids).toBeUndefined(); + }); + it('should handle empty mentionedUsers array', async () => { vi.spyOn(messageComposer.textComposer, 'text', 'get').mockReturnValue('Hello world'); vi.spyOn(messageComposer.textComposer, 'mentionedUsers', 'get').mockReturnValue([]); diff --git a/test/unit/MessageComposer/middleware/textComposer/MentionsSearchSource.test.ts b/test/unit/MessageComposer/middleware/textComposer/MentionsSearchSource.test.ts index 42cb04d04f..2f0007520a 100644 --- a/test/unit/MessageComposer/middleware/textComposer/MentionsSearchSource.test.ts +++ b/test/unit/MessageComposer/middleware/textComposer/MentionsSearchSource.test.ts @@ -8,11 +8,15 @@ import { StreamChat } from '../../../../../src/client'; import { MAX_CHANNEL_MEMBER_COUNT_IN_CHANNEL_QUERY } from '../../../../../src/constants'; import type { ChannelMemberResponse, + SearchUserGroupsOptions, + SearchUserGroupsResponse, Mute, + UserGroupResponse, UserResponse, UserFilters, MemberFilters, } from '../../../../../src/types'; +import type { MentionSuggestion } from '../../../../../src/messageComposer/middleware/textComposer/types'; describe('calculateLevenshtein', () => { it('should return length of first string if second is empty', () => { @@ -61,69 +65,248 @@ describe('MentionsSearchSource', () => { let client: StreamChat; let mockUsers: UserResponse[]; let mockMembers: Record; + let mockUserGroups: UserGroupResponse[]; beforeEach(() => { mockUsers = [ - { id: 'user1', name: 'John Doe' }, - { id: 'user2', name: 'Jane Smith' }, - { id: 'user3', name: 'Bob Wilson' }, - { id: 'currentUser', name: 'Alice Johnson' }, + { + id: 'user1', + name: 'John Doe', + role: 'user', + teams_role: { engineering: 'admin' }, + }, + { id: 'user2', name: 'Jane Smith', role: 'moderator' }, + { id: 'user3', name: 'Bob Wilson', role: 'user' }, + { id: 'currentUser', name: 'Alice Johnson', role: 'user' }, ]; mockMembers = { - user1: { user: { id: 'user1', name: 'John Doe' } }, - user2: { user: { id: 'user2', name: 'Jane Smith' } }, - currentUser: { user: { id: 'currentUser', name: 'Alice Johnson' } }, + user1: { user: mockUsers[0] }, + user2: { channel_role: 'channel_moderator', user: mockUsers[1] }, + currentUser: { user: mockUsers[3] }, }; + mockUserGroups = [ + { + created_at: '2026-05-08T12:00:00.000Z', + id: 'backend-team', + members: [ + { created_at: '2026-05-08T12:00:00.000Z', id: 'member-1', is_admin: false }, + ], + name: 'Backend Team', + }, + { + created_at: '2026-05-08T12:01:00.000Z', + id: 'admins-group', + members: [ + { created_at: '2026-05-08T12:00:00.000Z', id: 'member-1', is_admin: false }, + { created_at: '2026-05-08T12:01:00.000Z', id: 'member-2', is_admin: false }, + ], + name: 'Admins', + }, + ]; + client = { userID: 'currentUser', + listRoles: vi.fn().mockResolvedValue({ + roles: ['admin', 'channel_moderator', 'moderator'], + }), queryUsers: vi.fn().mockResolvedValue({ users: mockUsers }), + searchUserGroups: vi.fn().mockImplementation( + async (options: SearchUserGroupsOptions): Promise => ({ + user_groups: mockUserGroups.filter((group) => + group.name.toLowerCase().includes(options.query.toLowerCase()), + ), + }), + ), mutedUsers: [], - } as any; + } as unknown as StreamChat; channel = { + data: { team: 'engineering' }, getClient: vi.fn().mockReturnValue(client), state: { members: mockMembers, watchers: {}, }, queryMembers: vi.fn().mockResolvedValue({ members: Object.values(mockMembers) }), - } as any; + } as unknown as Channel; }); + const getSuggestion = ( + suggestions: MentionSuggestion[], + mentionType: MentionSuggestion['mentionType'], + id: string, + ) => suggestions.find((item) => item.mentionType === mentionType && item.id === id); + it('should initialize with correct type', () => { const source = new MentionsSearchSource(channel); expect(source.type).toBe('mentions'); + expect(source.config.allowedMentionTypes).toEqual({ + channel: true, + here: true, + role: true, + user: true, + user_group: true, + }); expect(source.config.mentionAllAppUsers).toBeUndefined; expect(source.config.textComposerText).toBeUndefined; expect(source.config.transliterate).toBeUndefined; const customizedSource = new MentionsSearchSource(channel, { + allowedMentionTypes: { role: false, user_group: false }, mentionAllAppUsers: true, + suggestionFactoryMappers: { + role: (value, { searchToken }) => ({ + id: value, + mentionType: 'role', + name: `custom-${value}`, + tokenizedDisplayName: { + parts: [`custom-${value}`], + token: searchToken, + }, + }), + }, textComposerText: '@', transliterate: (text: string) => text.toLowerCase(), }); + expect(customizedSource.config.allowedMentionTypes).toEqual({ + channel: true, + here: true, + role: false, + user: true, + user_group: false, + }); expect(customizedSource.config.mentionAllAppUsers).toBe(true); + expect(customizedSource.config.suggestionFactoryMappers?.role).toBeInstanceOf( + Function, + ); expect(customizedSource.config.textComposerText).toBe('@'); expect(customizedSource.transliterate).toBeInstanceOf(Function); }); + it('should return built-ins, roles, and users on empty query without user group search', async () => { + const source = new MentionsSearchSource(channel); + source.activate(); + source.config.textComposerText = '@'; + + const result = await source.query(''); + + expect(client.searchUserGroups).not.toHaveBeenCalled(); + expect(client.listRoles).toHaveBeenCalledTimes(1); + expect(getSuggestion(result.items, 'channel', 'channel')).toBeDefined(); + expect(getSuggestion(result.items, 'here', 'here')).toBeDefined(); + expect(getSuggestion(result.items, 'role', 'admin')).toBeDefined(); + expect(getSuggestion(result.items, 'role', 'channel_moderator')).toBeDefined(); + expect(getSuggestion(result.items, 'user_group', 'backend-team')).toBeUndefined(); + expect(getSuggestion(result.items, 'user', 'user1')).toBeDefined(); + }); + it('should search members locally when all members are loaded', async () => { const source = new MentionsSearchSource(channel); source.activate(); source.config.textComposerText = '@jo'; const result = await source.query('jo'); - expect(result.items).toHaveLength(1); - expect(result.items[0].name).toBe('John Doe'); + expect(getSuggestion(result.items, 'user', 'user1')?.name).toBe('John Doe'); + }); + + it('should search user groups by query and keep mixed result shape', async () => { + const source = new MentionsSearchSource(channel); + source.activate(); + source.config.textComposerText = '@adm'; + + const result = await source.query('adm'); + + expect(client.searchUserGroups).toHaveBeenCalledWith({ + limit: 10, + query: 'adm', + team_id: 'engineering', + } satisfies SearchUserGroupsOptions); + expect(client.listRoles).toHaveBeenCalledTimes(1); + expect(getSuggestion(result.items, 'role', 'admin')).toBeDefined(); + expect(getSuggestion(result.items, 'user_group', 'admins-group')).toBeDefined(); + expect(getSuggestion(result.items, 'channel', 'channel')).toBeUndefined(); + expect(getSuggestion(result.items, 'here', 'here')).toBeUndefined(); + }); + + it('should source role suggestions from client.listRoles', async () => { + const source = new MentionsSearchSource(channel); + source.activate(); + source.config.textComposerText = '@mod'; + + const result = await source.query('mod'); + + expect(client.listRoles).toHaveBeenCalledTimes(1); + expect(getSuggestion(result.items, 'role', 'moderator')).toBeDefined(); + expect(getSuggestion(result.items, 'role', 'channel_moderator')).toBeDefined(); + }); + + it('should allow overriding built-in suggestion mappers from options', async () => { + const source = new MentionsSearchSource(channel, { + suggestionFactoryMappers: { + role: (value, { searchToken }) => ({ + id: value, + mentionType: 'role', + name: `role:${value}`, + tokenizedDisplayName: { + parts: [`role:${value}`], + token: searchToken, + }, + }), + }, + }); + source.activate(); + source.config.textComposerText = '@mod'; + + const result = await source.query('mod'); + + expect(getSuggestion(result.items, 'role', 'moderator')?.name).toBe('role:moderator'); + expect(getSuggestion(result.items, 'role', 'channel_moderator')?.name).toBe( + 'role:channel_moderator', + ); + }); + + it('should respect allowedMentionTypes and skip disabled source queries', async () => { + const source = new MentionsSearchSource(channel, { + allowedMentionTypes: { + channel: false, + here: false, + role: false, + user_group: false, + }, + }); + source.activate(); + source.config.textComposerText = '@adm'; + + const result = await source.query('adm'); + + expect(client.listRoles).not.toHaveBeenCalled(); + expect(client.searchUserGroups).not.toHaveBeenCalled(); + expect(getSuggestion(result.items, 'channel', 'channel')).toBeUndefined(); + expect(getSuggestion(result.items, 'here', 'here')).toBeUndefined(); + expect(result.items.every((item) => item.mentionType === 'user')).toBe(true); + }); + + it('should skip user queries when user mentions are disabled', async () => { + const source = new MentionsSearchSource(channel, { + allowedMentionTypes: { user: false }, + mentionAllAppUsers: true, + }); + source.activate(); + source.config.textComposerText = '@john'; + + const result = await source.query('john'); + + expect(client.queryUsers).not.toHaveBeenCalled(); + expect(result.items.some((item) => item.mentionType === 'user')).toBe(false); }); - it('should query members from API when not all loaded', async () => { + it('should query members from API when not all members are loaded', async () => { const source = new MentionsSearchSource(channel); source.activate(); + source.config.textComposerText = '@john'; - // Simulate more members than MAX_CHANNEL_MEMBER_COUNT_IN_CHANNEL_QUERY const manyMembers: Record = {}; for (let i = 0; i < MAX_CHANNEL_MEMBER_COUNT_IN_CHANNEL_QUERY + 1; i++) { manyMembers[`user${i}`] = { user: { id: `user${i}`, name: `User ${i}` } }; @@ -131,26 +314,20 @@ describe('MentionsSearchSource', () => { channel.state.members = manyMembers; const result = await source.query('john'); + expect(channel.queryMembers).toHaveBeenCalled(); - expect(result.items).toHaveLength(Object.keys(mockMembers).length); + expect(result.items.some((item) => item.mentionType === 'user')).toBe(true); }); it('should query all app users when mentionAllAppUsers is true', async () => { const source = new MentionsSearchSource(channel, { mentionAllAppUsers: true }); source.activate(); + source.config.textComposerText = '@john'; const result = await source.query('john'); - expect(client.queryUsers).toHaveBeenCalled(); - expect(result.items).toHaveLength(mockUsers.length); - }); - it('should filter out current user from results', async () => { - const source = new MentionsSearchSource(channel); - source.activate(); - source.config.textComposerText = '@'; - - const result = await source.query(''); - expect(result.items.every((item) => item.id !== client.userID)).toBe(true); + expect(client.queryUsers).toHaveBeenCalled(); + expect(getSuggestion(result.items, 'user', 'user1')?.name).toBe('John Doe'); }); it('should handle transliteration when provided', async () => { @@ -160,14 +337,11 @@ describe('MentionsSearchSource', () => { source.config.textComposerText = '@john'; const result = await source.query('john'); - expect(result.items).toHaveLength(1); - expect(result.items[0].name).toBe('John Doe'); + expect(getSuggestion(result.items, 'user', 'user1')?.name).toBe('John Doe'); }); - it('should filter muted users correctly', async () => { + it('should preserve special mentions while filtering muted users', () => { const source = new MentionsSearchSource(channel); - source.activate(); - source.config.textComposerText = '@unmute'; const mute: Mute = { target: { id: 'user1' }, user: { id: 'currentUser' }, @@ -176,9 +350,37 @@ describe('MentionsSearchSource', () => { }; client.mutedUsers = [mute]; - const result = await source.query(''); - expect(result.items).toHaveLength(Object.keys(mockMembers).length - 1); - expect(result.items[0].id).toBe('user2'); + source.config.textComposerText = '@john'; + const result = source.filterMutes([ + source.toUserSuggestion(mockUsers[0], 'john'), + source.toUserSuggestion(mockUsers[1], 'john'), + source.toChannelMentionSuggestion('john'), + ]); + + expect(result).toEqual([ + source.toUserSuggestion(mockUsers[1], 'john'), + source.toChannelMentionSuggestion('john'), + ]); + }); + + it('should return only muted users for /unmute and hide special mentions', () => { + const source = new MentionsSearchSource(channel); + const mute: Mute = { + target: { id: 'user1' }, + user: { id: 'currentUser' }, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }; + client.mutedUsers = [mute]; + + source.config.textComposerText = '/unmute @'; + const result = source.filterMutes([ + source.toUserSuggestion(mockUsers[0]), + source.toUserSuggestion(mockUsers[1]), + source.toHereMentionSuggestion(), + ]); + + expect(result).toEqual([source.toUserSuggestion(mockUsers[0])]); }); it('should preserve items in state before first query', () => { @@ -203,6 +405,38 @@ describe('MentionsSearchSource', () => { expect(source.canExecuteQuery('test')).toBe(false); }); + it('should keep public pagination state offset-based while paginating user groups privately', async () => { + const source = new MentionsSearchSource(channel, { mentionAllAppUsers: true }); + source.activate(); + source.config.textComposerText = '@adm'; + source.state.partialNext({ + hasNext: true, + offset: 3, + searchQuery: 'adm', + }); + (source as unknown as { userGroupCursor?: string }).userGroupCursor = JSON.stringify({ + id_gt: 'group-0', + name_gt: 'Admins', + }); + + await source.executeQuery(); + + expect(client.queryUsers).toHaveBeenCalledWith( + expect.any(Object), + expect.any(Object), + expect.objectContaining({ limit: 10, offset: 3 }), + ); + expect(client.searchUserGroups).toHaveBeenCalledWith({ + id_gt: 'group-0', + limit: 10, + name_gt: 'Admins', + query: 'adm', + team_id: 'engineering', + } satisfies SearchUserGroupsOptions); + expect(source.state.getLatestValue().next).toBeUndefined(); + expect(source.state.getLatestValue().offset).toBe(7); + }); + it('should correctly get members and watchers without duplicates', () => { const source = new MentionsSearchSource(channel); channel.state.watchers = { @@ -220,12 +454,13 @@ describe('MentionsSearchSource', () => { source.userFilters = { id: { $in: ['admin1', 'admin2'] } } as UserFilters; source.userSort = [{ created_at: -1 }]; - const params = source.prepareQueryUsersParams('john'); + const params = source.prepareQueryUsersParams('john', 7); expect(params.filters).toEqual({ $or: [{ id: { $autocomplete: 'john' } }, { name: { $autocomplete: 'john' } }], id: { $in: ['admin1', 'admin2'] }, }); expect(params.sort).toEqual([{ created_at: -1 }]); + expect(params.options).toEqual(expect.objectContaining({ limit: 10, offset: 7 })); }); it('should prepare correct query parameters for members search', () => { @@ -233,9 +468,10 @@ describe('MentionsSearchSource', () => { source.memberFilters = { name: { $autocomplete: 'john' } } as MemberFilters; source.memberSort = { created_at: -1 }; - const params = source.prepareQueryMembersParams('john'); + const params = source.prepareQueryMembersParams('john', 5); expect(params.filters).toEqual({ name: { $autocomplete: 'john' } }); expect(params.sort).toEqual({ created_at: -1 }); + expect(params.options).toEqual(expect.objectContaining({ limit: 10, offset: 5 })); }); it('should handle empty or invalid user names in local search', () => { @@ -258,12 +494,16 @@ describe('MentionsSearchSource', () => { expect(result[0].name).toBe('Valid Name'); }); - it('should handle errors in API queries', async () => { + it('should keep partial mention results when one source query fails', async () => { const source = new MentionsSearchSource(channel); client.queryUsers = vi.fn().mockRejectedValue(new Error('API Error')); source.config.mentionAllAppUsers = true; - await expect(source.query('test')).rejects.toThrow('API Error'); + const result = await source.query('adm'); + + expect(getSuggestion(result.items, 'role', 'admin')).toBeDefined(); + expect(getSuggestion(result.items, 'user_group', 'admins-group')).toBeDefined(); + expect(getSuggestion(result.items, 'user', 'user1')).toBeUndefined(); }); it('should apply custom search options', async () => { diff --git a/test/unit/MessageComposer/middleware/textComposer/TextComposerMiddlewareExecutor.test.ts b/test/unit/MessageComposer/middleware/textComposer/TextComposerMiddlewareExecutor.test.ts index 4737910076..6daf185c95 100644 --- a/test/unit/MessageComposer/middleware/textComposer/TextComposerMiddlewareExecutor.test.ts +++ b/test/unit/MessageComposer/middleware/textComposer/TextComposerMiddlewareExecutor.test.ts @@ -11,9 +11,9 @@ import type { CommandResponse, DraftResponse, LocalMessage, - UserResponse, } from '../../../../../src/types'; import { TextComposerMiddleware } from '../../../../../src'; +import type { UserSuggestion } from '../../../../../src/messageComposer/middleware/textComposer/types'; // Mock dependencies vi.mock('../../../src/utils', () => ({ @@ -69,9 +69,10 @@ const setup = ({ }; const initialValue = { + mentionedUsers: [], + mentions: [], text: '', selection: { start: 0, end: 0 }, - mentionedUsers: [], }; describe('TextComposerMiddlewareExecutor', () => { @@ -183,14 +184,19 @@ describe('TextComposerMiddlewareExecutor', () => { const selectedSuggestion = { id: 'user1', + mentionType: 'user', name: 'John Doe', - } as TextComposerSuggestion; + tokenizedDisplayName: { token: 'jo', parts: ['John Doe'] }, + } as TextComposerSuggestion; await textComposer.handleSelect(selectedSuggestion); expect(textComposer.text).toBe('@John Doe '); expect(textComposer.suggestions).toBeUndefined(); - expect(textComposer.mentionedUsers).toContainEqual(selectedSuggestion); + expect(textComposer.mentionedUsers).toContainEqual({ + id: 'user1', + name: 'John Doe', + }); }); it('should handle suggestion selection with commands', async () => { @@ -369,6 +375,7 @@ describe('TextComposerMiddlewareExecutor', () => { text: '/test', selection: { start: 0, end: 0 }, mentionedUsers: [], + mentions: [], }); }); @@ -389,6 +396,7 @@ describe('TextComposerMiddlewareExecutor', () => { text: 'test', selection: { start: 0, end: 4 }, mentionedUsers: [], + mentions: [], }); }); @@ -468,7 +476,29 @@ describe('TextComposerMiddlewareExecutor', () => { text: '@test', selection: { start: 0, end: 0 }, mentionedUsers: [], + mentions: [], + }); + }); + + it('should preserve tracked enhanced mention entities during middleware passes', async () => { + const { + messageComposer: { textComposer }, + } = setup(); + const result = await textComposer.middlewareExecutor.execute({ + eventName: 'onChange', + initialValue: { + ...initialValue, + mentionedUsers: [], + mentions: [{ id: 'channel', mentionType: 'channel', name: 'channel' }], + text: 'hello world', + selection: { start: 11, end: 11 }, + }, }); + + expect(result.state.mentionedUsers).toEqual([]); + expect(result.state.mentions).toEqual([ + { id: 'channel', mentionType: 'channel', name: 'channel' }, + ]); }); it('should handle trigger with token', async () => { diff --git a/test/unit/MessageComposer/middleware/textComposer/command.test.ts b/test/unit/MessageComposer/middleware/textComposer/command.test.ts index 89febe0b85..aeb4d9bc6d 100644 --- a/test/unit/MessageComposer/middleware/textComposer/command.test.ts +++ b/test/unit/MessageComposer/middleware/textComposer/command.test.ts @@ -59,6 +59,7 @@ const initialValue = { text: '', selection: { start: 0, end: 0 }, mentionedUsers: [], + mentions: [], }; describe('Apply Command Settings Middleware', () => { diff --git a/test/unit/MessageComposer/textComposer.test.ts b/test/unit/MessageComposer/textComposer.test.ts index 3154598584..655cb8e7e0 100644 --- a/test/unit/MessageComposer/textComposer.test.ts +++ b/test/unit/MessageComposer/textComposer.test.ts @@ -16,6 +16,7 @@ import type { LocalAttachment } from '../../../src/messageComposer/types'; const textComposerMiddlewareExecuteOutput = { state: { mentionedUsers: [], + mentions: [], text: 'Test message', selection: { start: 12, end: 12 }, }, @@ -109,6 +110,7 @@ describe('TextComposer', () => { expect(messageComposer.textComposer.state.getLatestValue()).toEqual({ command: null, mentionedUsers: [], + mentions: [], text: '', selection: { start: 0, end: 0 }, }); @@ -120,6 +122,7 @@ describe('TextComposer', () => { expect(messageComposer.textComposer.state.getLatestValue()).toEqual({ command: null, mentionedUsers: [], + mentions: [], text: defaultValue, selection: { start: defaultValue.length, end: defaultValue.length }, }); @@ -153,6 +156,29 @@ describe('TextComposer', () => { ]); }); + it('should initialize special mention entities from message payload fields', () => { + const message: LocalMessage = { + id: 'test-message', + type: 'regular', + text: 'Hello @channel @here @backend', + mentioned_channel: true, + mentioned_group_ids: ['backend-team'], + mentioned_here: true, + mentioned_roles: ['admin'], + }; + + const { + messageComposer: { textComposer }, + } = setup({ composition: message }); + + expect(textComposer.mentions).toEqual([ + { id: 'channel', mentionType: 'channel', name: 'channel' }, + { id: 'here', mentionType: 'here', name: 'here' }, + { id: 'admin', mentionType: 'role', name: 'admin' }, + { id: 'backend-team', mentionType: 'user_group' }, + ]); + }); + it('should ignore default value when initialized with message', () => { const defaultValue = 'XXX'; const message: LocalMessage = { @@ -185,6 +211,7 @@ describe('TextComposer', () => { } = setup(); const state = { mentionedUsers: [{ id: 'user-1' }], + mentions: [{ id: 'user-1', mentionType: 'user' as const }], text: 'Hello world', selection: { start: 5, end: 5 }, suggestions: { query: 'test' }, @@ -192,6 +219,7 @@ describe('TextComposer', () => { textComposer.state.partialNext(state); expect(textComposer.mentionedUsers).toEqual([{ id: 'user-1' }]); + expect(textComposer.mentions).toEqual([{ id: 'user-1', mentionType: 'user' }]); expect(textComposer.text).toBe('Hello world'); expect(textComposer.selection).toEqual({ start: 5, end: 5 }); expect(textComposer.suggestions).toEqual({ query: 'test' }); @@ -232,6 +260,7 @@ describe('TextComposer', () => { const initialState = { command: null, mentionedUsers: [], + mentions: [], text: '', selection: { start: 0, end: 0 }, }; @@ -280,6 +309,23 @@ describe('TextComposer', () => { }); }); + describe('setMentions', () => { + it('should update mentions and deprecated mentionedUsers state', () => { + const { + messageComposer: { textComposer }, + } = setup(); + const entities = [ + { id: 'channel', mentionType: 'channel' as const, name: 'channel' }, + { id: 'admin', mentionType: 'role' as const, name: 'admin' }, + ]; + + textComposer.setMentions(entities); + + expect(textComposer.mentions).toEqual(entities); + expect(textComposer.state.getLatestValue().mentionedUsers).toEqual([]); + }); + }); + describe('commands', () => { const attachment = { image_url: 'https://getstream.io/image.png', @@ -758,6 +804,7 @@ describe('TextComposer', () => { textComposer.state.partialNext({ command: { name: 'ban' }, mentionedUsers: [{ id: 'user-1' }], + mentions: [{ id: 'user-1', mentionType: 'user' }], selection: { start: 12, end: 12 }, text: 'command args', }); @@ -800,6 +847,7 @@ describe('TextComposer', () => { textComposer.state.partialNext({ command: { name: 'ban' }, mentionedUsers: [], + mentions: [], selection: { start: 12, end: 12 }, text: 'command args', }); @@ -977,6 +1025,102 @@ describe('TextComposer', () => { }); }); + describe('upsertMentionEntity', () => { + it('should add a new mentioned entity', () => { + const { + messageComposer: { textComposer }, + } = setup(); + + textComposer.upsertMentionEntity({ + id: 'backend-team', + mentionType: 'user_group', + name: 'Backend Team', + }); + + expect(textComposer.mentions).toEqual([ + { id: 'backend-team', mentionType: 'user_group', name: 'Backend Team' }, + ]); + }); + + it('should update an existing mentioned entity', () => { + const { + messageComposer: { textComposer }, + } = setup(); + + textComposer.setMentions([{ id: 'admin', mentionType: 'role', name: 'admin' }]); + + textComposer.upsertMentionEntity({ + id: 'admin', + mentionType: 'role', + name: 'Administrators', + }); + + expect(textComposer.mentions).toEqual([ + { id: 'admin', mentionType: 'role', name: 'Administrators' }, + ]); + }); + }); + + describe('getMentionEntity', () => { + it('should return the mentioned entity if found', () => { + const { + messageComposer: { textComposer }, + } = setup(); + + textComposer.setMentions([ + { id: 'channel', mentionType: 'channel', name: 'channel' }, + { id: 'admin', mentionType: 'role', name: 'admin' }, + ]); + + expect(textComposer.getMentionEntity('role', 'admin')).toEqual({ + id: 'admin', + mentionType: 'role', + name: 'admin', + }); + }); + + it('should return undefined if mentioned entity is not found', () => { + const { + messageComposer: { textComposer }, + } = setup(); + + expect(textComposer.getMentionEntity('role', 'admin')).toBeUndefined(); + }); + }); + + describe('removeMentionEntity', () => { + it('should remove the mentioned entity if found', () => { + const { + messageComposer: { textComposer }, + } = setup(); + + textComposer.setMentions([ + { id: 'channel', mentionType: 'channel', name: 'channel' }, + { id: 'admin', mentionType: 'role', name: 'admin' }, + ]); + + textComposer.removeMentionEntity('channel', 'channel'); + + expect(textComposer.mentions).toEqual([ + { id: 'admin', mentionType: 'role', name: 'admin' }, + ]); + }); + + it('should not update state if mentioned entity is not found', () => { + const { + messageComposer: { textComposer }, + } = setup(); + + textComposer.setMentions([{ id: 'admin', mentionType: 'role', name: 'admin' }]); + + textComposer.removeMentionEntity('here', 'here'); + + expect(textComposer.mentions).toEqual([ + { id: 'admin', mentionType: 'role', name: 'admin' }, + ]); + }); + }); + describe('setText', () => { it('should update the text', () => { const message: LocalMessage = { @@ -1329,6 +1473,7 @@ describe('TextComposer', () => { describe('handleChange', () => { const initialState = { mentionedUsers: [], + mentions: [], text: '', selection: { start: 0, end: 0 }, }; @@ -1465,6 +1610,7 @@ describe('TextComposer', () => { describe('handleSelect', () => { const initialState = { mentionedUsers: [], + mentions: [], text: '', selection: { start: 0, end: 0 }, }; diff --git a/test/unit/pagination/UserGroupPaginator.test.ts b/test/unit/pagination/UserGroupPaginator.test.ts new file mode 100644 index 0000000000..f2e5556964 --- /dev/null +++ b/test/unit/pagination/UserGroupPaginator.test.ts @@ -0,0 +1,126 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { StreamChat } from '../../../src/client'; +import { UserGroupPaginator } from '../../../src/pagination'; +import type { UserGroupResponse } from '../../../src/types'; + +const createUserGroup = ( + overrides: Partial = {}, +): UserGroupResponse => ({ + id: 'group-1', + name: 'Backend Support', + created_at: '2026-01-01T00:00:00.000000000Z', + updated_at: '2026-01-01T00:00:00.000000000Z', + ...overrides, +}); + +describe('UserGroupPaginator', () => { + let client: StreamChat; + + beforeEach(() => { + client = new StreamChat('api_key'); + }); + + it('starts as a forward-only paginator', () => { + const paginator = new UserGroupPaginator(client); + + expect(paginator.hasNext).toBe(true); + expect(paginator.hasPrev).toBe(false); + expect(paginator.items).toBeUndefined(); + expect(paginator.cursor).toBeUndefined(); + }); + + it('paginates listed user groups using synthesized cursors', async () => { + const firstPage = [ + createUserGroup({ id: 'group-1', created_at: '2026-01-01T00:00:00.000000000Z' }), + createUserGroup({ + id: 'group-2', + name: 'Frontend Support', + created_at: '2026-01-02T00:00:00.000000000Z', + updated_at: '2026-01-02T00:00:00.000000000Z', + }), + ]; + const secondPage = [ + createUserGroup({ + id: 'group-3', + name: 'QA Support', + created_at: '2026-01-03T00:00:00.000000000Z', + updated_at: '2026-01-03T00:00:00.000000000Z', + }), + ]; + + const querySpy = vi + .spyOn(client, 'queryUserGroups') + .mockResolvedValueOnce({ duration: '0.01s', user_groups: firstPage }) + .mockResolvedValueOnce({ duration: '0.01s', user_groups: secondPage }); + + const paginator = new UserGroupPaginator(client, { pageSize: 2 }); + + await paginator.next(); + + expect(querySpy).toHaveBeenNthCalledWith(1, { limit: 2 }); + expect(paginator.items).toEqual(firstPage); + expect(paginator.hasNext).toBe(true); + expect(paginator.hasPrev).toBe(false); + expect(JSON.parse(paginator.cursor?.next ?? '{}')).toEqual({ + created_at_gt: firstPage[1].created_at, + id_gt: firstPage[1].id, + }); + + await paginator.next(); + + expect(querySpy).toHaveBeenNthCalledWith(2, { + limit: 2, + created_at_gt: firstPage[1].created_at, + id_gt: firstPage[1].id, + }); + expect(paginator.items).toEqual([...firstPage, ...secondPage]); + expect(paginator.hasNext).toBe(false); + expect(paginator.hasPrev).toBe(false); + expect(paginator.cursor).toEqual({ next: null, prev: null }); + }); + + it('resets paginator state when team id changes', async () => { + vi.spyOn(client, 'queryUserGroups').mockResolvedValue({ + duration: '0.01s', + user_groups: [createUserGroup()], + }); + + const paginator = new UserGroupPaginator(client, { pageSize: 1 }); + + await paginator.next(); + + paginator.teamId = 'engineering'; + + expect(paginator.items).toBeUndefined(); + expect(paginator.cursor).toBeUndefined(); + expect(paginator.hasNext).toBe(true); + expect(paginator.hasPrev).toBe(false); + }); + + it('ignores malformed stored cursors and retries from the first page options', async () => { + const querySpy = vi.spyOn(client, 'queryUserGroups').mockResolvedValue({ + duration: '0.01s', + user_groups: [createUserGroup()], + }); + + const paginator = new UserGroupPaginator(client, { pageSize: 1 }); + paginator.state.partialNext({ + cursor: { next: '{not-json', prev: null }, + }); + + await paginator.next(); + + expect(querySpy).toHaveBeenCalledWith({ limit: 1 }); + }); + + it('does not execute prev pagination requests', async () => { + const querySpy = vi.spyOn(client, 'queryUserGroups'); + const paginator = new UserGroupPaginator(client); + + await paginator.prev(); + + expect(querySpy).not.toHaveBeenCalled(); + expect(paginator.hasPrev).toBe(false); + }); +}); diff --git a/test/unit/user_groups.test.ts b/test/unit/user_groups.test.ts new file mode 100644 index 0000000000..ccba4a00e7 --- /dev/null +++ b/test/unit/user_groups.test.ts @@ -0,0 +1,216 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { StreamChat } from '../../src/client'; +import type { + AddUserGroupMembersOptions, + AddUserGroupMembersResponse, + APIResponse, + CreateUserGroupOptions, + CreateUserGroupResponse, + DeleteUserGroupOptions, + GetUserGroupOptions, + GetUserGroupResponse, + QueryUserGroupsOptions, + QueryUserGroupsResponse, + RemoveUserGroupMembersOptions, + RemoveUserGroupMembersResponse, + SearchUserGroupsOptions, + SearchUserGroupsResponse, + UpdateUserGroupOptions, + UpdateUserGroupResponse, + UserGroupResponse, +} from '../../src/types'; + +const createUserGroup = ( + overrides: Partial = {}, +): UserGroupResponse => ({ + id: 'group-1', + name: 'Backend Support', + created_at: '2026-01-01T00:00:00.000000000Z', + updated_at: '2026-01-01T00:00:00.000000000Z', + ...overrides, +}); + +describe('User Groups', () => { + let client: StreamChat; + + beforeEach(() => { + client = new StreamChat('api_key'); + }); + + describe('queryUserGroups', () => { + it('should query user groups with cursor options', async () => { + const mockResponse: QueryUserGroupsResponse = { + duration: '0.01s', + user_groups: [createUserGroup()], + }; + const getSpy = vi.spyOn(client, 'get').mockResolvedValue(mockResponse); + const options: QueryUserGroupsOptions = { + limit: 10, + id_gt: 'group-0', + created_at_gt: '2025-12-31T23:59:59.000000000Z', + team_id: 'engineering', + }; + + const result = await client.queryUserGroups(options); + + expect(getSpy).toHaveBeenCalledWith(`${client.baseURL}/usergroups`, options); + expect(result.user_groups).toHaveLength(1); + expect(result.user_groups[0].id).toBe('group-1'); + }); + }); + + describe('createUserGroup', () => { + it('should create a user group', async () => { + const mockResponse: CreateUserGroupResponse = { + duration: '0.01s', + user_group: createUserGroup(), + }; + const postSpy = vi.spyOn(client, 'post').mockResolvedValue(mockResponse); + const options: CreateUserGroupOptions = { + id: 'backend-support', + name: 'Backend Support', + description: 'On-call backend engineers', + team_id: 'engineering', + member_ids: ['tom', 'sara'], + }; + + const result = await client.createUserGroup(options); + + expect(postSpy).toHaveBeenCalledWith(`${client.baseURL}/usergroups`, options); + expect(result.user_group.id).toBe('group-1'); + }); + }); + + describe('getUserGroup', () => { + it('should get a user group by id', async () => { + const mockResponse: GetUserGroupResponse = { + duration: '0.01s', + user_group: createUserGroup(), + }; + const getSpy = vi.spyOn(client, 'get').mockResolvedValue(mockResponse); + const options: GetUserGroupOptions = { + team_id: 'engineering', + }; + + const result = await client.getUserGroup('backend-support', options); + + expect(getSpy).toHaveBeenCalledWith( + `${client.baseURL}/usergroups/backend-support`, + options, + ); + expect(result.user_group.name).toBe('Backend Support'); + }); + }); + + describe('searchUserGroups', () => { + it('should search user groups with prefix cursor options', async () => { + const mockResponse: SearchUserGroupsResponse = { + duration: '0.01s', + user_groups: [createUserGroup()], + }; + const getSpy = vi.spyOn(client, 'get').mockResolvedValue(mockResponse); + const options: SearchUserGroupsOptions = { + query: 'backend', + limit: 5, + name_gt: 'Backend Ops', + id_gt: 'group-0', + team_id: 'engineering', + }; + + const result = await client.searchUserGroups(options); + + expect(getSpy).toHaveBeenCalledWith(`${client.baseURL}/usergroups/search`, options); + expect(result.user_groups).toHaveLength(1); + expect(result.user_groups[0].name).toBe('Backend Support'); + }); + }); + + describe('updateUserGroup', () => { + it('should update a user group', async () => { + const mockResponse: UpdateUserGroupResponse = { + duration: '0.01s', + user_group: createUserGroup({ description: 'Updated description' }), + }; + const putSpy = vi.spyOn(client, 'put').mockResolvedValue(mockResponse); + const options: UpdateUserGroupOptions = { + description: 'Updated description', + name: 'Backend Support', + team_id: 'engineering', + }; + + const result = await client.updateUserGroup('backend-support', options); + + expect(putSpy).toHaveBeenCalledWith( + `${client.baseURL}/usergroups/backend-support`, + options, + ); + expect(result.user_group.description).toBe('Updated description'); + }); + }); + + describe('deleteUserGroup', () => { + it('should delete a user group', async () => { + const mockResponse: APIResponse = { + duration: '0.01s', + }; + const deleteSpy = vi.spyOn(client, 'delete').mockResolvedValue(mockResponse); + const options: DeleteUserGroupOptions = { + team_id: 'engineering', + }; + + const result = await client.deleteUserGroup('backend-support', options); + + expect(deleteSpy).toHaveBeenCalledWith( + `${client.baseURL}/usergroups/backend-support`, + options, + ); + expect(result.duration).toBe('0.01s'); + }); + }); + + describe('addUserGroupMembers', () => { + it('should add members to a user group', async () => { + const mockResponse: AddUserGroupMembersResponse = { + duration: '0.01s', + user_group: createUserGroup(), + }; + const postSpy = vi.spyOn(client, 'post').mockResolvedValue(mockResponse); + const options: AddUserGroupMembersOptions = { + member_ids: ['tom', 'sara'], + as_admin: true, + team_id: 'engineering', + }; + + const result = await client.addUserGroupMembers('backend-support', options); + + expect(postSpy).toHaveBeenCalledWith( + `${client.baseURL}/usergroups/backend-support/members`, + options, + ); + expect(result.user_group.id).toBe('group-1'); + }); + }); + + describe('removeUserGroupMembers', () => { + it('should remove members from a user group', async () => { + const mockResponse: RemoveUserGroupMembersResponse = { + duration: '0.01s', + user_group: createUserGroup(), + }; + const postSpy = vi.spyOn(client, 'post').mockResolvedValue(mockResponse); + const options: RemoveUserGroupMembersOptions = { + member_ids: ['tom', 'sara'], + team_id: 'engineering', + }; + + const result = await client.removeUserGroupMembers('backend-support', options); + + expect(postSpy).toHaveBeenCalledWith( + `${client.baseURL}/usergroups/backend-support/members/delete`, + options, + ); + expect(result.user_group.id).toBe('group-1'); + }); + }); +});