@tuturuuu/ui 0.23.0 → 0.25.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/CHANGELOG.md +43 -0
- package/biome.json +1 -1
- package/package.json +78 -78
- package/src/components/ui/calendar-app/components/require-workspace-timezone-dialog.test.tsx +121 -0
- package/src/components/ui/calendar-app/components/require-workspace-timezone-dialog.tsx +37 -4
- package/src/components/ui/chat/ai-message-markdown.tsx +6 -1
- package/src/components/ui/chat/chat-external-context-sidebar.tsx +113 -0
- package/src/components/ui/chat/chat-sidebar-conversation-groups.tsx +5 -1
- package/src/components/ui/chat/chat-sidebar-groups.test.ts +33 -0
- package/src/components/ui/chat/chat-sidebar-items.tsx +89 -5
- package/src/components/ui/chat/chat-sidebar-panel.test.tsx +29 -1
- package/src/components/ui/chat/chat-sidebar-panel.tsx +4 -3
- package/src/components/ui/chat/chat-sidebar-sections.ts +24 -10
- package/src/components/ui/chat/chat-sidebar.tsx +3 -0
- package/src/components/ui/chat/chat-utils.test.ts +173 -0
- package/src/components/ui/chat/chat-workspace-header.tsx +11 -5
- package/src/components/ui/chat/chat-workspace.tsx +105 -28
- package/src/components/ui/chat/hooks-conversations.ts +9 -1
- package/src/components/ui/chat/hooks-messages.ts +1 -0
- package/src/components/ui/chat/hooks-realtime.ts +93 -4
- package/src/components/ui/chat/message-bubble.tsx +49 -4
- package/src/components/ui/chat/message-composer.tsx +29 -23
- package/src/components/ui/chat/message-list.tsx +5 -3
- package/src/components/ui/chat/message-markdown.tsx +4 -1
- package/src/components/ui/chat/query-keys.ts +7 -1
- package/src/components/ui/chat/selection.ts +1 -1
- package/src/components/ui/chat/utils.ts +126 -3
- package/src/components/ui/custom/__tests__/workspace-select-helpers.test.ts +72 -0
- package/src/components/ui/custom/onboarding-settings-panel.tsx +131 -0
- package/src/components/ui/custom/settings-dialog-shell.tsx +55 -18
- package/src/components/ui/custom/workspace-select-helpers.ts +50 -0
- package/src/components/ui/custom/workspace-select.tsx +37 -38
- package/src/components/ui/finance/analytics/analytics-page.tsx +10 -0
- package/src/components/ui/finance/analytics/inventory-provider-summary.tsx +110 -0
- package/src/components/ui/finance/finance-overview-metrics.tsx +49 -0
- package/src/components/ui/finance/transactions/inventory-reconciliation-entry-row.tsx +122 -0
- package/src/components/ui/finance/transactions/inventory-reconciliation-panel.tsx +407 -0
- package/src/components/ui/finance/transactions/transaction-card.tsx +15 -0
- package/src/components/ui/finance/transactions/transaction-edit-dialog.test.tsx +31 -0
- package/src/components/ui/finance/transactions/transaction-edit-dialog.tsx +26 -4
- package/src/components/ui/finance/transactions/transactionId/transaction-details-client-page.tsx +22 -0
- package/src/components/ui/finance/transactions/transactions-page.tsx +8 -0
- package/src/components/ui/finance/wallets/walletId/inventory-wallet-contribution.tsx +84 -0
- package/src/components/ui/finance/wallets/walletId/wallet-details-page.test.tsx +4 -0
- package/src/components/ui/finance/wallets/walletId/wallet-details-page.tsx +9 -0
- package/src/components/ui/legacy/meet/create-plan-dialog.tsx +27 -10
- package/src/components/ui/legacy/meet/edit-plan-dialog.tsx +90 -26
- package/src/components/ui/legacy/meet/form.tsx +25 -1
- package/src/components/ui/legacy/meet/lib/meet-ics.test.ts +40 -0
- package/src/components/ui/legacy/meet/lib/meet-ics.ts +50 -0
- package/src/components/ui/legacy/meet/lib/meet-insights.test.ts +76 -0
- package/src/components/ui/legacy/meet/lib/meet-insights.ts +219 -0
- package/src/components/ui/legacy/meet/lib/meet-timezone.test.ts +53 -0
- package/src/components/ui/legacy/meet/lib/meet-timezone.ts +45 -0
- package/src/components/ui/legacy/meet/page.tsx +1 -0
- package/src/components/ui/legacy/meet/planId/agenda-details.tsx +46 -23
- package/src/components/ui/legacy/meet/planId/copy-link-button.tsx +24 -197
- package/src/components/ui/legacy/meet/planId/date-planner.tsx +3 -28
- package/src/components/ui/legacy/meet/planId/meet-insights-panel.tsx +135 -0
- package/src/components/ui/legacy/meet/planId/meet-plan-live-root.tsx +41 -0
- package/src/components/ui/legacy/meet/planId/meet-query.ts +26 -0
- package/src/components/ui/legacy/meet/planId/meet-suggestions-panel.tsx +463 -0
- package/src/components/ui/legacy/meet/planId/page.tsx +58 -18
- package/src/components/ui/legacy/meet/planId/plan-details-client.tsx +154 -140
- package/src/components/ui/legacy/meet/planId/selectable-day-time.tsx +88 -56
- package/src/components/ui/legacy/meet/planId/show-qr-button.tsx +3 -5
- package/src/components/ui/legacy/meet/planId/unified-availability.tsx +36 -13
- package/src/components/ui/legacy/meet/planId/utility-buttons.tsx +3 -24
- package/src/components/ui/legacy/meet/plans-grid.tsx +1 -1
- package/src/components/ui/legacy/meet/plans-list-view.tsx +1 -1
- package/src/components/ui/nuqs-adapter.tsx +1 -0
- package/src/hooks/time-blocking-provider.tsx +127 -211
|
@@ -115,4 +115,37 @@ describe('chat sidebar conversation sections', () => {
|
|
|
115
115
|
],
|
|
116
116
|
});
|
|
117
117
|
});
|
|
118
|
+
|
|
119
|
+
it('groups connected-site conversations without exposing connector identity', () => {
|
|
120
|
+
const sections = getChatConversationSections({
|
|
121
|
+
conversations: [
|
|
122
|
+
conversation('channel', 'site-thread-1', { externalChat: true }),
|
|
123
|
+
conversation('channel', 'site-thread-2', { externalChat: true }),
|
|
124
|
+
],
|
|
125
|
+
labels: {
|
|
126
|
+
ai: 'AI agents',
|
|
127
|
+
channel: 'Channels',
|
|
128
|
+
direct: 'Direct messages',
|
|
129
|
+
group: 'Groups',
|
|
130
|
+
},
|
|
131
|
+
scope: 'external',
|
|
132
|
+
sourceLabels: {
|
|
133
|
+
external: 'Connected sites',
|
|
134
|
+
zaloPersonal: 'Zalo Personal',
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
expect(sections).toMatchObject([
|
|
139
|
+
{
|
|
140
|
+
conversations: [],
|
|
141
|
+
sourceGroups: [
|
|
142
|
+
{
|
|
143
|
+
conversations: [{ id: 'site-thread-1' }, { id: 'site-thread-2' }],
|
|
144
|
+
id: 'external:connected-sites',
|
|
145
|
+
label: 'Connected sites',
|
|
146
|
+
},
|
|
147
|
+
],
|
|
148
|
+
},
|
|
149
|
+
]);
|
|
150
|
+
});
|
|
118
151
|
});
|
|
@@ -3,14 +3,20 @@
|
|
|
3
3
|
import { Archive, Pin, PinOff } from '@tuturuuu/icons';
|
|
4
4
|
import type { ChatConversation, ChatMessage } from '@tuturuuu/internal-api';
|
|
5
5
|
import { cn } from '@tuturuuu/utils/format';
|
|
6
|
-
import { useTranslations } from 'next-intl';
|
|
6
|
+
import { useLocale, useTranslations } from 'next-intl';
|
|
7
7
|
import { Button } from '../button';
|
|
8
8
|
import { Tooltip, TooltipContent, TooltipTrigger } from '../tooltip';
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
getChatConversationQueueDetails,
|
|
11
|
+
getChatMessageSenderLabel,
|
|
12
|
+
getConversationTitle,
|
|
13
|
+
isChatConversationPinned,
|
|
14
|
+
} from './utils';
|
|
10
15
|
|
|
11
16
|
export function ConversationRow({
|
|
12
17
|
conversation,
|
|
13
18
|
currentUserId,
|
|
19
|
+
isOnline,
|
|
14
20
|
isSelected,
|
|
15
21
|
onArchiveConversation,
|
|
16
22
|
onPinConversation,
|
|
@@ -18,12 +24,14 @@ export function ConversationRow({
|
|
|
18
24
|
}: {
|
|
19
25
|
conversation: ChatConversation;
|
|
20
26
|
currentUserId: string;
|
|
27
|
+
isOnline?: boolean;
|
|
21
28
|
isSelected: boolean;
|
|
22
29
|
onArchiveConversation?: (conversationId: string) => void;
|
|
23
30
|
onPinConversation?: (conversationId: string, pinned: boolean) => void;
|
|
24
31
|
onSelectConversation: (conversationId: string) => void;
|
|
25
32
|
}) {
|
|
26
33
|
const t = useTranslations('chat');
|
|
34
|
+
const locale = useLocale();
|
|
27
35
|
const title = getConversationTitle(conversation, currentUserId, {
|
|
28
36
|
ai: t('assistant_name'),
|
|
29
37
|
channel: t('untitled_channel'),
|
|
@@ -32,6 +40,8 @@ export function ConversationRow({
|
|
|
32
40
|
group: t('group_chat'),
|
|
33
41
|
});
|
|
34
42
|
const pinned = isChatConversationPinned(conversation, currentUserId);
|
|
43
|
+
const isExternal = conversation.metadata.externalChat === true;
|
|
44
|
+
const queueDetails = getChatConversationQueueDetails(conversation);
|
|
35
45
|
|
|
36
46
|
return (
|
|
37
47
|
<div
|
|
@@ -45,9 +55,39 @@ export function ConversationRow({
|
|
|
45
55
|
onClick={() => onSelectConversation(conversation.id)}
|
|
46
56
|
type="button"
|
|
47
57
|
>
|
|
48
|
-
<span className="
|
|
49
|
-
|
|
58
|
+
<span className="flex min-w-0 items-center justify-between gap-2 text-sm leading-5">
|
|
59
|
+
<span className="flex min-w-0 items-center gap-2 font-medium">
|
|
60
|
+
{isOnline ? (
|
|
61
|
+
<span
|
|
62
|
+
aria-label={t('online')}
|
|
63
|
+
className="size-2 shrink-0 rounded-full bg-primary"
|
|
64
|
+
role="status"
|
|
65
|
+
/>
|
|
66
|
+
) : null}
|
|
67
|
+
<span className="min-w-0 truncate">{title}</span>
|
|
68
|
+
</span>
|
|
69
|
+
{isExternal ? (
|
|
70
|
+
<span className="shrink-0 text-[11px] text-muted-foreground">
|
|
71
|
+
{formatQueueTimestamp(queueDetails.timestamp, locale)}
|
|
72
|
+
</span>
|
|
73
|
+
) : null}
|
|
50
74
|
</span>
|
|
75
|
+
{isExternal ? (
|
|
76
|
+
<>
|
|
77
|
+
<span className="mt-0.5 block truncate text-muted-foreground text-xs leading-4">
|
|
78
|
+
{queueDetails.preview ?? t('no_messages_yet')}
|
|
79
|
+
</span>
|
|
80
|
+
<span className="mt-0.5 flex min-w-0 items-center gap-1.5 text-[11px] text-muted-foreground leading-4">
|
|
81
|
+
<DeliveryStateDot
|
|
82
|
+
label={t(`delivery_${queueDetails.deliveryState ?? 'sent'}`)}
|
|
83
|
+
state={queueDetails.deliveryState ?? 'sent'}
|
|
84
|
+
/>
|
|
85
|
+
<span className="truncate">
|
|
86
|
+
{queueDetails.phone ?? t('external_sender')}
|
|
87
|
+
</span>
|
|
88
|
+
</span>
|
|
89
|
+
</>
|
|
90
|
+
) : null}
|
|
51
91
|
</button>
|
|
52
92
|
<ConversationUnreadState unreadCount={conversation.unreadCount} />
|
|
53
93
|
<ConversationQuickActions
|
|
@@ -61,6 +101,46 @@ export function ConversationRow({
|
|
|
61
101
|
);
|
|
62
102
|
}
|
|
63
103
|
|
|
104
|
+
function DeliveryStateDot({
|
|
105
|
+
label,
|
|
106
|
+
state,
|
|
107
|
+
}: {
|
|
108
|
+
label: string;
|
|
109
|
+
state: 'deleted' | 'failed' | 'seen' | 'sending' | 'sent';
|
|
110
|
+
}) {
|
|
111
|
+
return (
|
|
112
|
+
<span
|
|
113
|
+
aria-label={label}
|
|
114
|
+
className={cn(
|
|
115
|
+
'size-1.5 shrink-0 rounded-full',
|
|
116
|
+
state === 'failed' && 'bg-destructive',
|
|
117
|
+
state === 'seen' && 'bg-primary',
|
|
118
|
+
state === 'sending' && 'bg-muted-foreground',
|
|
119
|
+
state === 'sent' && 'bg-foreground/60',
|
|
120
|
+
state === 'deleted' && 'border border-muted-foreground'
|
|
121
|
+
)}
|
|
122
|
+
role="status"
|
|
123
|
+
title={label}
|
|
124
|
+
/>
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function formatQueueTimestamp(value: string, locale: string) {
|
|
129
|
+
const date = new Date(value);
|
|
130
|
+
if (!Number.isFinite(date.getTime())) return '';
|
|
131
|
+
const today = new Date();
|
|
132
|
+
const sameDay =
|
|
133
|
+
date.getFullYear() === today.getFullYear() &&
|
|
134
|
+
date.getMonth() === today.getMonth() &&
|
|
135
|
+
date.getDate() === today.getDate();
|
|
136
|
+
return new Intl.DateTimeFormat(
|
|
137
|
+
locale,
|
|
138
|
+
sameDay
|
|
139
|
+
? { hour: '2-digit', minute: '2-digit' }
|
|
140
|
+
: { day: 'numeric', month: 'short' }
|
|
141
|
+
).format(date);
|
|
142
|
+
}
|
|
143
|
+
|
|
64
144
|
export function SearchResultList({
|
|
65
145
|
messages,
|
|
66
146
|
onSelectConversation,
|
|
@@ -88,7 +168,11 @@ export function SearchResultList({
|
|
|
88
168
|
type="button"
|
|
89
169
|
>
|
|
90
170
|
<span className="block truncate font-medium text-sm">
|
|
91
|
-
{message
|
|
171
|
+
{getChatMessageSenderLabel(message, {
|
|
172
|
+
assistant: t('assistant_name'),
|
|
173
|
+
external: t('external_sender'),
|
|
174
|
+
unknown: t('unknown_sender'),
|
|
175
|
+
})}
|
|
92
176
|
</span>
|
|
93
177
|
<span className="mt-1 line-clamp-2 text-muted-foreground text-xs">
|
|
94
178
|
{message.content}
|
|
@@ -8,6 +8,7 @@ const mocks = vi.hoisted(() => ({
|
|
|
8
8
|
createDialogProps: null as Record<string, unknown> | null,
|
|
9
9
|
fetchNextPage: vi.fn(),
|
|
10
10
|
routerReplace: vi.fn(),
|
|
11
|
+
searchParams: 'scope=personal',
|
|
11
12
|
useChatMessageSearch: vi.fn(),
|
|
12
13
|
useInfiniteChatConversations: vi.fn(),
|
|
13
14
|
}));
|
|
@@ -21,7 +22,7 @@ vi.mock('next/navigation', () => ({
|
|
|
21
22
|
useRouter: () => ({
|
|
22
23
|
replace: mocks.routerReplace,
|
|
23
24
|
}),
|
|
24
|
-
useSearchParams: () => new URLSearchParams(
|
|
25
|
+
useSearchParams: () => new URLSearchParams(mocks.searchParams),
|
|
25
26
|
}));
|
|
26
27
|
|
|
27
28
|
vi.mock('./chat-sidebar', () => ({
|
|
@@ -80,6 +81,7 @@ describe('ChatSidebarPanel', () => {
|
|
|
80
81
|
vi.clearAllMocks();
|
|
81
82
|
mocks.chatSidebarProps = null;
|
|
82
83
|
mocks.createDialogProps = null;
|
|
84
|
+
mocks.searchParams = 'scope=personal';
|
|
83
85
|
window.localStorage.clear();
|
|
84
86
|
mocks.useChatMessageSearch.mockReturnValue({ data: [] });
|
|
85
87
|
mocks.useInfiniteChatConversations.mockReturnValue({
|
|
@@ -104,6 +106,7 @@ describe('ChatSidebarPanel', () => {
|
|
|
104
106
|
|
|
105
107
|
expect(mocks.useInfiniteChatConversations).toHaveBeenCalledWith({
|
|
106
108
|
archived: 'active',
|
|
109
|
+
scope: undefined,
|
|
107
110
|
wsId: 'personal',
|
|
108
111
|
});
|
|
109
112
|
expect(mocks.chatSidebarProps).toMatchObject({
|
|
@@ -120,6 +123,31 @@ describe('ChatSidebarPanel', () => {
|
|
|
120
123
|
expect(mocks.fetchNextPage).toHaveBeenCalledTimes(1);
|
|
121
124
|
});
|
|
122
125
|
|
|
126
|
+
it('requests server-scoped pages for the external inbox', () => {
|
|
127
|
+
mocks.searchParams = 'scope=external';
|
|
128
|
+
mocks.useInfiniteChatConversations.mockReturnValue({
|
|
129
|
+
data: { pages: [{ conversations: [], nextOffset: null }] },
|
|
130
|
+
fetchNextPage: mocks.fetchNextPage,
|
|
131
|
+
hasNextPage: false,
|
|
132
|
+
isFetchingNextPage: false,
|
|
133
|
+
isLoading: false,
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
render(
|
|
137
|
+
<ChatSidebarPanel
|
|
138
|
+
currentUserId="user-1"
|
|
139
|
+
isCollapsed={false}
|
|
140
|
+
wsId="personal"
|
|
141
|
+
/>
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
expect(mocks.useInfiniteChatConversations).toHaveBeenCalledWith({
|
|
145
|
+
archived: 'active',
|
|
146
|
+
scope: 'external',
|
|
147
|
+
wsId: 'personal',
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
123
151
|
it('updates Next router state when auto-selecting the first conversation', async () => {
|
|
124
152
|
render(
|
|
125
153
|
<ChatSidebarPanel
|
|
@@ -71,17 +71,18 @@ export function ChatSidebarPanel({
|
|
|
71
71
|
const setCreateOpen = onCreateOpenChange ?? setInternalCreateOpen;
|
|
72
72
|
const archiveFilter = controlledArchiveFilter ?? internalArchiveFilter;
|
|
73
73
|
const selectedTypes = controlledSelectedTypes ?? internalSelectedTypes;
|
|
74
|
+
const conversationScope = normalizeChatConversationScope(
|
|
75
|
+
searchParams.get('scope') ?? defaultConversationScope
|
|
76
|
+
);
|
|
74
77
|
const conversationsQuery = useInfiniteChatConversations({
|
|
75
78
|
archived: archiveFilter,
|
|
79
|
+
scope: conversationScope === 'external' ? 'external' : undefined,
|
|
76
80
|
wsId,
|
|
77
81
|
});
|
|
78
82
|
const searchQuery = useChatMessageSearch({
|
|
79
83
|
query: searchValue,
|
|
80
84
|
wsId,
|
|
81
85
|
});
|
|
82
|
-
const conversationScope = normalizeChatConversationScope(
|
|
83
|
-
searchParams.get('scope') ?? defaultConversationScope
|
|
84
|
-
);
|
|
85
86
|
const conversations = flattenChatConversationPages(conversationsQuery.data);
|
|
86
87
|
const scopeConversations = filterChatConversationsByScope(
|
|
87
88
|
conversations,
|
|
@@ -42,8 +42,8 @@ export function getChatConversationSections({
|
|
|
42
42
|
scope?: ChatConversationScope;
|
|
43
43
|
sourceLabels?: ChatConversationSourceLabels;
|
|
44
44
|
}): ChatConversationSection[] {
|
|
45
|
-
if (scope === 'workspaces') {
|
|
46
|
-
|
|
45
|
+
if (scope === 'external' || scope === 'workspaces') {
|
|
46
|
+
const sections = [
|
|
47
47
|
createChatConversationSection({
|
|
48
48
|
conversations: conversations.filter(
|
|
49
49
|
(conversation) => conversation.type === 'channel'
|
|
@@ -52,15 +52,22 @@ export function getChatConversationSections({
|
|
|
52
52
|
sectionType: 'channel',
|
|
53
53
|
sourceLabels,
|
|
54
54
|
}),
|
|
55
|
-
createChatConversationSection({
|
|
56
|
-
conversations: conversations.filter(
|
|
57
|
-
(conversation) => conversation.type === 'ai'
|
|
58
|
-
),
|
|
59
|
-
label: labels.ai,
|
|
60
|
-
sectionType: 'ai',
|
|
61
|
-
sourceLabels,
|
|
62
|
-
}),
|
|
63
55
|
];
|
|
56
|
+
|
|
57
|
+
if (scope === 'workspaces') {
|
|
58
|
+
sections.push(
|
|
59
|
+
createChatConversationSection({
|
|
60
|
+
conversations: conversations.filter(
|
|
61
|
+
(conversation) => conversation.type === 'ai'
|
|
62
|
+
),
|
|
63
|
+
label: labels.ai,
|
|
64
|
+
sectionType: 'ai',
|
|
65
|
+
sourceLabels,
|
|
66
|
+
})
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return sections;
|
|
64
71
|
}
|
|
65
72
|
|
|
66
73
|
if (scope === 'personal') {
|
|
@@ -176,6 +183,13 @@ function getChatConversationSourceGroup(
|
|
|
176
183
|
): Omit<ChatConversationSourceGroup, 'conversations'> | null {
|
|
177
184
|
const metadata = conversation.metadata ?? {};
|
|
178
185
|
|
|
186
|
+
if (metadata.externalChat === true) {
|
|
187
|
+
return {
|
|
188
|
+
id: 'external:connected-sites',
|
|
189
|
+
label: labels.external,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
179
193
|
if (metadata.source !== 'ai-agent-external-thread') return null;
|
|
180
194
|
|
|
181
195
|
const adapter = readString(metadata.adapter);
|
|
@@ -49,6 +49,7 @@ interface ChatSidebarProps {
|
|
|
49
49
|
isFetchingMoreConversations?: boolean;
|
|
50
50
|
onArchiveConversation?: (conversationId: string) => void;
|
|
51
51
|
onLoadMoreConversations?: () => Promise<unknown> | undefined;
|
|
52
|
+
onlineConversationIds?: ReadonlySet<string>;
|
|
52
53
|
onPinConversation?: (conversationId: string, pinned: boolean) => void;
|
|
53
54
|
onSearchChange: (value: string) => void;
|
|
54
55
|
onSelectConversation: (conversationId: string) => void;
|
|
@@ -75,6 +76,7 @@ export function ChatSidebar({
|
|
|
75
76
|
isFetchingMoreConversations,
|
|
76
77
|
onArchiveConversation,
|
|
77
78
|
onLoadMoreConversations,
|
|
79
|
+
onlineConversationIds,
|
|
78
80
|
onPinConversation,
|
|
79
81
|
onSearchChange,
|
|
80
82
|
onSelectConversation,
|
|
@@ -156,6 +158,7 @@ export function ChatSidebar({
|
|
|
156
158
|
isFetchingMoreConversations={isFetchingMoreConversations}
|
|
157
159
|
onArchiveConversation={onArchiveConversation}
|
|
158
160
|
onLoadMoreConversations={onLoadMoreConversations}
|
|
161
|
+
onlineConversationIds={onlineConversationIds}
|
|
159
162
|
onPinConversation={onPinConversation}
|
|
160
163
|
onSelectConversation={onSelectConversation}
|
|
161
164
|
archiveFilter={archiveFilter}
|
|
@@ -1,17 +1,22 @@
|
|
|
1
1
|
import type { ChatConversation, ChatMessage } from '@tuturuuu/internal-api';
|
|
2
2
|
import { describe, expect, it, vi } from 'vitest';
|
|
3
3
|
import {
|
|
4
|
+
canPersistChatReadState,
|
|
4
5
|
filterChatConversationsByScope,
|
|
5
6
|
formatChatRelativeTime,
|
|
6
7
|
formatFileSize,
|
|
8
|
+
getChatConversationDeliveryState,
|
|
9
|
+
getChatConversationQueueDetails,
|
|
7
10
|
getChatConversationTypesForScope,
|
|
8
11
|
getChatInitials,
|
|
12
|
+
getChatMessageSenderLabel,
|
|
9
13
|
getChatSelectionStorageKey,
|
|
10
14
|
getConversationTitle,
|
|
11
15
|
getLastMessagePreview,
|
|
12
16
|
isReadOnlyChatConversation,
|
|
13
17
|
normalizeChatConversationScope,
|
|
14
18
|
resolveChatConversationSelection,
|
|
19
|
+
resolvePendingChatSendRequest,
|
|
15
20
|
} from './utils';
|
|
16
21
|
|
|
17
22
|
const baseMessage: ChatMessage = {
|
|
@@ -53,6 +58,40 @@ function conversation(overrides: Partial<ChatConversation>): ChatConversation {
|
|
|
53
58
|
}
|
|
54
59
|
|
|
55
60
|
describe('chat utils', () => {
|
|
61
|
+
it('reuses a request id for the same failed send payload', () => {
|
|
62
|
+
const payload = { attachments: [], content: 'Retry me' };
|
|
63
|
+
const createRequestId = vi.fn(() => 'request-1');
|
|
64
|
+
const first = resolvePendingChatSendRequest(null, payload, createRequestId);
|
|
65
|
+
const retry = resolvePendingChatSendRequest(
|
|
66
|
+
first,
|
|
67
|
+
payload,
|
|
68
|
+
createRequestId
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
expect(retry).toBe(first);
|
|
72
|
+
expect(createRequestId).toHaveBeenCalledOnce();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('creates a new request id when the send payload changes', () => {
|
|
76
|
+
const createRequestId = vi
|
|
77
|
+
.fn<() => string>()
|
|
78
|
+
.mockReturnValueOnce('request-1')
|
|
79
|
+
.mockReturnValueOnce('request-2');
|
|
80
|
+
const first = resolvePendingChatSendRequest(
|
|
81
|
+
null,
|
|
82
|
+
{ attachments: [], content: 'First' },
|
|
83
|
+
createRequestId
|
|
84
|
+
);
|
|
85
|
+
const changed = resolvePendingChatSendRequest(
|
|
86
|
+
first,
|
|
87
|
+
{ attachments: [], content: 'Second' },
|
|
88
|
+
createRequestId
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
expect(changed.requestId).toBe('request-2');
|
|
92
|
+
expect(createRequestId).toHaveBeenCalledTimes(2);
|
|
93
|
+
});
|
|
94
|
+
|
|
56
95
|
it('derives compact initials for users and labels', () => {
|
|
57
96
|
expect(getChatInitials('Ada Lovelace')).toBe('AL');
|
|
58
97
|
expect(
|
|
@@ -177,6 +216,30 @@ describe('chat utils', () => {
|
|
|
177
216
|
).toBe(true);
|
|
178
217
|
});
|
|
179
218
|
|
|
219
|
+
it('allows external inbox viewers to persist read state on first open', () => {
|
|
220
|
+
expect(
|
|
221
|
+
canPersistChatReadState({
|
|
222
|
+
externalChat: true,
|
|
223
|
+
hasMembership: false,
|
|
224
|
+
readOnly: false,
|
|
225
|
+
})
|
|
226
|
+
).toBe(true);
|
|
227
|
+
expect(
|
|
228
|
+
canPersistChatReadState({
|
|
229
|
+
externalChat: false,
|
|
230
|
+
hasMembership: false,
|
|
231
|
+
readOnly: false,
|
|
232
|
+
})
|
|
233
|
+
).toBe(false);
|
|
234
|
+
expect(
|
|
235
|
+
canPersistChatReadState({
|
|
236
|
+
externalChat: true,
|
|
237
|
+
hasMembership: false,
|
|
238
|
+
readOnly: true,
|
|
239
|
+
})
|
|
240
|
+
).toBe(false);
|
|
241
|
+
});
|
|
242
|
+
|
|
180
243
|
it('splits personal and workspace conversation scopes', () => {
|
|
181
244
|
const direct = conversation({ id: 'direct-1', type: 'direct' });
|
|
182
245
|
const group = conversation({ id: 'group-1', type: 'group' });
|
|
@@ -187,9 +250,18 @@ describe('chat utils', () => {
|
|
|
187
250
|
type: 'channel',
|
|
188
251
|
});
|
|
189
252
|
const ai = conversation({ id: 'ai-1', type: 'ai' });
|
|
253
|
+
const external = conversation({
|
|
254
|
+
id: 'external-1',
|
|
255
|
+
metadata: { externalChat: true },
|
|
256
|
+
type: 'channel',
|
|
257
|
+
});
|
|
190
258
|
|
|
259
|
+
expect(normalizeChatConversationScope('external')).toBe('external');
|
|
191
260
|
expect(normalizeChatConversationScope('workspaces')).toBe('workspaces');
|
|
192
261
|
expect(normalizeChatConversationScope('unknown')).toBe('personal');
|
|
262
|
+
expect(normalizeChatConversationScope('unknown', 'external')).toBe(
|
|
263
|
+
'external'
|
|
264
|
+
);
|
|
193
265
|
expect(getChatConversationTypesForScope('personal')).toEqual([
|
|
194
266
|
'direct',
|
|
195
267
|
'group',
|
|
@@ -200,6 +272,7 @@ describe('chat utils', () => {
|
|
|
200
272
|
'channel',
|
|
201
273
|
'ai',
|
|
202
274
|
]);
|
|
275
|
+
expect(getChatConversationTypesForScope('external')).toEqual(['channel']);
|
|
203
276
|
expect(
|
|
204
277
|
filterChatConversationsByScope(
|
|
205
278
|
[direct, group, channel, ai],
|
|
@@ -250,6 +323,106 @@ describe('chat utils', () => {
|
|
|
250
323
|
'workspaces'
|
|
251
324
|
).map((item) => item.id)
|
|
252
325
|
).toEqual(['channel-1', 'ai-1', 'agent-thread-1']);
|
|
326
|
+
expect(
|
|
327
|
+
filterChatConversationsByScope(
|
|
328
|
+
[direct, channel, external],
|
|
329
|
+
'external'
|
|
330
|
+
).map((item) => item.id)
|
|
331
|
+
).toEqual(['external-1']);
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it('uses dynamic external sender metadata when no native sender exists', () => {
|
|
335
|
+
const fallback = {
|
|
336
|
+
assistant: 'Assistant',
|
|
337
|
+
external: 'Website visitor',
|
|
338
|
+
unknown: 'Unknown sender',
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
expect(
|
|
342
|
+
getChatMessageSenderLabel(
|
|
343
|
+
{
|
|
344
|
+
...baseMessage,
|
|
345
|
+
metadata: {
|
|
346
|
+
externalChat: true,
|
|
347
|
+
externalSender: { displayName: 'Visitor 42' },
|
|
348
|
+
},
|
|
349
|
+
},
|
|
350
|
+
fallback
|
|
351
|
+
)
|
|
352
|
+
).toBe('Visitor 42');
|
|
353
|
+
expect(
|
|
354
|
+
getChatMessageSenderLabel(
|
|
355
|
+
{ ...baseMessage, metadata: { externalChat: true } },
|
|
356
|
+
fallback
|
|
357
|
+
)
|
|
358
|
+
).toBe('Website visitor');
|
|
359
|
+
expect(getChatMessageSenderLabel(baseMessage, fallback)).toBe(
|
|
360
|
+
'Unknown sender'
|
|
361
|
+
);
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
it('builds a dense connected-site queue summary from dynamic metadata', () => {
|
|
365
|
+
const details = getChatConversationQueueDetails(
|
|
366
|
+
conversation({
|
|
367
|
+
latestMessage: {
|
|
368
|
+
...baseMessage,
|
|
369
|
+
content: 'Need help with this page',
|
|
370
|
+
createdAt: '2026-08-05T07:15:00.000Z',
|
|
371
|
+
metadata: { status: 'seen' },
|
|
372
|
+
},
|
|
373
|
+
metadata: { externalChat: true, phone: '0900000000' },
|
|
374
|
+
})
|
|
375
|
+
);
|
|
376
|
+
|
|
377
|
+
expect(details).toEqual({
|
|
378
|
+
deliveryState: 'seen',
|
|
379
|
+
phone: '0900000000',
|
|
380
|
+
preview: 'Need help with this page',
|
|
381
|
+
timestamp: '2026-08-05T07:15:00.000Z',
|
|
382
|
+
});
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
it('normalizes connected-site delivery and deletion states', () => {
|
|
386
|
+
expect(
|
|
387
|
+
getChatConversationDeliveryState({
|
|
388
|
+
deletedAt: '2026-08-05T07:16:00.000Z',
|
|
389
|
+
metadata: { status: 'seen' },
|
|
390
|
+
})
|
|
391
|
+
).toBe('deleted');
|
|
392
|
+
expect(
|
|
393
|
+
getChatConversationDeliveryState({
|
|
394
|
+
deletedAt: null,
|
|
395
|
+
metadata: { status: 'delivery_failed' },
|
|
396
|
+
})
|
|
397
|
+
).toBe('failed');
|
|
398
|
+
expect(
|
|
399
|
+
getChatConversationDeliveryState({
|
|
400
|
+
deletedAt: null,
|
|
401
|
+
metadata: { status: 'queued' },
|
|
402
|
+
})
|
|
403
|
+
).toBe('sending');
|
|
404
|
+
expect(
|
|
405
|
+
getChatConversationDeliveryState({
|
|
406
|
+
deletedAt: null,
|
|
407
|
+
metadata: { status: '2' },
|
|
408
|
+
})
|
|
409
|
+
).toBe('sent');
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
it('does not expose deleted message content in queue previews', () => {
|
|
413
|
+
const details = getChatConversationQueueDetails(
|
|
414
|
+
conversation({
|
|
415
|
+
latestMessage: {
|
|
416
|
+
...baseMessage,
|
|
417
|
+
attachments: [{ filename: 'private.png' } as never],
|
|
418
|
+
content: 'deleted private content',
|
|
419
|
+
deletedAt: '2026-08-05T07:16:00.000Z',
|
|
420
|
+
},
|
|
421
|
+
})
|
|
422
|
+
);
|
|
423
|
+
|
|
424
|
+
expect(details.deliveryState).toBe('deleted');
|
|
425
|
+
expect(details.preview).toBeNull();
|
|
253
426
|
});
|
|
254
427
|
|
|
255
428
|
it('resolves workspace chat selection from requested, stored, then first conversation', () => {
|
|
@@ -408,7 +408,11 @@ function getArchiveDescription({
|
|
|
408
408
|
return t('archive_chat_warning');
|
|
409
409
|
}
|
|
410
410
|
|
|
411
|
-
export function EmptyConversationState({
|
|
411
|
+
export function EmptyConversationState({
|
|
412
|
+
onCreate,
|
|
413
|
+
}: {
|
|
414
|
+
onCreate?: () => void;
|
|
415
|
+
}) {
|
|
412
416
|
const t = useTranslations('chat');
|
|
413
417
|
|
|
414
418
|
return (
|
|
@@ -419,10 +423,12 @@ export function EmptyConversationState({ onCreate }: { onCreate: () => void }) {
|
|
|
419
423
|
<p className="mt-1 text-muted-foreground text-sm">
|
|
420
424
|
{t('empty_conversations_description')}
|
|
421
425
|
</p>
|
|
422
|
-
|
|
423
|
-
<
|
|
424
|
-
|
|
425
|
-
|
|
426
|
+
{onCreate ? (
|
|
427
|
+
<Button className="mt-4" onClick={onCreate} type="button">
|
|
428
|
+
<CheckCircle2 className="size-4" />
|
|
429
|
+
{t('new_conversation')}
|
|
430
|
+
</Button>
|
|
431
|
+
) : null}
|
|
426
432
|
</div>
|
|
427
433
|
</div>
|
|
428
434
|
);
|