stream-chat 9.50.3 → 9.52.0
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.
- package/dist/cjs/index.browser.js +601 -275
- package/dist/cjs/index.browser.js.map +4 -4
- package/dist/cjs/index.node.js +610 -276
- package/dist/cjs/index.node.js.map +4 -4
- package/dist/esm/index.mjs +599 -273
- package/dist/esm/index.mjs.map +4 -4
- package/dist/types/channel.d.ts +4 -3
- package/dist/types/client.d.ts +23 -9
- package/dist/types/index.d.ts +7 -0
- package/dist/types/messageComposer/attachmentIdentity.d.ts +4 -0
- package/dist/types/messageComposer/attachmentManager.d.ts +8 -0
- package/dist/types/messageComposer/messageComposer.d.ts +13 -0
- package/dist/types/messageComposer/middleware/attachmentManager/postUpload/attachmentEnrichment.d.ts +20 -1
- package/dist/types/messageComposer/middleware/messageComposer/MessageComposerMiddlewareExecutor.d.ts +6 -1
- package/dist/types/messageComposer/middleware/messageComposer/attachments.d.ts +24 -0
- package/dist/types/messageComposer/middleware/messageComposer/types.d.ts +22 -1
- package/dist/types/messageComposer/middleware/textComposer/mentions.d.ts +15 -9
- package/dist/types/messageComposer/types.d.ts +5 -0
- package/dist/types/middleware.d.ts +8 -0
- package/dist/types/offline-support/offline_support_api.d.ts +7 -1
- package/dist/types/offline-support/offline_sync_manager.d.ts +12 -1
- package/dist/types/search/BaseSearchSource.d.ts +42 -24
- package/dist/types/search/ChannelMemberSearchSource.d.ts +9 -4
- package/dist/types/search/ChannelSearchSource.d.ts +9 -3
- package/dist/types/search/MessageSearchSource.d.ts +14 -3
- package/dist/types/search/UserSearchSource.d.ts +9 -3
- package/dist/types/search/types.d.ts +14 -1
- package/dist/types/types.d.ts +12 -1
- package/dist/types/uploadManager.d.ts +30 -0
- package/dist/types/utils.d.ts +21 -2
- package/package.json +7 -7
- package/src/channel.ts +20 -8
- package/src/client.ts +71 -13
- package/src/index.ts +12 -0
- package/src/messageComposer/attachmentIdentity.ts +27 -0
- package/src/messageComposer/attachmentManager.ts +61 -12
- package/src/messageComposer/messageComposer.ts +43 -8
- package/src/messageComposer/middleware/attachmentManager/postUpload/AttachmentPostUploadMiddlewareExecutor.ts +1 -1
- package/src/messageComposer/middleware/attachmentManager/postUpload/attachmentEnrichment.ts +58 -33
- package/src/messageComposer/middleware/attachmentManager/postUpload/uploadErrorHandler.ts +4 -0
- package/src/messageComposer/middleware/messageComposer/MessageComposerMiddlewareExecutor.ts +9 -0
- package/src/messageComposer/middleware/messageComposer/attachments.ts +109 -0
- package/src/messageComposer/middleware/messageComposer/types.ts +22 -1
- package/src/messageComposer/middleware/textComposer/mentions.ts +75 -23
- package/src/messageComposer/types.ts +5 -0
- package/src/middleware.ts +11 -0
- package/src/offline-support/offline_support_api.ts +21 -2
- package/src/offline-support/offline_sync_manager.ts +36 -8
- package/src/search/BaseSearchSource.ts +123 -39
- package/src/search/ChannelMemberSearchSource.ts +22 -11
- package/src/search/ChannelSearchSource.ts +16 -5
- package/src/search/MessageSearchSource.ts +36 -4
- package/src/search/UserSearchSource.ts +16 -5
- package/src/search/types.ts +14 -1
- package/src/types.ts +13 -1
- package/src/uploadManager.ts +45 -0
- package/src/utils.ts +78 -3
|
@@ -9,7 +9,11 @@ import {
|
|
|
9
9
|
userSuggestionToMentionEntity,
|
|
10
10
|
userSuggestionToUserResponse,
|
|
11
11
|
} from './mentionUtils';
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
BaseSearchSource,
|
|
14
|
+
type SearchQueryOptions,
|
|
15
|
+
type SearchSourceOptions,
|
|
16
|
+
} from '../../../search';
|
|
13
17
|
import { mergeWith } from '../../../utils/mergeWith';
|
|
14
18
|
import type {
|
|
15
19
|
ChannelMentionSuggestion,
|
|
@@ -92,6 +96,13 @@ export const calculateLevenshtein = (query: string, name: string) => {
|
|
|
92
96
|
};
|
|
93
97
|
|
|
94
98
|
export type MentionsSearchSourceOptions = SearchSourceOptions & {
|
|
99
|
+
/** Static base filters for the app-wide user query (mentionAllAppUsers). */
|
|
100
|
+
userFilters?: UserFilters;
|
|
101
|
+
userSort?: UserSort;
|
|
102
|
+
/** Static base filters for the channel member query. */
|
|
103
|
+
memberFilters?: MemberFilters;
|
|
104
|
+
memberSort?: MemberSort;
|
|
105
|
+
searchOptions?: Omit<UserOptions, 'limit' | 'offset'>;
|
|
95
106
|
mentionAllAppUsers?: boolean;
|
|
96
107
|
suggestionFactoryMappers?: MentionSuggestionFactoryMapperOverrides;
|
|
97
108
|
textComposerText?: string;
|
|
@@ -299,20 +310,34 @@ export class MentionsSearchSource extends BaseSearchSource<MentionSuggestion> {
|
|
|
299
310
|
userSort: UserSort | undefined;
|
|
300
311
|
memberSort: MemberSort | undefined; // todo: document there are filters and sort options for users and members
|
|
301
312
|
searchOptions: Omit<UserOptions, 'limit' | 'offset'> | undefined;
|
|
302
|
-
config:
|
|
313
|
+
config: Pick<
|
|
314
|
+
MentionsSearchSourceOptions,
|
|
315
|
+
'mentionAllAppUsers' | 'suggestionFactoryMappers' | 'textComposerText' | 'trigger'
|
|
316
|
+
>;
|
|
303
317
|
|
|
304
318
|
constructor(channel: Channel, options?: MentionsSearchSourceOptions) {
|
|
305
319
|
const {
|
|
306
320
|
mentionAllAppUsers,
|
|
321
|
+
memberFilters,
|
|
322
|
+
memberSort,
|
|
323
|
+
searchOptions,
|
|
307
324
|
suggestionFactoryMappers,
|
|
308
325
|
textComposerText,
|
|
309
326
|
transliterate,
|
|
310
327
|
trigger,
|
|
328
|
+
userFilters,
|
|
329
|
+
userSort,
|
|
311
330
|
...restOptions
|
|
312
331
|
} = options || {};
|
|
313
|
-
|
|
332
|
+
// suggestions are shown for a bare trigger, so the empty query must be allowed
|
|
333
|
+
super({ ...restOptions, allowEmptySearchString: true });
|
|
314
334
|
this.client = channel.getClient();
|
|
315
335
|
this.channel = channel;
|
|
336
|
+
this.userFilters = userFilters;
|
|
337
|
+
this.userSort = userSort;
|
|
338
|
+
this.memberFilters = memberFilters;
|
|
339
|
+
this.memberSort = memberSort;
|
|
340
|
+
this.searchOptions = searchOptions;
|
|
316
341
|
this.config = {
|
|
317
342
|
mentionAllAppUsers,
|
|
318
343
|
suggestionFactoryMappers,
|
|
@@ -425,11 +450,6 @@ export class MentionsSearchSource extends BaseSearchSource<MentionSuggestion> {
|
|
|
425
450
|
};
|
|
426
451
|
}
|
|
427
452
|
|
|
428
|
-
canExecuteQuery = (newSearchString?: string) => {
|
|
429
|
-
const hasNewSearchQuery = typeof newSearchString !== 'undefined';
|
|
430
|
-
return this.isActive && !this.isLoading && (hasNewSearchQuery || this.hasNext);
|
|
431
|
-
};
|
|
432
|
-
|
|
433
453
|
protected updatePaginationStateFromQuery() {
|
|
434
454
|
const userPaginationState = this.latestUserPaginationState ?? { itemCount: 0 };
|
|
435
455
|
|
|
@@ -472,10 +492,13 @@ export class MentionsSearchSource extends BaseSearchSource<MentionSuggestion> {
|
|
|
472
492
|
: []),
|
|
473
493
|
].filter(({ name }) => this.matchesPrefixSearchQuery(name, searchQuery));
|
|
474
494
|
|
|
475
|
-
getRoleMentionSuggestions = async (
|
|
495
|
+
getRoleMentionSuggestions = async (
|
|
496
|
+
query: string,
|
|
497
|
+
queryOptions: SearchQueryOptions = {},
|
|
498
|
+
): Promise<RoleMentionSuggestion[]> => {
|
|
476
499
|
if (!this.isMentionTypeAllowed('role')) return [];
|
|
477
500
|
if (!query) return [];
|
|
478
|
-
const { roles } = await this.client.searchRoles({ query });
|
|
501
|
+
const { roles } = await this.client.searchRoles({ query }, queryOptions);
|
|
479
502
|
return [...(roles?.map((role) => role.name) ?? [])]
|
|
480
503
|
.sort((left, right) => left.localeCompare(right))
|
|
481
504
|
.map((role) => this.toRoleMentionSuggestion(role, query));
|
|
@@ -558,23 +581,40 @@ export class MentionsSearchSource extends BaseSearchSource<MentionSuggestion> {
|
|
|
558
581
|
};
|
|
559
582
|
};
|
|
560
583
|
|
|
561
|
-
queryUsers = async (
|
|
584
|
+
queryUsers = async (
|
|
585
|
+
searchQuery: string,
|
|
586
|
+
offset = 0,
|
|
587
|
+
queryOptions: SearchQueryOptions = {},
|
|
588
|
+
) => {
|
|
562
589
|
const { filters, sort, options } = this.prepareQueryUsersParams(searchQuery, offset);
|
|
563
|
-
const { users } = await this.client.queryUsers(filters, sort, options);
|
|
590
|
+
const { users } = await this.client.queryUsers(filters, sort, options, queryOptions);
|
|
564
591
|
return users;
|
|
565
592
|
};
|
|
566
593
|
|
|
567
|
-
queryMembers = async (
|
|
594
|
+
queryMembers = async (
|
|
595
|
+
searchQuery: string,
|
|
596
|
+
offset = 0,
|
|
597
|
+
queryOptions: SearchQueryOptions = {},
|
|
598
|
+
) => {
|
|
568
599
|
const { filters, sort, options } = this.prepareQueryMembersParams(
|
|
569
600
|
searchQuery,
|
|
570
601
|
offset,
|
|
571
602
|
);
|
|
572
|
-
const response = await this.channel.queryMembers(
|
|
603
|
+
const response = await this.channel.queryMembers(
|
|
604
|
+
filters,
|
|
605
|
+
sort,
|
|
606
|
+
options,
|
|
607
|
+
queryOptions,
|
|
608
|
+
);
|
|
573
609
|
|
|
574
610
|
return response.members.map((member) => member.user) as UserResponse[];
|
|
575
611
|
};
|
|
576
612
|
|
|
577
|
-
getUserSuggestionsPage = async (
|
|
613
|
+
getUserSuggestionsPage = async (
|
|
614
|
+
searchQuery: string,
|
|
615
|
+
userOffset = 0,
|
|
616
|
+
queryOptions: SearchQueryOptions = {},
|
|
617
|
+
) => {
|
|
578
618
|
if (!this.isMentionTypeAllowed('user')) {
|
|
579
619
|
return {
|
|
580
620
|
items: [],
|
|
@@ -587,7 +627,7 @@ export class MentionsSearchSource extends BaseSearchSource<MentionSuggestion> {
|
|
|
587
627
|
this.allMembersLoadedWithInitialChannelQuery || !searchQuery;
|
|
588
628
|
|
|
589
629
|
if (this.config.mentionAllAppUsers) {
|
|
590
|
-
users = await this.queryUsers(searchQuery, userOffset);
|
|
630
|
+
users = await this.queryUsers(searchQuery, userOffset, queryOptions);
|
|
591
631
|
} else if (shouldSearchLocally) {
|
|
592
632
|
const localUsers = this.searchMembersLocally(searchQuery);
|
|
593
633
|
const items = localUsers
|
|
@@ -601,7 +641,7 @@ export class MentionsSearchSource extends BaseSearchSource<MentionSuggestion> {
|
|
|
601
641
|
: undefined,
|
|
602
642
|
};
|
|
603
643
|
} else {
|
|
604
|
-
users = await this.queryMembers(searchQuery, userOffset);
|
|
644
|
+
users = await this.queryMembers(searchQuery, userOffset, queryOptions);
|
|
605
645
|
}
|
|
606
646
|
|
|
607
647
|
const items = users.map((user) => this.toUserSuggestion(user, searchQuery));
|
|
@@ -623,7 +663,11 @@ export class MentionsSearchSource extends BaseSearchSource<MentionSuggestion> {
|
|
|
623
663
|
} satisfies UserGroupSearchCursor);
|
|
624
664
|
};
|
|
625
665
|
|
|
626
|
-
getUserGroupSuggestionsPage = async (
|
|
666
|
+
getUserGroupSuggestionsPage = async (
|
|
667
|
+
searchQuery: string,
|
|
668
|
+
cursor?: string,
|
|
669
|
+
queryOptions: SearchQueryOptions = {},
|
|
670
|
+
) => {
|
|
627
671
|
if (!this.isMentionTypeAllowed('user_group')) {
|
|
628
672
|
return {
|
|
629
673
|
items: [],
|
|
@@ -647,7 +691,7 @@ export class MentionsSearchSource extends BaseSearchSource<MentionSuggestion> {
|
|
|
647
691
|
...(userGroupCursor?.id_gt ? { id_gt: userGroupCursor.id_gt } : {}),
|
|
648
692
|
...(userGroupCursor?.name_gt ? { name_gt: userGroupCursor.name_gt } : {}),
|
|
649
693
|
};
|
|
650
|
-
const { user_groups } = await this.client.searchUserGroups(options);
|
|
694
|
+
const { user_groups } = await this.client.searchUserGroups(options, queryOptions);
|
|
651
695
|
|
|
652
696
|
return {
|
|
653
697
|
items: user_groups.map((userGroup) =>
|
|
@@ -657,20 +701,28 @@ export class MentionsSearchSource extends BaseSearchSource<MentionSuggestion> {
|
|
|
657
701
|
};
|
|
658
702
|
};
|
|
659
703
|
|
|
660
|
-
async query(searchQuery: string) {
|
|
704
|
+
async query(searchQuery: string, queryOptions: SearchQueryOptions = {}) {
|
|
661
705
|
const userOffset = this.offset ?? 0;
|
|
662
706
|
const isFirstPage = userOffset === 0 && typeof this.userGroupCursor === 'undefined';
|
|
663
707
|
const previousUserPaginationState = this.latestUserPaginationState;
|
|
664
708
|
const previousUserGroupCursor = this.userGroupCursor;
|
|
665
709
|
const [userResultsState, userGroupResultsState, roleSuggestionsState] =
|
|
666
710
|
await Promise.allSettled([
|
|
667
|
-
this.getUserSuggestionsPage(searchQuery, userOffset),
|
|
668
|
-
this.getUserGroupSuggestionsPage(
|
|
711
|
+
this.getUserSuggestionsPage(searchQuery, userOffset, queryOptions),
|
|
712
|
+
this.getUserGroupSuggestionsPage(
|
|
713
|
+
searchQuery,
|
|
714
|
+
previousUserGroupCursor,
|
|
715
|
+
queryOptions,
|
|
716
|
+
),
|
|
669
717
|
isFirstPage
|
|
670
|
-
? this.getRoleMentionSuggestions(searchQuery)
|
|
718
|
+
? this.getRoleMentionSuggestions(searchQuery, queryOptions)
|
|
671
719
|
: Promise.resolve([] as RoleMentionSuggestion[]),
|
|
672
720
|
]);
|
|
673
721
|
|
|
722
|
+
// On abort the requests above reject and the fallback branches below would write
|
|
723
|
+
// empty results into the pagination cursors, corrupting them for the newer query.
|
|
724
|
+
if (queryOptions.signal?.aborted) return { items: [] };
|
|
725
|
+
|
|
674
726
|
const userResults =
|
|
675
727
|
userResultsState.status === 'fulfilled'
|
|
676
728
|
? userResultsState.value
|
|
@@ -119,6 +119,11 @@ export type LocalAttachmentUploadMetadata = {
|
|
|
119
119
|
*/
|
|
120
120
|
previewUri?: string;
|
|
121
121
|
uploadState: AttachmentLoadingState;
|
|
122
|
+
/**
|
|
123
|
+
* Mirrors {@link UploadRecord.uploadConfirmationPending}: every byte has been sent, but the server has
|
|
124
|
+
* not confirmed yet - not returned response to the upload request. Render an indeterminate indicator rather than a full bar.
|
|
125
|
+
*/
|
|
126
|
+
uploadConfirmationPending?: boolean;
|
|
122
127
|
uploadPermissionCheck?: UploadPermissionCheckResult; // added new
|
|
123
128
|
/** 0–100 while uploading when progress tracking is enabled; undefined otherwise or when indeterminate */
|
|
124
129
|
uploadProgress?: number;
|
package/src/middleware.ts
CHANGED
|
@@ -58,6 +58,17 @@ export class MiddlewareExecutor<TValue, THandlers extends string> {
|
|
|
58
58
|
this.id = generateUUIDv4();
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
/**
|
|
62
|
+
* The middleware currently installed, in execution order.
|
|
63
|
+
*
|
|
64
|
+
* Read-only: use `use` / `insert` / `replace` / `setOrder` / `remove` to change the chain. It
|
|
65
|
+
* exists so components can ask what the chain is capable of - a middleware may declare
|
|
66
|
+
* behaviour its host has to account for, and the host has no other way to find out.
|
|
67
|
+
*/
|
|
68
|
+
get installedMiddleware(): ReadonlyArray<Middleware<TValue, THandlers>> {
|
|
69
|
+
return this.middleware;
|
|
70
|
+
}
|
|
71
|
+
|
|
61
72
|
use(middleware: Middleware<TValue, THandlers> | Middleware<TValue, THandlers>[]) {
|
|
62
73
|
this.middleware = this.middleware.concat(middleware);
|
|
63
74
|
return this;
|
|
@@ -40,9 +40,24 @@ export abstract class AbstractOfflineDB implements OfflineDBApi {
|
|
|
40
40
|
public syncManager: OfflineDBSyncManager;
|
|
41
41
|
public state: StateStore<OfflineDBState>;
|
|
42
42
|
|
|
43
|
-
constructor({
|
|
43
|
+
constructor({
|
|
44
|
+
client,
|
|
45
|
+
syncMaxEventCount,
|
|
46
|
+
}: {
|
|
47
|
+
client: StreamChat;
|
|
48
|
+
/**
|
|
49
|
+
* Optional positive cap forwarded to the {@link OfflineDBSyncManager}, which
|
|
50
|
+
* owns sync. `undefined` or any non-positive value means "no limit". See
|
|
51
|
+
* {@link OfflineDBSyncManager.syncMaxEventCount}.
|
|
52
|
+
*/
|
|
53
|
+
syncMaxEventCount?: number;
|
|
54
|
+
}) {
|
|
44
55
|
this.client = client;
|
|
45
|
-
this.syncManager = new OfflineDBSyncManager({
|
|
56
|
+
this.syncManager = new OfflineDBSyncManager({
|
|
57
|
+
client,
|
|
58
|
+
offlineDb: this,
|
|
59
|
+
syncMaxEventCount,
|
|
60
|
+
});
|
|
46
61
|
this.state = new StateStore<OfflineDBState>({
|
|
47
62
|
initialized: false,
|
|
48
63
|
userId: this.client.userID,
|
|
@@ -1035,6 +1050,10 @@ export abstract class AbstractOfflineDB implements OfflineDBApi {
|
|
|
1035
1050
|
}
|
|
1036
1051
|
|
|
1037
1052
|
if (type === 'message.read' || type === 'notification.mark_read') {
|
|
1053
|
+
// We make sure not to update channel reads (which is what's stored in
|
|
1054
|
+
// the offline DB in any case) whenever we receive a read event for a
|
|
1055
|
+
// a thread specifically.
|
|
1056
|
+
if (event.thread) return [];
|
|
1038
1057
|
return this.handleRead({ event, unreadMessages: 0, execute });
|
|
1039
1058
|
}
|
|
1040
1059
|
|
|
@@ -20,16 +20,29 @@ export class OfflineDBSyncManager {
|
|
|
20
20
|
new Map();
|
|
21
21
|
private client: StreamChat;
|
|
22
22
|
private offlineDb: AbstractOfflineDB;
|
|
23
|
+
/**
|
|
24
|
+
* Optional positive cap on the number of events a single `/sync` response may
|
|
25
|
+
* contain before this manager skips event replay into local storage and relies
|
|
26
|
+
* on the reconnect-time channel refresh (queryChannels + `channel.watch()`) to
|
|
27
|
+
* bring visible surfaces up to date.
|
|
28
|
+
*
|
|
29
|
+
* `undefined` or any non-positive value means "no limit" and event replay always
|
|
30
|
+
* runs with whatever maximum the server decides to return.
|
|
31
|
+
*/
|
|
32
|
+
public readonly syncMaxEventCount?: number;
|
|
23
33
|
|
|
24
34
|
constructor({
|
|
25
35
|
client,
|
|
26
36
|
offlineDb,
|
|
37
|
+
syncMaxEventCount,
|
|
27
38
|
}: {
|
|
28
39
|
client: StreamChat;
|
|
29
40
|
offlineDb: AbstractOfflineDB;
|
|
41
|
+
syncMaxEventCount?: number;
|
|
30
42
|
}) {
|
|
31
43
|
this.client = client;
|
|
32
44
|
this.offlineDb = offlineDb;
|
|
45
|
+
this.syncMaxEventCount = syncMaxEventCount;
|
|
33
46
|
}
|
|
34
47
|
|
|
35
48
|
/**
|
|
@@ -173,14 +186,29 @@ export class OfflineDBSyncManager {
|
|
|
173
186
|
await this.offlineDb.resetDB();
|
|
174
187
|
} else {
|
|
175
188
|
const result = await this.client.sync(cids, lastSyncedAtDate.toISOString());
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
const
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
189
|
+
|
|
190
|
+
// Opt-in positive cap owned by this manager; undefined/non-positive = no limit.
|
|
191
|
+
const { syncMaxEventCount } = this;
|
|
192
|
+
const exceedsLimit =
|
|
193
|
+
typeof syncMaxEventCount === 'number' &&
|
|
194
|
+
syncMaxEventCount > 0 &&
|
|
195
|
+
result.events.length > syncMaxEventCount;
|
|
196
|
+
if (exceedsLimit) {
|
|
197
|
+
this.client.logger(
|
|
198
|
+
'warn',
|
|
199
|
+
`Skipping sync event replay: received ${result.events.length} events, which exceeds the configured limit of ${syncMaxEventCount}. Visible channels are refreshed on reconnect instead.`,
|
|
200
|
+
{ tags: ['sync', 'offline'] },
|
|
201
|
+
);
|
|
202
|
+
} else {
|
|
203
|
+
const queryPromises = result.events.map((event) =>
|
|
204
|
+
this.offlineDb.handleEvent({ event, execute: false }),
|
|
205
|
+
);
|
|
206
|
+
const queriesArray = await Promise.all(queryPromises);
|
|
207
|
+
const queries = queriesArray.flat() as ExecuteBatchDBQueriesType;
|
|
208
|
+
|
|
209
|
+
if (queries.length) {
|
|
210
|
+
await this.offlineDb.executeSqlBatch(queries);
|
|
211
|
+
}
|
|
184
212
|
}
|
|
185
213
|
}
|
|
186
214
|
}
|
|
@@ -9,10 +9,24 @@ import type {
|
|
|
9
9
|
import type { APIError } from '../errors';
|
|
10
10
|
import { isAPIError, isErrorRetryable } from '../errors';
|
|
11
11
|
|
|
12
|
+
/**
|
|
13
|
+
* Passed to a search source's `query()`. Carries a cancellation handle and makes no
|
|
14
|
+
* claim that the query performs a request: a source may resolve from local data and
|
|
15
|
+
* ignore the signal. Where a source does issue requests, forward this straight through
|
|
16
|
+
* as the request options.
|
|
17
|
+
*/
|
|
18
|
+
export type SearchQueryOptions = {
|
|
19
|
+
/** Aborted once a newer query supersedes this one. */
|
|
20
|
+
signal?: AbortSignal;
|
|
21
|
+
};
|
|
22
|
+
|
|
12
23
|
export type DebounceOptions = {
|
|
13
|
-
|
|
24
|
+
/** Applies to both short and long queries unless overridden by the options below. */
|
|
25
|
+
debounceMs?: number;
|
|
26
|
+
shortQueryDebounceMs?: number;
|
|
27
|
+
longQueryDebounceMs?: number;
|
|
28
|
+
shortQueryMaxLength?: number;
|
|
14
29
|
};
|
|
15
|
-
type DebouncedExecQueryFunction = DebouncedFunc<(searchString?: string) => Promise<void>>;
|
|
16
30
|
|
|
17
31
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
18
32
|
interface ISearchSource<T = any> {
|
|
@@ -54,18 +68,44 @@ export interface SearchSourceSync<T = any> extends ISearchSource<T> {
|
|
|
54
68
|
search(text?: string): void;
|
|
55
69
|
}
|
|
56
70
|
|
|
57
|
-
|
|
58
|
-
|
|
71
|
+
// Debounce defaults are resolved by resolveDebounceOptions, not here.
|
|
72
|
+
const DEFAULT_SEARCH_SOURCE_OPTIONS: Required<
|
|
73
|
+
Omit<SearchSourceOptions, keyof DebounceOptions>
|
|
74
|
+
> = {
|
|
59
75
|
pageSize: 10,
|
|
60
76
|
allowEmptySearchString: false,
|
|
61
77
|
resetOnNewSearchQuery: true,
|
|
62
78
|
} as const;
|
|
63
79
|
|
|
64
|
-
|
|
80
|
+
/**
|
|
81
|
+
* An explicit `debounceMs` applies to both buckets, so integrators who already tuned it
|
|
82
|
+
* keep exactly their configured interval. Only when it is absent do the 500/300 defaults
|
|
83
|
+
* kick in.
|
|
84
|
+
*/
|
|
85
|
+
const resolveDebounceOptions = ({
|
|
86
|
+
debounceMs,
|
|
87
|
+
shortQueryDebounceMs,
|
|
88
|
+
longQueryDebounceMs,
|
|
89
|
+
shortQueryMaxLength,
|
|
90
|
+
}: DebounceOptions) => ({
|
|
91
|
+
shortQueryDebounceMs: shortQueryDebounceMs ?? debounceMs ?? 500,
|
|
92
|
+
longQueryDebounceMs: longQueryDebounceMs ?? debounceMs ?? 300,
|
|
93
|
+
shortQueryMaxLength: shortQueryMaxLength ?? 2,
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
abstract class BaseSearchSourceBase<
|
|
97
|
+
T,
|
|
98
|
+
R extends void | Promise<void>,
|
|
99
|
+
> implements ISearchSource<T> {
|
|
65
100
|
state: StateStore<SearchSourceState<T>>;
|
|
66
101
|
pageSize: number;
|
|
67
102
|
protected allowEmptySearchString: boolean;
|
|
68
103
|
protected resetOnNewSearchQuery: boolean;
|
|
104
|
+
// assigned by setDebounceOptions, which the constructor always calls
|
|
105
|
+
protected shortQueryDebounceMs!: number;
|
|
106
|
+
protected longQueryDebounceMs!: number;
|
|
107
|
+
protected shortQueryMaxLength!: number;
|
|
108
|
+
protected searchDebounced!: DebouncedFunc<(searchString?: string) => R>;
|
|
69
109
|
abstract readonly type: SearchSourceType;
|
|
70
110
|
|
|
71
111
|
protected constructor(options?: SearchSourceOptions) {
|
|
@@ -77,8 +117,44 @@ abstract class BaseSearchSourceBase<T> implements ISearchSource<T> {
|
|
|
77
117
|
this.allowEmptySearchString = allowEmptySearchString;
|
|
78
118
|
this.resetOnNewSearchQuery = resetOnNewSearchQuery;
|
|
79
119
|
this.state = new StateStore<SearchSourceState<T>>(this.initialState);
|
|
120
|
+
// Field initializers run before this body, so setDebounceOptions is already
|
|
121
|
+
// assigned; it only captures getDebounceMs lazily.
|
|
122
|
+
this.setDebounceOptions(options ?? {});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
abstract executeQuery(newSearchString?: string): R;
|
|
126
|
+
|
|
127
|
+
setDebounceOptions = (options: DebounceOptions = {}) => {
|
|
128
|
+
const resolved = resolveDebounceOptions(options);
|
|
129
|
+
this.shortQueryDebounceMs = resolved.shortQueryDebounceMs;
|
|
130
|
+
this.longQueryDebounceMs = resolved.longQueryDebounceMs;
|
|
131
|
+
this.shortQueryMaxLength = resolved.shortQueryMaxLength;
|
|
132
|
+
this.searchDebounced = debounce(this.executeQuery.bind(this), (searchString) =>
|
|
133
|
+
this.getDebounceMs(searchString),
|
|
134
|
+
);
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Short queries match too much and are slow server-side, so they get a longer debounce.
|
|
139
|
+
* Resolved per call, which is what lets the interval switch as the user keeps typing.
|
|
140
|
+
*/
|
|
141
|
+
protected getDebounceMs = (searchString?: string) => {
|
|
142
|
+
const { length } = searchString ?? this.searchQuery;
|
|
143
|
+
return length <= this.shortQueryMaxLength
|
|
144
|
+
? this.shortQueryDebounceMs
|
|
145
|
+
: this.longQueryDebounceMs;
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* A new search query preempts an in-flight one; pagination waits for it to finish
|
|
150
|
+
* and needs a next page to fetch.
|
|
151
|
+
*/
|
|
152
|
+
protected canDispatchQuery(hasNewSearchQuery: boolean) {
|
|
153
|
+
return hasNewSearchQuery || (!this.isLoading && this.hasNext);
|
|
80
154
|
}
|
|
81
155
|
|
|
156
|
+
search = (searchQuery?: string) => this.searchDebounced(searchQuery);
|
|
157
|
+
|
|
82
158
|
get lastQueryError() {
|
|
83
159
|
return this.state.getLatestValue().lastQueryError;
|
|
84
160
|
}
|
|
@@ -99,7 +175,7 @@ abstract class BaseSearchSourceBase<T> implements ISearchSource<T> {
|
|
|
99
175
|
return this.state.getLatestValue().isLoading;
|
|
100
176
|
}
|
|
101
177
|
|
|
102
|
-
get initialState() {
|
|
178
|
+
get initialState(): SearchSourceState<T> {
|
|
103
179
|
return {
|
|
104
180
|
hasNext: true,
|
|
105
181
|
isActive: false,
|
|
@@ -143,8 +219,7 @@ abstract class BaseSearchSourceBase<T> implements ISearchSource<T> {
|
|
|
143
219
|
const searchString = newSearchString ?? this.searchQuery;
|
|
144
220
|
return !!(
|
|
145
221
|
this.isActive &&
|
|
146
|
-
|
|
147
|
-
(this.hasNext || hasNewSearchQuery) &&
|
|
222
|
+
this.canDispatchQuery(hasNewSearchQuery) &&
|
|
148
223
|
(this.allowEmptySearchString || searchString)
|
|
149
224
|
);
|
|
150
225
|
};
|
|
@@ -217,34 +292,48 @@ abstract class BaseSearchSourceBase<T> implements ISearchSource<T> {
|
|
|
217
292
|
}
|
|
218
293
|
|
|
219
294
|
export abstract class BaseSearchSource<T>
|
|
220
|
-
extends BaseSearchSourceBase<T
|
|
295
|
+
extends BaseSearchSourceBase<T, Promise<void>>
|
|
221
296
|
implements SearchSource<T>
|
|
222
297
|
{
|
|
223
|
-
|
|
298
|
+
/** Aborts the in-flight request, if any, once a newer query is dispatched. */
|
|
299
|
+
protected queryAbortController: AbortController | null = null;
|
|
224
300
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
protected abstract query(searchQuery: string): Promise<QueryReturnValue<T>>;
|
|
301
|
+
protected abstract query(
|
|
302
|
+
searchQuery: string,
|
|
303
|
+
options?: SearchQueryOptions,
|
|
304
|
+
): Promise<QueryReturnValue<T>>;
|
|
232
305
|
|
|
233
306
|
protected abstract filterQueryResults(items: T[]): T[] | Promise<T[]>;
|
|
234
307
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
308
|
+
/** Aborts the in-flight request, if any. Returns true when one was aborted. */
|
|
309
|
+
protected abortInFlightQuery() {
|
|
310
|
+
if (!this.queryAbortController) return false;
|
|
311
|
+
this.queryAbortController.abort();
|
|
312
|
+
this.queryAbortController = null;
|
|
313
|
+
return true;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
resetState() {
|
|
317
|
+
// otherwise an in-flight response would repopulate the state we just cleared
|
|
318
|
+
this.abortInFlightQuery();
|
|
319
|
+
super.resetState();
|
|
320
|
+
}
|
|
238
321
|
|
|
239
322
|
async executeQuery(newSearchString?: string) {
|
|
323
|
+
// Checked before anything is dispatched, so that the abort below always has a
|
|
324
|
+
// successor that will clear isLoading.
|
|
240
325
|
if (!this.canExecuteQuery(newSearchString)) return;
|
|
241
326
|
|
|
327
|
+
// cancel the previous request before dispatching the new one
|
|
328
|
+
this.abortInFlightQuery();
|
|
329
|
+
const { signal } = (this.queryAbortController = new AbortController());
|
|
330
|
+
|
|
242
331
|
const { hasNewSearchQuery, searchString } =
|
|
243
332
|
this.prepareStateForQuery(newSearchString);
|
|
244
333
|
|
|
245
334
|
let stateUpdate: Partial<SearchSourceState<T>> = {};
|
|
246
335
|
try {
|
|
247
|
-
const results = await this.query(searchString);
|
|
336
|
+
const results = await this.query(searchString, { signal });
|
|
248
337
|
if (!results) return;
|
|
249
338
|
|
|
250
339
|
const { items } = results;
|
|
@@ -256,37 +345,34 @@ export abstract class BaseSearchSource<T>
|
|
|
256
345
|
stateUpdate.hasNext = false;
|
|
257
346
|
}
|
|
258
347
|
} finally {
|
|
259
|
-
|
|
348
|
+
// A newer query owns the state now - publishing here would clobber it with a
|
|
349
|
+
// stale result (and with the abort error).
|
|
350
|
+
if (!signal.aborted) {
|
|
351
|
+
this.state.next(this.getStateAfterQuery(stateUpdate, hasNewSearchQuery));
|
|
352
|
+
}
|
|
260
353
|
}
|
|
261
354
|
}
|
|
262
355
|
|
|
263
|
-
search = (searchQuery?: string) => this.searchDebounced(searchQuery);
|
|
264
|
-
|
|
265
356
|
cancelScheduledQuery() {
|
|
266
357
|
this.searchDebounced.cancel();
|
|
358
|
+
// Nothing will dispatch a successor query, so release the loading state the
|
|
359
|
+
// aborted query owned - its own finally block skips the state write.
|
|
360
|
+
if (this.abortInFlightQuery() && this.isLoading) {
|
|
361
|
+
this.state.partialNext({ isLoading: false });
|
|
362
|
+
}
|
|
267
363
|
}
|
|
268
364
|
}
|
|
269
365
|
|
|
366
|
+
// Queries are resolved locally, so there is nothing to cancel here - only the
|
|
367
|
+
// dynamic debounce applies.
|
|
270
368
|
export abstract class BaseSearchSourceSync<T>
|
|
271
|
-
extends BaseSearchSourceBase<T>
|
|
369
|
+
extends BaseSearchSourceBase<T, void>
|
|
272
370
|
implements SearchSourceSync<T>
|
|
273
371
|
{
|
|
274
|
-
protected searchDebounced!: DebouncedExecQueryFunction;
|
|
275
|
-
|
|
276
|
-
constructor(options?: SearchSourceOptions) {
|
|
277
|
-
const { debounceMs } = { ...DEFAULT_SEARCH_SOURCE_OPTIONS, ...options };
|
|
278
|
-
super(options);
|
|
279
|
-
this.setDebounceOptions({ debounceMs });
|
|
280
|
-
}
|
|
281
|
-
|
|
282
372
|
protected abstract query(searchQuery: string): QueryReturnValue<T>;
|
|
283
373
|
|
|
284
374
|
protected abstract filterQueryResults(items: T[]): T[];
|
|
285
375
|
|
|
286
|
-
setDebounceOptions = ({ debounceMs }: DebounceOptions) => {
|
|
287
|
-
this.searchDebounced = debounce(this.executeQuery.bind(this), debounceMs);
|
|
288
|
-
};
|
|
289
|
-
|
|
290
376
|
executeQuery(newSearchString?: string) {
|
|
291
377
|
if (!this.canExecuteQuery(newSearchString)) return;
|
|
292
378
|
|
|
@@ -311,8 +397,6 @@ export abstract class BaseSearchSourceSync<T>
|
|
|
311
397
|
}
|
|
312
398
|
}
|
|
313
399
|
|
|
314
|
-
search = (searchQuery?: string) => this.searchDebounced(searchQuery);
|
|
315
|
-
|
|
316
400
|
cancelScheduledQuery() {
|
|
317
401
|
this.searchDebounced.cancel();
|
|
318
402
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { BaseSearchSource } from './BaseSearchSource';
|
|
1
|
+
import { BaseSearchSource, type SearchQueryOptions } from './BaseSearchSource';
|
|
2
2
|
import { FilterBuilder, type FilterBuilderOptions } from '../pagination';
|
|
3
3
|
import type { Channel } from '../channel';
|
|
4
4
|
import type {
|
|
@@ -15,6 +15,13 @@ export type ChannelMemberSearchSourceFilterBuilderContext<
|
|
|
15
15
|
C extends CustomContext = CustomContext,
|
|
16
16
|
> = { searchQuery?: string } & C;
|
|
17
17
|
|
|
18
|
+
export type ChannelMemberSearchSourceOptions = SearchSourceOptions & {
|
|
19
|
+
/** Static base filters merged under the dynamically generated ones. */
|
|
20
|
+
filters?: MemberFilters;
|
|
21
|
+
sort?: MemberSort;
|
|
22
|
+
searchOptions?: Omit<QueryMembersOptions, 'limit' | 'offset'>;
|
|
23
|
+
};
|
|
24
|
+
|
|
18
25
|
export class ChannelMemberSearchSource<
|
|
19
26
|
TFilterContext extends CustomContext = CustomContext,
|
|
20
27
|
> extends BaseSearchSource<ChannelMemberResponse> {
|
|
@@ -30,14 +37,19 @@ export class ChannelMemberSearchSource<
|
|
|
30
37
|
|
|
31
38
|
constructor(
|
|
32
39
|
channel: Channel,
|
|
33
|
-
options?:
|
|
40
|
+
options?: ChannelMemberSearchSourceOptions,
|
|
34
41
|
filterBuilderOptions: FilterBuilderOptions<
|
|
35
42
|
MemberFilters,
|
|
36
43
|
ChannelMemberSearchSourceFilterBuilderContext<TFilterContext>
|
|
37
44
|
> = {},
|
|
38
45
|
) {
|
|
39
|
-
|
|
46
|
+
const { filters, sort, searchOptions, ...restOptions } = options || {};
|
|
47
|
+
// members are listed with an empty query, so the initial load must be allowed
|
|
48
|
+
super({ ...restOptions, allowEmptySearchString: true });
|
|
40
49
|
this.channel = channel;
|
|
50
|
+
this.filters = filters;
|
|
51
|
+
this.sort = sort;
|
|
52
|
+
this.searchOptions = searchOptions;
|
|
41
53
|
this.filterBuilder = new FilterBuilder<
|
|
42
54
|
MemberFilters,
|
|
43
55
|
ChannelMemberSearchSourceFilterBuilderContext<TFilterContext>
|
|
@@ -60,13 +72,7 @@ export class ChannelMemberSearchSource<
|
|
|
60
72
|
});
|
|
61
73
|
}
|
|
62
74
|
|
|
63
|
-
|
|
64
|
-
const hasNewSearchQuery = typeof newSearchString !== 'undefined';
|
|
65
|
-
|
|
66
|
-
return this.isActive && !this.isLoading && (this.hasNext || hasNewSearchQuery);
|
|
67
|
-
};
|
|
68
|
-
|
|
69
|
-
protected async query(searchQuery: string) {
|
|
75
|
+
protected async query(searchQuery: string, queryOptions: SearchQueryOptions = {}) {
|
|
70
76
|
const filters = this.filterBuilder.buildFilters({
|
|
71
77
|
baseFilters: this.filters,
|
|
72
78
|
context: {
|
|
@@ -75,7 +81,12 @@ export class ChannelMemberSearchSource<
|
|
|
75
81
|
});
|
|
76
82
|
const sort = this.sort ?? [];
|
|
77
83
|
const options = { ...this.searchOptions, limit: this.pageSize, offset: this.offset };
|
|
78
|
-
const { members } = await this.channel.queryMembers(
|
|
84
|
+
const { members } = await this.channel.queryMembers(
|
|
85
|
+
filters ?? {},
|
|
86
|
+
sort,
|
|
87
|
+
options,
|
|
88
|
+
queryOptions,
|
|
89
|
+
);
|
|
79
90
|
return { items: members };
|
|
80
91
|
}
|
|
81
92
|
|