@tuturuuu/ui 0.3.2 → 0.4.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.
@@ -1,5 +1,5 @@
1
1
  import type { ChatConversation } from '@tuturuuu/internal-api';
2
- import { usePathname, useSearchParams } from 'next/navigation';
2
+ import { usePathname, useRouter, useSearchParams } from 'next/navigation';
3
3
  import { useEffect, useState } from 'react';
4
4
  import { ChatSidebar } from './chat-sidebar';
5
5
  import { CreateConversationDialog } from './create-conversation-dialog';
@@ -8,6 +8,7 @@ import {
8
8
  useChatMessageSearch,
9
9
  useInfiniteChatConversations,
10
10
  } from './hooks';
11
+ import { type ChatDetailsTarget, replaceChatSelection } from './selection';
11
12
  import {
12
13
  CHAT_CONVERSATION_TYPE_FILTERS,
13
14
  type ChatConversationArchiveFilter,
@@ -16,6 +17,7 @@ import {
16
17
  filterChatConversationsByScope,
17
18
  getChatConversationTypesForScope,
18
19
  getChatSelectionStorageKey,
20
+ getChatSourceGroupStorageKey,
19
21
  normalizeChatConversationScope,
20
22
  resolveChatConversationSelection,
21
23
  } from './utils';
@@ -26,6 +28,7 @@ interface ChatSidebarPanelProps {
26
28
  createOpen?: boolean;
27
29
  currentUserId: string;
28
30
  defaultConversationScope?: ChatConversationScope;
31
+ enableRootIntegrations?: boolean;
29
32
  isCollapsed: boolean;
30
33
  onCreateOpenChange?: (open: boolean) => void;
31
34
  onSearchChange?: (value: string) => void;
@@ -40,6 +43,7 @@ export function ChatSidebarPanel({
40
43
  createOpen: controlledCreateOpen,
41
44
  currentUserId,
42
45
  defaultConversationScope = 'personal',
46
+ enableRootIntegrations,
43
47
  isCollapsed,
44
48
  onCreateOpenChange,
45
49
  onSearchChange,
@@ -48,6 +52,7 @@ export function ChatSidebarPanel({
48
52
  wsId,
49
53
  }: ChatSidebarPanelProps) {
50
54
  const pathname = usePathname();
55
+ const router = useRouter();
51
56
  const searchParams = useSearchParams();
52
57
  const [internalSearchValue, setInternalSearchValue] = useState('');
53
58
  const [internalCreateOpen, setInternalCreateOpen] = useState(false);
@@ -101,6 +106,15 @@ export function ChatSidebarPanel({
101
106
  wsId,
102
107
  conversationScope
103
108
  );
109
+ const sourceGroupStorageKey = getChatSourceGroupStorageKey(
110
+ wsId,
111
+ conversationScope
112
+ );
113
+ const requestedConversationPending = Boolean(
114
+ requestedConversationId &&
115
+ !scopedConversationIdList.includes(requestedConversationId) &&
116
+ conversationsQuery.isFetching
117
+ );
104
118
  const selectedConversationId = resolveChatConversationSelection({
105
119
  conversationIds: scopedConversationIdList,
106
120
  requestedConversationId,
@@ -116,37 +130,41 @@ export function ChatSidebarPanel({
116
130
  useEffect(() => {
117
131
  if (!selectedConversationId) return;
118
132
  if (!storedSelectionLoaded && !requestedConversationId) return;
133
+ if (requestedConversationPending) return;
119
134
 
120
135
  localStorage.setItem(selectionStorageKey, selectedConversationId);
121
136
  if (requestedConversationId === selectedConversationId) return;
122
137
 
123
- const nextParams = new URLSearchParams(searchParams.toString());
124
- nextParams.set('conversationId', selectedConversationId);
125
- const nextQuery = nextParams.toString();
126
- window.history.replaceState(
127
- null,
128
- '',
129
- nextQuery ? `${pathname}?${nextQuery}` : pathname
130
- );
138
+ replaceChatSelection({
139
+ conversationId: selectedConversationId,
140
+ pathname,
141
+ router,
142
+ searchParams,
143
+ storageKey: selectionStorageKey,
144
+ });
131
145
  }, [
132
146
  pathname,
133
147
  requestedConversationId,
148
+ router,
134
149
  searchParams,
135
150
  selectedConversationId,
136
151
  selectionStorageKey,
137
152
  storedSelectionLoaded,
153
+ requestedConversationPending,
138
154
  ]);
139
155
 
140
- function selectConversation(conversationId: string) {
141
- const nextParams = new URLSearchParams(searchParams.toString());
142
- nextParams.set('conversationId', conversationId);
143
- const nextQuery = nextParams.toString();
144
- window.history.replaceState(
145
- null,
146
- '',
147
- nextQuery ? `${pathname}?${nextQuery}` : pathname
148
- );
149
- localStorage.setItem(selectionStorageKey, conversationId);
156
+ function selectConversation(
157
+ conversationId: string,
158
+ details: ChatDetailsTarget = null
159
+ ) {
160
+ replaceChatSelection({
161
+ conversationId,
162
+ details,
163
+ pathname,
164
+ router,
165
+ searchParams,
166
+ storageKey: selectionStorageKey,
167
+ });
150
168
  closeOnMobile?.();
151
169
  }
152
170
 
@@ -176,6 +194,7 @@ export function ChatSidebarPanel({
176
194
  selectedConversationId={selectedConversationId}
177
195
  showControls={false}
178
196
  showTitle={false}
197
+ sourceGroupStorageKey={sourceGroupStorageKey}
179
198
  scope={conversationScope}
180
199
  />
181
200
  <CreateConversationDialog
@@ -183,7 +202,11 @@ export function ChatSidebarPanel({
183
202
  conversationScope={conversationScope}
184
203
  defaultType={getChatConversationTypesForScope(conversationScope)[0]}
185
204
  currentUserId={currentUserId}
205
+ enableRootIntegrations={enableRootIntegrations}
186
206
  onCreated={handleCreated}
207
+ onIntegrationCreated={(conversationId) =>
208
+ selectConversation(conversationId, 'agent')
209
+ }
187
210
  onOpenChange={setCreateOpen}
188
211
  open={createOpen}
189
212
  wsId={wsId}
@@ -0,0 +1,199 @@
1
+ import type { ChatConversation } from '@tuturuuu/internal-api';
2
+ import type { ChatConversationScope } from './utils';
3
+
4
+ export type ChatConversationSectionLabels = {
5
+ ai: string;
6
+ channel: string;
7
+ direct: string;
8
+ group: string;
9
+ };
10
+
11
+ export type ChatConversationSourceLabels = {
12
+ external: string;
13
+ zaloPersonal: string;
14
+ };
15
+
16
+ export interface ChatConversationSourceGroup {
17
+ conversations: ChatConversation[];
18
+ id: string;
19
+ label: string;
20
+ }
21
+
22
+ export interface ChatConversationSection {
23
+ conversations: ChatConversation[];
24
+ label: string | null;
25
+ sectionType: ChatConversation['type'];
26
+ sourceGroups: ChatConversationSourceGroup[];
27
+ }
28
+
29
+ const DEFAULT_SOURCE_LABELS = {
30
+ external: 'External source',
31
+ zaloPersonal: 'Zalo Personal',
32
+ } as const satisfies ChatConversationSourceLabels;
33
+
34
+ export function getChatConversationSections({
35
+ conversations,
36
+ labels,
37
+ scope,
38
+ sourceLabels = DEFAULT_SOURCE_LABELS,
39
+ }: {
40
+ conversations: ChatConversation[];
41
+ labels: ChatConversationSectionLabels;
42
+ scope?: ChatConversationScope;
43
+ sourceLabels?: ChatConversationSourceLabels;
44
+ }): ChatConversationSection[] {
45
+ if (scope === 'workspaces') {
46
+ return [
47
+ createChatConversationSection({
48
+ conversations: conversations.filter(
49
+ (conversation) => conversation.type === 'channel'
50
+ ),
51
+ label: labels.channel,
52
+ sectionType: 'channel',
53
+ sourceLabels,
54
+ }),
55
+ createChatConversationSection({
56
+ conversations: conversations.filter(
57
+ (conversation) => conversation.type === 'ai'
58
+ ),
59
+ label: labels.ai,
60
+ sectionType: 'ai',
61
+ sourceLabels,
62
+ }),
63
+ ];
64
+ }
65
+
66
+ if (scope === 'personal') {
67
+ return [
68
+ createChatConversationSection({
69
+ conversations: conversations.filter(
70
+ (conversation) => conversation.type === 'direct'
71
+ ),
72
+ label: labels.direct,
73
+ sectionType: 'direct',
74
+ sourceLabels,
75
+ }),
76
+ createChatConversationSection({
77
+ conversations: conversations.filter(
78
+ (conversation) => conversation.type === 'group'
79
+ ),
80
+ label: labels.group,
81
+ sectionType: 'group',
82
+ sourceLabels,
83
+ }),
84
+ createChatConversationSection({
85
+ conversations: conversations.filter(
86
+ (conversation) => conversation.type === 'channel'
87
+ ),
88
+ label: labels.channel,
89
+ sectionType: 'channel',
90
+ sourceLabels,
91
+ }),
92
+ createChatConversationSection({
93
+ conversations: conversations.filter(
94
+ (conversation) => conversation.type === 'ai'
95
+ ),
96
+ label: labels.ai,
97
+ sectionType: 'ai',
98
+ sourceLabels,
99
+ }),
100
+ ];
101
+ }
102
+
103
+ return [
104
+ createChatConversationSection({
105
+ conversations,
106
+ label: null,
107
+ sectionType: 'direct',
108
+ sourceLabels,
109
+ }),
110
+ ];
111
+ }
112
+
113
+ export function getChatConversationSourceGroups({
114
+ conversations,
115
+ labels = DEFAULT_SOURCE_LABELS,
116
+ }: {
117
+ conversations: ChatConversation[];
118
+ labels?: ChatConversationSourceLabels;
119
+ }) {
120
+ const groups = new Map<string, ChatConversationSourceGroup>();
121
+
122
+ for (const conversation of conversations) {
123
+ const sourceGroup = getChatConversationSourceGroup(conversation, labels);
124
+
125
+ if (!sourceGroup) continue;
126
+
127
+ const group = groups.get(sourceGroup.id);
128
+
129
+ if (group) {
130
+ group.conversations.push(conversation);
131
+ } else {
132
+ groups.set(sourceGroup.id, {
133
+ ...sourceGroup,
134
+ conversations: [conversation],
135
+ });
136
+ }
137
+ }
138
+
139
+ return [...groups.values()];
140
+ }
141
+
142
+ function createChatConversationSection({
143
+ conversations,
144
+ label,
145
+ sectionType,
146
+ sourceLabels,
147
+ }: {
148
+ conversations: ChatConversation[];
149
+ label: string | null;
150
+ sectionType: ChatConversation['type'];
151
+ sourceLabels: ChatConversationSourceLabels;
152
+ }): ChatConversationSection {
153
+ const sourceGroups = getChatConversationSourceGroups({
154
+ conversations,
155
+ labels: sourceLabels,
156
+ });
157
+ const sourceConversationIds = new Set(
158
+ sourceGroups.flatMap((group) =>
159
+ group.conversations.map((conversation) => conversation.id)
160
+ )
161
+ );
162
+
163
+ return {
164
+ conversations: conversations.filter(
165
+ (conversation) => !sourceConversationIds.has(conversation.id)
166
+ ),
167
+ label,
168
+ sectionType,
169
+ sourceGroups,
170
+ };
171
+ }
172
+
173
+ function getChatConversationSourceGroup(
174
+ conversation: ChatConversation,
175
+ labels: ChatConversationSourceLabels
176
+ ): Omit<ChatConversationSourceGroup, 'conversations'> | null {
177
+ const metadata = conversation.metadata ?? {};
178
+
179
+ if (metadata.source !== 'ai-agent-external-thread') return null;
180
+
181
+ const adapter = readString(metadata.adapter);
182
+
183
+ if (adapter !== 'zalo') return null;
184
+
185
+ const agentId = readString(metadata.agentId) ?? 'unknown-agent';
186
+ const channelId =
187
+ readString(metadata.channelId) ??
188
+ readString(metadata.externalChannelId) ??
189
+ 'unknown-channel';
190
+
191
+ return {
192
+ id: `external:${adapter}:${agentId}:${channelId}`,
193
+ label: labels.zaloPersonal,
194
+ };
195
+ }
196
+
197
+ function readString(value: unknown) {
198
+ return typeof value === 'string' && value.trim() ? value.trim() : null;
199
+ }
@@ -1,6 +1,5 @@
1
1
  'use client';
2
2
 
3
- import { useVirtualizer } from '@tanstack/react-virtual';
4
3
  import {
5
4
  Bot,
6
5
  Funnel,
@@ -13,7 +12,7 @@ import {
13
12
  import type { ChatConversation, ChatMessage } from '@tuturuuu/internal-api';
14
13
  import { cn } from '@tuturuuu/utils/format';
15
14
  import { useTranslations } from 'next-intl';
16
- import { type ReactNode, type UIEvent, useMemo, useRef, useState } from 'react';
15
+ import { type ReactNode, useState } from 'react';
17
16
  import { Button } from '../button';
18
17
  import {
19
18
  DropdownMenu,
@@ -24,12 +23,18 @@ import {
24
23
  DropdownMenuTrigger,
25
24
  } from '../dropdown-menu';
26
25
  import { Input } from '../input';
27
- import { ConversationRow, SearchResultList } from './chat-sidebar-items';
26
+ import { ConversationGroups } from './chat-sidebar-conversation-groups';
27
+ import { SearchResultList } from './chat-sidebar-items';
28
28
  import type {
29
29
  ChatConversationArchiveFilter,
30
30
  ChatConversationScope,
31
31
  } from './utils';
32
32
 
33
+ export {
34
+ getChatConversationSections,
35
+ getChatConversationSourceGroups,
36
+ } from './chat-sidebar-sections';
37
+
33
38
  interface ChatSidebarProps {
34
39
  actions?: ReactNode;
35
40
  archiveFilter?: ChatConversationArchiveFilter;
@@ -51,6 +56,7 @@ interface ChatSidebarProps {
51
56
  scope?: ChatConversationScope;
52
57
  showControls?: boolean;
53
58
  showTitle?: boolean;
59
+ sourceGroupStorageKey?: string | null;
54
60
  searchValue: string;
55
61
  selectedConversationId?: string | null;
56
62
  }
@@ -76,6 +82,7 @@ export function ChatSidebar({
76
82
  scope,
77
83
  showControls = true,
78
84
  showTitle = true,
85
+ sourceGroupStorageKey,
79
86
  searchValue,
80
87
  selectedConversationId,
81
88
  }: ChatSidebarProps) {
@@ -154,6 +161,7 @@ export function ChatSidebar({
154
161
  archiveFilter={archiveFilter}
155
162
  scope={scope}
156
163
  selectedConversationId={selectedConversationId}
164
+ sourceGroupStorageKey={sourceGroupStorageKey}
157
165
  />
158
166
  ) : (
159
167
  <div className="p-6 text-center">
@@ -171,254 +179,6 @@ export function ChatSidebar({
171
179
  );
172
180
  }
173
181
 
174
- type ConversationListItem =
175
- | { key: string; label: string; type: 'archive-label' }
176
- | {
177
- key: string;
178
- label: string;
179
- sectionType: ChatConversation['type'];
180
- type: 'group-label';
181
- }
182
- | { conversation: ChatConversation; key: string; type: 'conversation' }
183
- | { key: string; type: 'loader' };
184
-
185
- type ChatConversationSectionLabels = {
186
- ai: string;
187
- channel: string;
188
- direct: string;
189
- group: string;
190
- };
191
-
192
- export function getChatConversationSections({
193
- conversations,
194
- labels,
195
- scope,
196
- }: {
197
- conversations: ChatConversation[];
198
- labels: ChatConversationSectionLabels;
199
- scope?: ChatConversationScope;
200
- }) {
201
- if (scope === 'workspaces') {
202
- return [
203
- {
204
- conversations: conversations.filter(
205
- (conversation) => conversation.type === 'channel'
206
- ),
207
- label: labels.channel,
208
- sectionType: 'channel' as const,
209
- },
210
- {
211
- conversations: conversations.filter(
212
- (conversation) => conversation.type === 'ai'
213
- ),
214
- label: labels.ai,
215
- sectionType: 'ai' as const,
216
- },
217
- ];
218
- }
219
-
220
- if (scope === 'personal') {
221
- return [
222
- {
223
- conversations: conversations.filter(
224
- (conversation) => conversation.type === 'direct'
225
- ),
226
- label: labels.direct,
227
- sectionType: 'direct' as const,
228
- },
229
- {
230
- conversations: conversations.filter(
231
- (conversation) => conversation.type === 'group'
232
- ),
233
- label: labels.group,
234
- sectionType: 'group' as const,
235
- },
236
- {
237
- conversations: conversations.filter(
238
- (conversation) => conversation.type === 'ai'
239
- ),
240
- label: labels.ai,
241
- sectionType: 'ai' as const,
242
- },
243
- ];
244
- }
245
-
246
- return [
247
- {
248
- conversations,
249
- label: null,
250
- sectionType: 'direct' as const,
251
- },
252
- ];
253
- }
254
-
255
- function ConversationGroups({
256
- archiveFilter,
257
- conversations,
258
- currentUserId,
259
- hasMoreConversations,
260
- isFetchingMoreConversations,
261
- onArchiveConversation,
262
- onLoadMoreConversations,
263
- onPinConversation,
264
- onSelectConversation,
265
- scope,
266
- selectedConversationId,
267
- }: {
268
- archiveFilter: ChatConversationArchiveFilter;
269
- conversations: ChatConversation[];
270
- currentUserId: string;
271
- hasMoreConversations?: boolean;
272
- isFetchingMoreConversations?: boolean;
273
- onArchiveConversation?: (conversationId: string) => void;
274
- onLoadMoreConversations?: () => Promise<unknown> | undefined;
275
- onPinConversation?: (conversationId: string, pinned: boolean) => void;
276
- onSelectConversation: (conversationId: string) => void;
277
- scope?: ChatConversationScope;
278
- selectedConversationId?: string | null;
279
- }) {
280
- const t = useTranslations('chat');
281
- const parentRef = useRef<HTMLDivElement | null>(null);
282
- const groups = useMemo(
283
- () =>
284
- getChatConversationSections({
285
- conversations,
286
- labels: {
287
- ai: t('ai_agents'),
288
- channel: t('channels'),
289
- direct: t('direct_messages'),
290
- group: t('groups'),
291
- },
292
- scope,
293
- }),
294
- [conversations, scope, t]
295
- );
296
- const items = useMemo<ConversationListItem[]>(() => {
297
- const next: ConversationListItem[] = [];
298
- if (archiveFilter !== 'active') {
299
- next.push({
300
- key: 'archive-label',
301
- label:
302
- archiveFilter === 'archived'
303
- ? t('showing_archived_chats')
304
- : t('showing_all_chats'),
305
- type: 'archive-label',
306
- });
307
- }
308
-
309
- for (const [index, group] of groups.entries()) {
310
- if (group.conversations.length === 0) continue;
311
- if (group.label) {
312
- next.push({
313
- key: `group-${group.label}-${index}`,
314
- label: group.label,
315
- sectionType: group.sectionType,
316
- type: 'group-label',
317
- });
318
- }
319
-
320
- for (const conversation of group.conversations) {
321
- next.push({
322
- conversation,
323
- key: conversation.id,
324
- type: 'conversation',
325
- });
326
- }
327
- }
328
-
329
- if (hasMoreConversations) next.push({ key: 'loader', type: 'loader' });
330
- return next;
331
- }, [archiveFilter, groups, hasMoreConversations, t]);
332
-
333
- const virtualizer = useVirtualizer({
334
- count: items.length,
335
- estimateSize: (index) => {
336
- const item = items[index];
337
- if (item?.type === 'conversation') return 36;
338
- if (item?.type === 'loader') return 44;
339
- return 30;
340
- },
341
- getItemKey: (index) => items[index]?.key ?? index,
342
- getScrollElement: () => parentRef.current,
343
- overscan: 8,
344
- });
345
- const virtualItems = virtualizer.getVirtualItems();
346
-
347
- function maybeLoadMore(event: UIEvent<HTMLDivElement>) {
348
- if (!(hasMoreConversations && onLoadMoreConversations)) return;
349
- if (isFetchingMoreConversations) return;
350
-
351
- const target = event.currentTarget;
352
- const distanceToEnd =
353
- target.scrollHeight - target.scrollTop - target.clientHeight;
354
- if (distanceToEnd < 180) {
355
- void onLoadMoreConversations();
356
- }
357
- }
358
-
359
- return (
360
- <div
361
- className="h-full overflow-y-auto overflow-x-hidden overscroll-contain p-2"
362
- onScroll={maybeLoadMore}
363
- ref={parentRef}
364
- >
365
- <div
366
- className="relative"
367
- style={{ height: `${virtualizer.getTotalSize()}px` }}
368
- >
369
- {virtualItems.map((virtualItem) => {
370
- const item = items[virtualItem.index];
371
- if (!item) return null;
372
-
373
- return (
374
- <div
375
- className="absolute inset-x-0 top-0"
376
- data-index={virtualItem.index}
377
- key={virtualItem.key}
378
- ref={virtualizer.measureElement}
379
- style={{ transform: `translateY(${virtualItem.start}px)` }}
380
- >
381
- {item.type === 'archive-label' ? (
382
- <p className="px-2 py-1 text-muted-foreground text-xs">
383
- {item.label}
384
- </p>
385
- ) : item.type === 'group-label' ? (
386
- <h3 className="flex items-center gap-1.5 px-2 py-1.5 font-medium text-muted-foreground text-xs uppercase">
387
- <ConversationSectionIcon type={item.sectionType} />
388
- {item.label}
389
- </h3>
390
- ) : item.type === 'loader' ? (
391
- <div className="flex items-center justify-center py-2 text-muted-foreground text-xs">
392
- <LoaderCircle className="mr-2 size-3.5 animate-spin" />
393
- {t('loading_conversations')}
394
- </div>
395
- ) : (
396
- <ConversationRow
397
- conversation={item.conversation}
398
- currentUserId={currentUserId}
399
- isSelected={item.conversation.id === selectedConversationId}
400
- onArchiveConversation={onArchiveConversation}
401
- onPinConversation={onPinConversation}
402
- onSelectConversation={onSelectConversation}
403
- />
404
- )}
405
- </div>
406
- );
407
- })}
408
- </div>
409
- </div>
410
- );
411
- }
412
-
413
- function ConversationSectionIcon({ type }: { type: ChatConversation['type'] }) {
414
- const className = 'size-3.5 shrink-0';
415
-
416
- if (type === 'channel') return <Hash className={className} />;
417
- if (type === 'ai') return <Bot className={className} />;
418
- if (type === 'group') return <Users className={className} />;
419
- return <MessageCircle className={className} />;
420
- }
421
-
422
182
  export function ChatConversationFilterMenu({
423
183
  archiveFilter,
424
184
  className,
@@ -181,6 +181,11 @@ describe('chat utils', () => {
181
181
  const direct = conversation({ id: 'direct-1', type: 'direct' });
182
182
  const group = conversation({ id: 'group-1', type: 'group' });
183
183
  const channel = conversation({ id: 'channel-1', type: 'channel' });
184
+ const personalChannel = conversation({
185
+ id: 'personal-channel-1',
186
+ metadata: { scope: 'personal' },
187
+ type: 'channel',
188
+ });
184
189
  const ai = conversation({ id: 'ai-1', type: 'ai' });
185
190
 
186
191
  expect(normalizeChatConversationScope('workspaces')).toBe('workspaces');
@@ -188,6 +193,7 @@ describe('chat utils', () => {
188
193
  expect(getChatConversationTypesForScope('personal')).toEqual([
189
194
  'direct',
190
195
  'group',
196
+ 'channel',
191
197
  'ai',
192
198
  ]);
193
199
  expect(getChatConversationTypesForScope('workspaces')).toEqual([
@@ -206,6 +212,7 @@ describe('chat utils', () => {
206
212
  [
207
213
  direct,
208
214
  group,
215
+ personalChannel,
209
216
  channel,
210
217
  conversation({
211
218
  id: 'ai-chat-1',
@@ -220,7 +227,13 @@ describe('chat utils', () => {
220
227
  ],
221
228
  'personal'
222
229
  ).map((item) => item.id)
223
- ).toEqual(['direct-1', 'group-1', 'ai-chat-1', 'personal-ai-1']);
230
+ ).toEqual([
231
+ 'direct-1',
232
+ 'group-1',
233
+ 'personal-channel-1',
234
+ 'ai-chat-1',
235
+ 'personal-ai-1',
236
+ ]);
224
237
  expect(
225
238
  filterChatConversationsByScope(
226
239
  [