stream-chat 9.44.2 → 9.45.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/dist/cjs/index.browser.js +3460 -2659
  2. package/dist/cjs/index.browser.js.map +4 -4
  3. package/dist/cjs/index.node.js +3469 -2659
  4. package/dist/cjs/index.node.js.map +4 -4
  5. package/dist/esm/index.mjs +3460 -2659
  6. package/dist/esm/index.mjs.map +4 -4
  7. package/dist/types/channel_state.d.ts +1 -1
  8. package/dist/types/client.d.ts +81 -3
  9. package/dist/types/constants.d.ts +1 -0
  10. package/dist/types/messageComposer/LocationComposer.d.ts +1 -1
  11. package/dist/types/messageComposer/configuration/commands.configuration.d.ts +6 -0
  12. package/dist/types/messageComposer/configuration/configuration.d.ts +1 -2
  13. package/dist/types/messageComposer/configuration/index.d.ts +4 -0
  14. package/dist/types/messageComposer/configuration/types.d.ts +21 -0
  15. package/dist/types/messageComposer/fileUtils.d.ts +1 -1
  16. package/dist/types/messageComposer/messageComposer.d.ts +6 -4
  17. package/dist/types/messageComposer/middleware/messageComposer/compositionValidation.d.ts +2 -1
  18. package/dist/types/messageComposer/middleware/messageComposer/textComposer.d.ts +1 -1
  19. package/dist/types/messageComposer/middleware/textComposer/commandUtils.d.ts +10 -1
  20. package/dist/types/messageComposer/middleware/textComposer/mentionUtils.d.ts +8 -0
  21. package/dist/types/messageComposer/middleware/textComposer/mentions.d.ts +77 -15
  22. package/dist/types/messageComposer/middleware/textComposer/types.d.ts +51 -2
  23. package/dist/types/messageComposer/pollComposer.d.ts +2 -2
  24. package/dist/types/messageComposer/textComposer.d.ts +17 -3
  25. package/dist/types/pagination/UserGroupPaginator.d.ts +21 -0
  26. package/dist/types/pagination/index.d.ts +1 -0
  27. package/dist/types/types.d.ts +123 -2
  28. package/dist/types/utils.d.ts +2 -0
  29. package/package.json +38 -31
  30. package/src/client.ts +143 -2
  31. package/src/constants.ts +1 -0
  32. package/src/messageComposer/MessageComposerEffectHandlers.ts +1 -0
  33. package/src/messageComposer/configuration/commands.configuration.ts +55 -0
  34. package/src/messageComposer/configuration/configuration.ts +3 -1
  35. package/src/messageComposer/configuration/index.ts +4 -0
  36. package/src/messageComposer/configuration/types.ts +27 -0
  37. package/src/messageComposer/messageComposer.ts +73 -22
  38. package/src/messageComposer/middleware/messageComposer/compositionValidation.ts +23 -15
  39. package/src/messageComposer/middleware/messageComposer/textComposer.ts +151 -31
  40. package/src/messageComposer/middleware/textComposer/commandUtils.ts +68 -1
  41. package/src/messageComposer/middleware/textComposer/commands.ts +6 -2
  42. package/src/messageComposer/middleware/textComposer/mentionUtils.ts +33 -0
  43. package/src/messageComposer/middleware/textComposer/mentions.ts +596 -66
  44. package/src/messageComposer/middleware/textComposer/types.ts +70 -2
  45. package/src/messageComposer/textComposer.ts +154 -10
  46. package/src/pagination/UserGroupPaginator.ts +93 -0
  47. package/src/pagination/index.ts +1 -0
  48. package/src/permissions.ts +1 -0
  49. package/src/types.ts +152 -2
  50. package/src/utils.ts +1 -0
@@ -3,14 +3,31 @@ import {
3
3
  getTriggerCharWithToken,
4
4
  insertItemWithTrigger,
5
5
  } from './textMiddlewareUtils';
6
+ import { getMentionedUsersInText } from './commandUtils';
7
+ import {
8
+ userResponsesToMentionEntities,
9
+ userSuggestionToMentionEntity,
10
+ userSuggestionToUserResponse,
11
+ } from './mentionUtils';
6
12
  import { BaseSearchSource, type SearchSourceOptions } from '../../../search';
7
13
  import { mergeWith } from '../../../utils/mergeWith';
8
- import type { TextComposerMiddlewareOptions, UserSuggestion } from './types';
14
+ import type {
15
+ ChannelMentionSuggestion,
16
+ HereMentionSuggestion,
17
+ MentionEntity,
18
+ MentionSuggestion,
19
+ RoleMentionSuggestion,
20
+ TextComposerMiddlewareOptions,
21
+ UserGroupMentionSuggestion,
22
+ UserSuggestion,
23
+ } from './types';
9
24
  import type { StreamChat } from '../../../client';
10
25
  import type {
11
26
  MemberFilters,
12
27
  MemberSort,
28
+ SearchUserGroupsOptions,
13
29
  UserFilters,
30
+ UserGroupResponse,
14
31
  UserOptions,
15
32
  UserResponse,
16
33
  UserSort,
@@ -75,17 +92,204 @@ export const calculateLevenshtein = (query: string, name: string) => {
75
92
  };
76
93
 
77
94
  export type MentionsSearchSourceOptions = SearchSourceOptions & {
95
+ allowedMentionTypes?: Partial<Record<MentionType, boolean>>;
78
96
  mentionAllAppUsers?: boolean;
97
+ suggestionFactoryMappers?: MentionSuggestionFactoryMapperOverrides;
79
98
  textComposerText?: string;
99
+ trigger?: string;
80
100
  // todo: document that if you want transliteration, you need to provide the function, e.g. import {default: transliterate} from '@sindresorhus/transliterate';
81
101
  // this is now replacing a parameter useMentionsTransliteration
82
102
  transliterate?: (text: string) => string;
83
103
  };
84
104
 
85
- export class MentionsSearchSource extends BaseSearchSource<UserSuggestion> {
105
+ type MentionType = MentionSuggestion['mentionType'];
106
+ type MentionSuggestionFactoryInputByType = {
107
+ channel: 'channel';
108
+ here: 'here';
109
+ role: string;
110
+ user: UserResponse;
111
+ user_group: UserGroupResponse;
112
+ };
113
+ type MentionSuggestionByType = {
114
+ channel: ChannelMentionSuggestion;
115
+ here: HereMentionSuggestion;
116
+ role: RoleMentionSuggestion;
117
+ user: UserSuggestion;
118
+ user_group: UserGroupMentionSuggestion;
119
+ };
120
+
121
+ export type MentionSuggestionFactoryMapperContext = {
122
+ searchToken: string;
123
+ source: MentionsSearchSource;
124
+ };
125
+
126
+ export type MentionSuggestionFactoryMapper<
127
+ TMentionType extends MentionType = MentionType,
128
+ > = (
129
+ value: MentionSuggestionFactoryInputByType[TMentionType],
130
+ context: MentionSuggestionFactoryMapperContext,
131
+ ) => MentionSuggestionByType[TMentionType];
132
+
133
+ export type MentionSuggestionFactoryMapperOverrides = {
134
+ [TMentionType in MentionType]?: MentionSuggestionFactoryMapper<TMentionType>;
135
+ };
136
+
137
+ const DEFAULT_ALLOWED_MENTION_TYPES: Record<MentionType, boolean> = {
138
+ channel: true,
139
+ here: true,
140
+ role: true,
141
+ user: true,
142
+ user_group: true,
143
+ };
144
+
145
+ type UserGroupSearchCursor = Pick<SearchUserGroupsOptions, 'id_gt' | 'name_gt'>;
146
+ type UserPaginationState = {
147
+ itemCount: number;
148
+ nextOffset?: number;
149
+ };
150
+
151
+ const decodeUserGroupCursor = <TCursor extends object>(cursor?: string | null) => {
152
+ if (!cursor) return undefined;
153
+
154
+ try {
155
+ return JSON.parse(cursor) as TCursor;
156
+ } catch {
157
+ return undefined;
158
+ }
159
+ };
160
+
161
+ const upsertUserResponse = (users: UserResponse[], user: UserResponse) => {
162
+ const existingIndex = users.findIndex((currentUser) => currentUser.id === user.id);
163
+ if (existingIndex === -1) return users.concat(user);
164
+
165
+ const nextUsers = [...users];
166
+ nextUsers.splice(existingIndex, 1, user);
167
+ return nextUsers;
168
+ };
169
+
170
+ const upsertMentionEntity = (mentions: MentionEntity[], entity: MentionEntity) => {
171
+ const existingIndex = mentions.findIndex(
172
+ (currentEntity) =>
173
+ currentEntity.id === entity.id && currentEntity.mentionType === entity.mentionType,
174
+ );
175
+ if (existingIndex === -1) return mentions.concat(entity);
176
+
177
+ const nextMentions = [...mentions];
178
+ nextMentions.splice(existingIndex, 1, entity);
179
+ return nextMentions;
180
+ };
181
+
182
+ const mentionSuggestionToEntity = (suggestion: MentionSuggestion): MentionEntity => {
183
+ if (suggestion.mentionType === 'user') {
184
+ return userSuggestionToMentionEntity(suggestion);
185
+ } else if (suggestion.mentionType === 'channel') {
186
+ return {
187
+ id: 'channel',
188
+ mentionType: 'channel',
189
+ name: 'channel',
190
+ };
191
+ } else if (suggestion.mentionType === 'here') {
192
+ return {
193
+ id: 'here',
194
+ mentionType: 'here',
195
+ name: 'here',
196
+ };
197
+ } else if (suggestion.mentionType === 'role') {
198
+ return {
199
+ id: suggestion.id,
200
+ mentionType: 'role',
201
+ name: suggestion.name,
202
+ };
203
+ } else if (suggestion.mentionType === 'user_group') {
204
+ return {
205
+ id: suggestion.id,
206
+ mentionType: 'user_group',
207
+ name: suggestion.name,
208
+ };
209
+ }
210
+
211
+ throw new Error(`Unsupported mention suggestion type: ${JSON.stringify(suggestion)}`);
212
+ };
213
+
214
+ const mentionSuggestionToInsertText = (suggestion: MentionSuggestion) =>
215
+ `@${suggestion.name || suggestion.id} `;
216
+
217
+ const DEFAULT_SUGGESTION_FACTORY_MAPPERS: {
218
+ [TMentionType in MentionType]: MentionSuggestionFactoryMapper<TMentionType>;
219
+ } = {
220
+ channel: (value, { searchToken }) => {
221
+ const name = String(value);
222
+ return {
223
+ id: name,
224
+ mentionType: 'channel',
225
+ name: 'channel',
226
+ ...getTokenizedSuggestionDisplayName({
227
+ displayName: name,
228
+ searchToken,
229
+ }),
230
+ } satisfies ChannelMentionSuggestion;
231
+ },
232
+ here: (value, { searchToken }) => {
233
+ const name = String(value);
234
+ return {
235
+ id: name,
236
+ mentionType: 'here',
237
+ name: 'here',
238
+ ...getTokenizedSuggestionDisplayName({
239
+ displayName: name,
240
+ searchToken,
241
+ }),
242
+ } satisfies HereMentionSuggestion;
243
+ },
244
+ role: (value, { searchToken }) => {
245
+ const role = String(value);
246
+ return {
247
+ id: role,
248
+ mentionType: 'role',
249
+ name: role,
250
+ ...getTokenizedSuggestionDisplayName({
251
+ displayName: role,
252
+ searchToken,
253
+ }),
254
+ } satisfies RoleMentionSuggestion;
255
+ },
256
+ user: (value, { searchToken }) => {
257
+ const user = value as UserResponse;
258
+ return {
259
+ ...user,
260
+ mentionType: 'user',
261
+ ...getTokenizedSuggestionDisplayName({
262
+ displayName: user.name || user.id,
263
+ searchToken,
264
+ }),
265
+ } satisfies UserSuggestion;
266
+ },
267
+ user_group: (value, { searchToken }) => {
268
+ const userGroup = value as UserGroupResponse;
269
+ return {
270
+ description: userGroup.description,
271
+ id: userGroup.id,
272
+ /*
273
+ Currently, all members of the group are always returned. Groups are limited to 100 members.
274
+ The memberCount == len(members) will always be true unless we add pagination here in the future
275
+ */
276
+ memberCount: userGroup.members?.length,
277
+ mentionType: 'user_group',
278
+ name: userGroup.name,
279
+ ...getTokenizedSuggestionDisplayName({
280
+ displayName: userGroup.name || userGroup.id,
281
+ searchToken,
282
+ }),
283
+ } satisfies UserGroupMentionSuggestion;
284
+ },
285
+ };
286
+
287
+ export class MentionsSearchSource extends BaseSearchSource<MentionSuggestion> {
86
288
  readonly type = 'mentions';
87
289
  protected client: StreamChat;
88
290
  protected channel: Channel;
291
+ protected latestUserPaginationState?: UserPaginationState;
292
+ protected userGroupCursor?: string;
89
293
  userFilters: UserFilters | undefined;
90
294
  memberFilters: MemberFilters | undefined;
91
295
  userSort: UserSort | undefined;
@@ -94,12 +298,28 @@ export class MentionsSearchSource extends BaseSearchSource<UserSuggestion> {
94
298
  config: MentionsSearchSourceOptions;
95
299
 
96
300
  constructor(channel: Channel, options?: MentionsSearchSourceOptions) {
97
- const { mentionAllAppUsers, textComposerText, transliterate, ...restOptions } =
98
- options || {};
301
+ const {
302
+ allowedMentionTypes,
303
+ mentionAllAppUsers,
304
+ suggestionFactoryMappers,
305
+ textComposerText,
306
+ transliterate,
307
+ trigger,
308
+ ...restOptions
309
+ } = options || {};
99
310
  super(restOptions);
100
311
  this.client = channel.getClient();
101
312
  this.channel = channel;
102
- this.config = { mentionAllAppUsers, textComposerText };
313
+ this.config = {
314
+ allowedMentionTypes: {
315
+ ...DEFAULT_ALLOWED_MENTION_TYPES,
316
+ ...allowedMentionTypes,
317
+ },
318
+ mentionAllAppUsers,
319
+ suggestionFactoryMappers,
320
+ textComposerText,
321
+ trigger,
322
+ };
103
323
 
104
324
  if (transliterate) {
105
325
  this.transliterate = transliterate;
@@ -111,15 +331,91 @@ export class MentionsSearchSource extends BaseSearchSource<UserSuggestion> {
111
331
  return countLoadedMembers < MAX_CHANNEL_MEMBER_COUNT_IN_CHANNEL_QUERY;
112
332
  }
113
333
 
114
- toUserSuggestion = (user: UserResponse): UserSuggestion => ({
115
- ...user,
116
- ...getTokenizedSuggestionDisplayName({
117
- displayName: user.name || user.id,
118
- searchToken: this.searchQuery,
119
- }),
120
- });
334
+ normalizeSearchValue = (value?: string) =>
335
+ this.transliterate(removeDiacritics(value)).toLowerCase();
336
+
337
+ matchesSearchQuery = (value: string | undefined, searchQuery: string) => {
338
+ if (!searchQuery) return true;
339
+ return this.normalizeSearchValue(value).includes(
340
+ this.normalizeSearchValue(searchQuery),
341
+ );
342
+ };
343
+
344
+ matchesPrefixSearchQuery = (value: string | undefined, searchQuery: string) => {
345
+ if (!searchQuery) return true;
346
+
347
+ return this.normalizeSearchValue(value).startsWith(
348
+ this.normalizeSearchValue(searchQuery),
349
+ );
350
+ };
351
+
352
+ matchesUserNameSearchQuery = (value: string | undefined, searchQuery: string) => {
353
+ if (!searchQuery) return true;
354
+
355
+ const normalizedValueWords = this.normalizeSearchValue(value)
356
+ .split(/\s+/)
357
+ .filter(Boolean);
358
+ const normalizedQueryWords = this.normalizeSearchValue(searchQuery)
359
+ .split(/\s+/)
360
+ .filter(Boolean);
361
+
362
+ if (!normalizedValueWords.length || !normalizedQueryWords.length) return false;
363
+
364
+ const fullMatchWords = normalizedQueryWords.slice(0, -1);
365
+ const finalQueryWord = normalizedQueryWords[normalizedQueryWords.length - 1];
366
+
367
+ return (
368
+ fullMatchWords.every((queryWord) => normalizedValueWords.includes(queryWord)) &&
369
+ normalizedValueWords.some((valueWord) => valueWord.startsWith(finalQueryWord))
370
+ );
371
+ };
372
+
373
+ isMentionTypeAllowed = (mentionType: MentionType) =>
374
+ this.config.allowedMentionTypes?.[mentionType] ?? true;
375
+
376
+ protected mapMentionSuggestion = <TMentionType extends MentionType>(
377
+ mentionType: TMentionType,
378
+ value: MentionSuggestionFactoryInputByType[TMentionType],
379
+ searchToken = this.searchQuery,
380
+ ) => {
381
+ const mapper =
382
+ this.config.suggestionFactoryMappers?.[mentionType] ??
383
+ DEFAULT_SUGGESTION_FACTORY_MAPPERS[mentionType];
384
+
385
+ return mapper(value, {
386
+ searchToken,
387
+ source: this,
388
+ }) as MentionSuggestionByType[TMentionType];
389
+ };
390
+
391
+ getChannelTeam = () => this.channel.data?.team;
392
+
393
+ toUserSuggestion = (
394
+ user: UserResponse,
395
+ searchToken = this.searchQuery,
396
+ ): UserSuggestion => this.mapMentionSuggestion('user', user, searchToken);
397
+
398
+ toChannelMentionSuggestion = (
399
+ searchToken = this.searchQuery,
400
+ ): ChannelMentionSuggestion =>
401
+ this.mapMentionSuggestion('channel', 'channel', searchToken);
402
+
403
+ toHereMentionSuggestion = (searchToken = this.searchQuery): HereMentionSuggestion =>
404
+ this.mapMentionSuggestion('here', 'here', searchToken);
405
+
406
+ toRoleMentionSuggestion = (
407
+ role: string,
408
+ searchToken = this.searchQuery,
409
+ ): RoleMentionSuggestion => this.mapMentionSuggestion('role', role, searchToken);
410
+
411
+ toUserGroupMentionSuggestion = (
412
+ userGroup: UserGroupResponse,
413
+ searchToken = this.searchQuery,
414
+ ): UserGroupMentionSuggestion =>
415
+ this.mapMentionSuggestion('user_group', userGroup, searchToken);
121
416
 
122
417
  getStateBeforeFirstQuery(newSearchString: string) {
418
+ this.userGroupCursor = undefined;
123
419
  const newState = super.getStateBeforeFirstQuery(newSearchString);
124
420
  const { items } = this.state.getLatestValue();
125
421
  return {
@@ -133,6 +429,18 @@ export class MentionsSearchSource extends BaseSearchSource<UserSuggestion> {
133
429
  return this.isActive && !this.isLoading && (hasNewSearchQuery || this.hasNext);
134
430
  };
135
431
 
432
+ protected updatePaginationStateFromQuery() {
433
+ const userPaginationState = this.latestUserPaginationState ?? { itemCount: 0 };
434
+
435
+ return {
436
+ hasNext:
437
+ typeof userPaginationState.nextOffset !== 'undefined' ||
438
+ typeof this.userGroupCursor !== 'undefined',
439
+ next: undefined,
440
+ offset: (this.offset ?? 0) + userPaginationState.itemCount,
441
+ };
442
+ }
443
+
136
444
  transliterate = (text: string) => text;
137
445
 
138
446
  getMembersAndWatchers = () => {
@@ -153,6 +461,25 @@ export class MentionsSearchSource extends BaseSearchSource<UserSuggestion> {
153
461
  return Object.values(uniqueUsers);
154
462
  };
155
463
 
464
+ getBuiltinMentionSuggestions = (searchQuery: string): MentionSuggestion[] =>
465
+ [
466
+ ...(this.isMentionTypeAllowed('channel')
467
+ ? [this.toChannelMentionSuggestion(searchQuery)]
468
+ : []),
469
+ ...(this.isMentionTypeAllowed('here')
470
+ ? [this.toHereMentionSuggestion(searchQuery)]
471
+ : []),
472
+ ].filter(({ name }) => this.matchesPrefixSearchQuery(name, searchQuery));
473
+
474
+ getRoleMentionSuggestions = async (query: string): Promise<RoleMentionSuggestion[]> => {
475
+ if (!this.isMentionTypeAllowed('role')) return [];
476
+ if (!query) return [];
477
+ const { roles } = await this.client.searchRoles({ query });
478
+ return [...(roles?.map((role) => role.name) ?? [])]
479
+ .sort((left, right) => left.localeCompare(right))
480
+ .map((role) => this.toRoleMentionSuggestion(role, query));
481
+ };
482
+
156
483
  searchMembersLocally = (searchQuery: string) => {
157
484
  const { textComposerText } = this.config;
158
485
  if (!textComposerText) return [];
@@ -163,22 +490,16 @@ export class MentionsSearchSource extends BaseSearchSource<UserSuggestion> {
163
490
  if (!searchQuery) return true;
164
491
 
165
492
  const updatedId = this.transliterate(removeDiacritics(user.id)).toLowerCase();
166
- const updatedName = this.transliterate(removeDiacritics(user.name)).toLowerCase();
167
493
  const updatedQuery = this.transliterate(
168
494
  removeDiacritics(searchQuery),
169
495
  ).toLowerCase();
170
496
 
171
497
  const maxDistance = 3;
172
- const lastDigits = textComposerText.slice(-(maxDistance + 1)).includes('@');
173
-
174
- if (updatedName) {
175
- const levenshtein = calculateLevenshtein(updatedQuery, updatedName);
176
- if (
177
- updatedName.includes(updatedQuery) ||
178
- (levenshtein <= maxDistance && lastDigits)
179
- ) {
180
- return true;
181
- }
498
+ const trigger = this.config.trigger ?? '@';
499
+ const lastDigits = textComposerText.slice(-(maxDistance + 1)).includes(trigger);
500
+
501
+ if (this.matchesUserNameSearchQuery(user.name, updatedQuery)) {
502
+ return true;
182
503
  }
183
504
 
184
505
  const levenshtein = calculateLevenshtein(updatedQuery, updatedId);
@@ -204,7 +525,7 @@ export class MentionsSearchSource extends BaseSearchSource<UserSuggestion> {
204
525
  });
205
526
  };
206
527
 
207
- prepareQueryUsersParams = (searchQuery: string) => ({
528
+ prepareQueryUsersParams = (searchQuery: string, offset = 0) => ({
208
529
  filters: {
209
530
  $or: [
210
531
  { id: { $autocomplete: searchQuery } },
@@ -213,10 +534,10 @@ export class MentionsSearchSource extends BaseSearchSource<UserSuggestion> {
213
534
  ...this.userFilters,
214
535
  } as UserFilters,
215
536
  sort: this.userSort ?? ([{ name: 1 }, { id: 1 }] as UserSort), // todo: document the change - the sort is overridden, not merged
216
- options: { ...this.searchOptions, limit: this.pageSize, offset: this.offset },
537
+ options: { ...this.searchOptions, limit: this.pageSize, offset },
217
538
  });
218
539
 
219
- prepareQueryMembersParams = (searchQuery: string) => {
540
+ prepareQueryMembersParams = (searchQuery: string, offset = 0) => {
220
541
  // QueryMembers failed with error: \"sort must contain at maximum 1 item\"
221
542
  const maxSortParamsCount = 1;
222
543
  let sort: MemberSort = [{ user_id: 1 }];
@@ -232,42 +553,160 @@ export class MentionsSearchSource extends BaseSearchSource<UserSuggestion> {
232
553
  filters:
233
554
  this.memberFilters ?? ({ name: { $autocomplete: searchQuery } } as MemberFilters), // autocomplete possible only for name
234
555
  sort,
235
- options: { ...this.searchOptions, limit: this.pageSize, offset: this.offset },
556
+ options: { ...this.searchOptions, limit: this.pageSize, offset },
236
557
  };
237
558
  };
238
559
 
239
- queryUsers = async (searchQuery: string) => {
240
- const { filters, sort, options } = this.prepareQueryUsersParams(searchQuery);
560
+ queryUsers = async (searchQuery: string, offset = 0) => {
561
+ const { filters, sort, options } = this.prepareQueryUsersParams(searchQuery, offset);
241
562
  const { users } = await this.client.queryUsers(filters, sort, options);
242
563
  return users;
243
564
  };
244
565
 
245
- queryMembers = async (searchQuery: string) => {
246
- const { filters, sort, options } = this.prepareQueryMembersParams(searchQuery);
566
+ queryMembers = async (searchQuery: string, offset = 0) => {
567
+ const { filters, sort, options } = this.prepareQueryMembersParams(
568
+ searchQuery,
569
+ offset,
570
+ );
247
571
  const response = await this.channel.queryMembers(filters, sort, options);
248
572
 
249
573
  return response.members.map((member) => member.user) as UserResponse[];
250
574
  };
251
575
 
252
- async query(searchQuery: string) {
576
+ getUserSuggestionsPage = async (searchQuery: string, userOffset = 0) => {
577
+ if (!this.isMentionTypeAllowed('user')) {
578
+ return {
579
+ items: [],
580
+ nextOffset: undefined,
581
+ };
582
+ }
583
+
253
584
  let users: UserResponse[];
254
585
  const shouldSearchLocally =
255
586
  this.allMembersLoadedWithInitialChannelQuery || !searchQuery;
256
587
 
257
588
  if (this.config.mentionAllAppUsers) {
258
- users = await this.queryUsers(searchQuery);
589
+ users = await this.queryUsers(searchQuery, userOffset);
259
590
  } else if (shouldSearchLocally) {
260
- users = this.searchMembersLocally(searchQuery);
591
+ const localUsers = this.searchMembersLocally(searchQuery);
592
+ const items = localUsers
593
+ .slice(userOffset, userOffset + this.pageSize)
594
+ .map((user) => this.toUserSuggestion(user, searchQuery));
595
+ return {
596
+ items,
597
+ nextOffset:
598
+ localUsers.length > userOffset + this.pageSize
599
+ ? userOffset + items.length
600
+ : undefined,
601
+ };
261
602
  } else {
262
- users = await this.queryMembers(searchQuery);
603
+ users = await this.queryMembers(searchQuery, userOffset);
263
604
  }
264
605
 
606
+ const items = users.map((user) => this.toUserSuggestion(user, searchQuery));
265
607
  return {
266
- items: users.map(this.toUserSuggestion),
608
+ items,
609
+ nextOffset: users.length === this.pageSize ? userOffset + users.length : undefined,
610
+ };
611
+ };
612
+
613
+ buildUserGroupSearchCursor = (items: UserGroupResponse[]) => {
614
+ if (items.length < this.pageSize) return undefined;
615
+
616
+ const lastItem = items[items.length - 1];
617
+ if (!lastItem?.name) return undefined;
618
+
619
+ return JSON.stringify({
620
+ id_gt: lastItem.id,
621
+ name_gt: lastItem.name,
622
+ } satisfies UserGroupSearchCursor);
623
+ };
624
+
625
+ getUserGroupSuggestionsPage = async (searchQuery: string, cursor?: string) => {
626
+ if (!this.isMentionTypeAllowed('user_group')) {
627
+ return {
628
+ items: [],
629
+ next: undefined,
630
+ };
631
+ }
632
+
633
+ if (!searchQuery) {
634
+ return {
635
+ items: [],
636
+ next: undefined,
637
+ };
638
+ }
639
+
640
+ const teamId = this.getChannelTeam();
641
+ const userGroupCursor = decodeUserGroupCursor<UserGroupSearchCursor>(cursor);
642
+ const options: SearchUserGroupsOptions = {
643
+ query: searchQuery,
644
+ limit: this.pageSize,
645
+ ...(teamId ? { team_id: teamId } : {}),
646
+ ...(userGroupCursor?.id_gt ? { id_gt: userGroupCursor.id_gt } : {}),
647
+ ...(userGroupCursor?.name_gt ? { name_gt: userGroupCursor.name_gt } : {}),
648
+ };
649
+ const { user_groups } = await this.client.searchUserGroups(options);
650
+
651
+ return {
652
+ items: user_groups.map((userGroup) =>
653
+ this.toUserGroupMentionSuggestion(userGroup, searchQuery),
654
+ ),
655
+ next: this.buildUserGroupSearchCursor(user_groups),
656
+ };
657
+ };
658
+
659
+ async query(searchQuery: string) {
660
+ const userOffset = this.offset ?? 0;
661
+ const isFirstPage = userOffset === 0 && typeof this.userGroupCursor === 'undefined';
662
+ const previousUserPaginationState = this.latestUserPaginationState;
663
+ const previousUserGroupCursor = this.userGroupCursor;
664
+ const [userResultsState, userGroupResultsState, roleSuggestionsState] =
665
+ await Promise.allSettled([
666
+ this.getUserSuggestionsPage(searchQuery, userOffset),
667
+ this.getUserGroupSuggestionsPage(searchQuery, previousUserGroupCursor),
668
+ isFirstPage
669
+ ? this.getRoleMentionSuggestions(searchQuery)
670
+ : Promise.resolve([] as RoleMentionSuggestion[]),
671
+ ]);
672
+
673
+ const userResults =
674
+ userResultsState.status === 'fulfilled'
675
+ ? userResultsState.value
676
+ : {
677
+ items: [],
678
+ nextOffset: isFirstPage ? undefined : previousUserPaginationState?.nextOffset,
679
+ };
680
+ const userGroupResults =
681
+ userGroupResultsState.status === 'fulfilled'
682
+ ? userGroupResultsState.value
683
+ : {
684
+ items: [],
685
+ next: isFirstPage ? undefined : previousUserGroupCursor,
686
+ };
687
+ const roleSuggestions =
688
+ roleSuggestionsState.status === 'fulfilled' ? roleSuggestionsState.value : [];
689
+ const items = [
690
+ ...(isFirstPage ? this.getBuiltinMentionSuggestions(searchQuery) : []),
691
+ ...roleSuggestions,
692
+ ...userGroupResults.items,
693
+ ...userResults.items,
694
+ ];
695
+
696
+ this.latestUserPaginationState = {
697
+ itemCount: userResults.items.length,
698
+ nextOffset: userResults.nextOffset,
699
+ };
700
+ this.userGroupCursor = userGroupResults.next;
701
+
702
+ return {
703
+ items,
267
704
  };
268
705
  }
269
706
 
270
- filterMutes = (data: UserSuggestion[]) => {
707
+ filterMutes(data: UserSuggestion[]): UserSuggestion[];
708
+ filterMutes(data: MentionSuggestion[]): MentionSuggestion[];
709
+ filterMutes(data: MentionSuggestion[]) {
271
710
  const { textComposerText } = this.config;
272
711
  if (!textComposerText) return [];
273
712
 
@@ -278,28 +717,32 @@ export class MentionsSearchSource extends BaseSearchSource<UserSuggestion> {
278
717
  if (!mutedUsers.length) return data;
279
718
 
280
719
  if (textComposerText.includes('/unmute')) {
281
- return data.filter((suggestion) =>
282
- mutedUsers.some((mute) => mute.target.id === suggestion.id),
720
+ return data.filter(
721
+ (suggestion) =>
722
+ suggestion.mentionType === 'user' &&
723
+ mutedUsers.some((mute) => mute.target.id === suggestion.id),
283
724
  );
284
725
  }
285
- return data.filter((suggestion) =>
286
- mutedUsers.every((mute) => mute.target.id !== suggestion.id),
726
+ return data.filter(
727
+ (suggestion) =>
728
+ suggestion.mentionType !== 'user' ||
729
+ mutedUsers.every((mute) => mute.target.id !== suggestion.id),
287
730
  );
288
- };
731
+ }
289
732
 
290
- filterQueryResults(items: UserSuggestion[]) {
733
+ filterQueryResults(items: MentionSuggestion[]) {
291
734
  return this.filterMutes(items);
292
735
  }
736
+
737
+ resetState() {
738
+ this.latestUserPaginationState = undefined;
739
+ this.userGroupCursor = undefined;
740
+ super.resetState();
741
+ }
293
742
  }
294
743
 
295
744
  const DEFAULT_OPTIONS: TextComposerMiddlewareOptions = { minChars: 1, trigger: '@' };
296
745
 
297
- const userSuggestionToUserResponse = (suggestion: UserSuggestion): UserResponse => {
298
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
299
- const { tokenizedDisplayName, ...userResponse } = suggestion;
300
- return userResponse;
301
- };
302
-
303
746
  /**
304
747
  * TextComposer middleware for mentions
305
748
  * Usage:
@@ -320,7 +763,7 @@ const userSuggestionToUserResponse = (suggestion: UserSuggestion): UserResponse
320
763
  */
321
764
 
322
765
  export type MentionsMiddleware = Middleware<
323
- TextComposerMiddlewareExecutorState<UserSuggestion>,
766
+ TextComposerMiddlewareExecutorState<MentionSuggestion>,
324
767
  'onChange' | 'onSuggestionItemSelect'
325
768
  >;
326
769
 
@@ -336,18 +779,52 @@ export const createMentionsMiddleware = (
336
779
  searchSource = options.searchSource;
337
780
  searchSource.resetState();
338
781
  } else {
339
- searchSource = new MentionsSearchSource(channel);
782
+ searchSource = new MentionsSearchSource(channel, { trigger: finalOptions.trigger });
340
783
  }
341
784
  searchSource.activate();
785
+ // Tracks the cursor position of the most recently inserted mention so the
786
+ // VERY NEXT change (typically a controlled value echo on some platforms)
787
+ // can suppress the dropdown even when the text shape heuristic in `onChange`
788
+ // would otherwise let it reopen (which is wrong). Consumed on the first
789
+ // `onChange` after it's set, so any user driven event that triggers it would
790
+ // clear it.
791
+ let lastInsertedMentionEndOffset: number | undefined;
342
792
  return {
343
793
  id: 'stream-io/text-composer/mentions-middleware',
344
794
  handlers: {
345
795
  onChange: ({ state, next, complete, forward }) => {
346
796
  if (!state.selection) return forward();
797
+ const cursorJustInsertedAMention =
798
+ lastInsertedMentionEndOffset !== undefined &&
799
+ state.selection.end === lastInsertedMentionEndOffset;
800
+ lastInsertedMentionEndOffset = undefined;
801
+ // Only prune stale mentions during normal text editing. Entering command mode
802
+ // clears text/mentions through the `command.activate` effect, which first
803
+ // snapshots the previous TextComposer state so it can be restored on
804
+ // `clearCommand()`. Custom middleware is allowed to remove that effect,
805
+ // though, and in that opt-out case we must not silently drop mentions
806
+ // here just because the user typed a raw command like `/ban`.
807
+ const currentMentions =
808
+ state.command || state.text.trimStart().startsWith('/')
809
+ ? state.mentionedUsers
810
+ : getMentionedUsersInText(state.text, state.mentionedUsers);
811
+ const mentionedUsersChanged =
812
+ currentMentions.length !== state.mentionedUsers.length ||
813
+ currentMentions.some(
814
+ (user, index) => user.id !== state.mentionedUsers[index]?.id,
815
+ );
816
+ const stateWithMentions = mentionedUsersChanged
817
+ ? { ...state, mentionedUsers: currentMentions }
818
+ : state;
819
+
820
+ const textBeforeCursor = stateWithMentions.text.slice(
821
+ 0,
822
+ stateWithMentions.selection.end,
823
+ );
347
824
 
348
825
  const triggerWithToken = getTriggerCharWithToken({
349
826
  trigger: finalOptions.trigger,
350
- text: state.text.slice(0, state.selection.end),
827
+ text: textBeforeCursor,
351
828
  });
352
829
 
353
830
  const newSearchTriggered =
@@ -357,22 +834,59 @@ export const createMentionsMiddleware = (
357
834
  searchSource.resetStateAndActivate();
358
835
  }
359
836
 
837
+ // The trigger detection regex above also accepts `@<token><trailing-space>`
838
+ // as "active" so users can keep refining a partial mention they typed by
839
+ // hand. That falsely reopens the dropdown when the cursor sits at the
840
+ // trailing space boundary of a mention the user has already committed
841
+ // (post suggestion select or manual cursor placement back into that slot).
842
+ //
843
+ // Discriminate that case by checking, for each entity in
844
+ // `state.mentions`, whether the text immediately before the cursor
845
+ // ends with the entity's actual inserted textual form (`@<name|id> `)
846
+ // AND that form appears exactly once in the prefix. This:
847
+ // - avoids false positives when an entity's `id` happens to match a
848
+ // different `@<token>` the user just typed (e.g. user "John Doe"
849
+ // whose id is "john" and the user types a fresh `@ivan ` later);
850
+ // - avoids false positives when the user is refining a brand new
851
+ // mention whose query equals an already committed mention name
852
+ // (text has two `@<name> ` occurrences; only one is committed, the
853
+ // cursor is most likely on the new one being typed).
854
+ const triggerMatchesCommittedMention =
855
+ !!triggerWithToken &&
856
+ /\s$/.test(textBeforeCursor) &&
857
+ (cursorJustInsertedAMention ||
858
+ (stateWithMentions.mentions ?? []).some((entity) => {
859
+ const insertedToken = entity.name ?? entity.id;
860
+ if (!insertedToken) return false;
861
+ const insertedForm = `@${insertedToken} `;
862
+ if (!textBeforeCursor.endsWith(insertedForm)) return false;
863
+ const escapedInsertedForm = insertedForm.replace(
864
+ /[.*+?^${}()|[\]\\]/g,
865
+ '\\$&',
866
+ );
867
+ const occurrences = textBeforeCursor.match(
868
+ new RegExp(escapedInsertedForm, 'g'),
869
+ );
870
+ return (occurrences?.length ?? 0) === 1;
871
+ }));
872
+
360
873
  const triggerWasRemoved =
361
874
  !triggerWithToken || triggerWithToken.length < finalOptions.minChars;
362
875
 
363
- if (triggerWasRemoved) {
364
- const hasStaleSuggestions = state.suggestions?.trigger === finalOptions.trigger;
365
- const newState = { ...state };
876
+ if (triggerWasRemoved || triggerMatchesCommittedMention) {
877
+ const hasStaleSuggestions =
878
+ stateWithMentions.suggestions?.trigger === finalOptions.trigger;
879
+ const newState = { ...stateWithMentions };
366
880
  if (hasStaleSuggestions) {
367
881
  delete newState.suggestions;
368
882
  }
369
883
  return next(newState);
370
884
  }
371
885
 
372
- searchSource.config.textComposerText = state.text;
886
+ searchSource.config.textComposerText = stateWithMentions.text;
373
887
 
374
888
  return complete({
375
- ...state,
889
+ ...stateWithMentions,
376
890
  suggestions: {
377
891
  query: triggerWithToken.slice(1),
378
892
  searchSource,
@@ -386,17 +900,33 @@ export const createMentionsMiddleware = (
386
900
  return forward();
387
901
 
388
902
  searchSource.resetStateAndActivate();
903
+ const mentionEntity = mentionSuggestionToEntity(selectedSuggestion);
904
+ const mentions = upsertMentionEntity(
905
+ state.mentions ?? userResponsesToMentionEntities(state.mentionedUsers),
906
+ mentionEntity,
907
+ );
908
+ const insertResult = insertItemWithTrigger({
909
+ insertText: mentionSuggestionToInsertText(selectedSuggestion),
910
+ selection: state.selection,
911
+ text: state.text,
912
+ trigger: finalOptions.trigger,
913
+ });
914
+ // Hand off the just inserted cursor position to the next `onChange`
915
+ // so it can suppress the dropdown even when the text shape heuristic
916
+ // doesn't catch a reselection of the same entity (multiple
917
+ // occurrences of `@<name> ` in the text for exammple).
918
+ lastInsertedMentionEndOffset = insertResult.selection.end;
389
919
  return complete({
390
920
  ...state,
391
- ...insertItemWithTrigger({
392
- insertText: `@${selectedSuggestion.name || selectedSuggestion.id} `,
393
- selection: state.selection,
394
- text: state.text,
395
- trigger: finalOptions.trigger,
396
- }),
397
- mentionedUsers: state.mentionedUsers.concat(
398
- userSuggestionToUserResponse(selectedSuggestion),
399
- ),
921
+ ...insertResult,
922
+ mentionedUsers:
923
+ selectedSuggestion.mentionType === 'user'
924
+ ? upsertUserResponse(
925
+ state.mentionedUsers,
926
+ userSuggestionToUserResponse(selectedSuggestion),
927
+ )
928
+ : state.mentionedUsers,
929
+ mentions,
400
930
  suggestions: undefined,
401
931
  });
402
932
  },