@tuturuuu/ui 0.3.2 → 0.4.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 +8 -0
- package/package.json +6 -6
- package/src/components/ui/chat/chat-sidebar-groups.test.ts +13 -3
- package/src/components/ui/chat/chat-sidebar-panel.test.tsx +56 -2
- package/src/components/ui/chat/chat-sidebar-panel.tsx +36 -19
- package/src/components/ui/chat/chat-sidebar.tsx +7 -0
- package/src/components/ui/chat/chat-utils.test.ts +14 -1
- package/src/components/ui/chat/chat-workspace.tsx +84 -43
- package/src/components/ui/chat/create-conversation-dialog-utils.tsx +56 -0
- package/src/components/ui/chat/create-conversation-dialog.test.tsx +105 -0
- package/src/components/ui/chat/create-conversation-dialog.tsx +176 -170
- package/src/components/ui/chat/create-integration-panel.tsx +110 -0
- package/src/components/ui/chat/hooks-integrations.ts +28 -0
- package/src/components/ui/chat/hooks.ts +1 -0
- package/src/components/ui/chat/selection.ts +74 -0
- package/src/components/ui/chat/utils.ts +7 -1
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
2
2
|
import type { ChatConversation } from '@tuturuuu/internal-api';
|
|
3
|
+
import { ROOT_WORKSPACE_ID } from '@tuturuuu/utils/constants';
|
|
3
4
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
4
5
|
import { CreateConversationDialog } from './create-conversation-dialog';
|
|
6
|
+
import { CreateIntegrationPanel } from './create-integration-panel';
|
|
5
7
|
|
|
6
8
|
const mocks = vi.hoisted(() => ({
|
|
7
9
|
createConversation: vi.fn(),
|
|
8
10
|
createFriendRequest: vi.fn(),
|
|
11
|
+
createIntegration: vi.fn(),
|
|
9
12
|
}));
|
|
10
13
|
|
|
11
14
|
vi.mock('next-intl', () => ({
|
|
@@ -25,6 +28,18 @@ vi.mock('./hooks', () => ({
|
|
|
25
28
|
isPending: false,
|
|
26
29
|
mutateAsync: mocks.createFriendRequest,
|
|
27
30
|
}),
|
|
31
|
+
useCreateChatIntegration: () => ({
|
|
32
|
+
isPending: false,
|
|
33
|
+
mutate: mocks.createIntegration,
|
|
34
|
+
variables: null,
|
|
35
|
+
}),
|
|
36
|
+
}));
|
|
37
|
+
|
|
38
|
+
vi.mock('../sonner', () => ({
|
|
39
|
+
toast: {
|
|
40
|
+
error: vi.fn(),
|
|
41
|
+
success: vi.fn(),
|
|
42
|
+
},
|
|
28
43
|
}));
|
|
29
44
|
|
|
30
45
|
const createdConversation: ChatConversation = {
|
|
@@ -53,6 +68,13 @@ describe('CreateConversationDialog', () => {
|
|
|
53
68
|
mocks.createConversation.mockResolvedValue({
|
|
54
69
|
conversation: createdConversation,
|
|
55
70
|
});
|
|
71
|
+
mocks.createIntegration.mockImplementation((_payload, options) => {
|
|
72
|
+
options?.onSuccess?.({
|
|
73
|
+
agent: { id: 'chat-integrations' },
|
|
74
|
+
channel: { id: 'chat-zalo-personal' },
|
|
75
|
+
conversationId: 'ai-agent-chat-integrations-chat-zalo-personal',
|
|
76
|
+
});
|
|
77
|
+
});
|
|
56
78
|
});
|
|
57
79
|
|
|
58
80
|
it('creates personal AI chats with personal AI metadata', async () => {
|
|
@@ -86,4 +108,87 @@ describe('CreateConversationDialog', () => {
|
|
|
86
108
|
});
|
|
87
109
|
expect(onCreated).toHaveBeenCalledWith(createdConversation);
|
|
88
110
|
});
|
|
111
|
+
|
|
112
|
+
it('creates personal channels with personal scope metadata', async () => {
|
|
113
|
+
render(
|
|
114
|
+
<CreateConversationDialog
|
|
115
|
+
conversationScope="personal"
|
|
116
|
+
currentUserId="user-1"
|
|
117
|
+
onCreated={vi.fn()}
|
|
118
|
+
onOpenChange={vi.fn()}
|
|
119
|
+
open
|
|
120
|
+
wsId="personal-workspace-1"
|
|
121
|
+
/>
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
fireEvent.click(screen.getByText('type_channel'));
|
|
125
|
+
fireEvent.click(screen.getByText('next'));
|
|
126
|
+
fireEvent.change(screen.getByPlaceholderText('channel_name_placeholder'), {
|
|
127
|
+
target: { value: 'Ideas' },
|
|
128
|
+
});
|
|
129
|
+
fireEvent.click(screen.getByText('create'));
|
|
130
|
+
|
|
131
|
+
await waitFor(() => {
|
|
132
|
+
expect(mocks.createConversation).toHaveBeenCalledWith(
|
|
133
|
+
expect.objectContaining({
|
|
134
|
+
aiEnabled: false,
|
|
135
|
+
metadata: {
|
|
136
|
+
scope: 'personal',
|
|
137
|
+
},
|
|
138
|
+
title: 'Ideas',
|
|
139
|
+
type: 'channel',
|
|
140
|
+
})
|
|
141
|
+
);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('hides the integrations tab outside the internal root workspace', () => {
|
|
146
|
+
render(
|
|
147
|
+
<CreateConversationDialog
|
|
148
|
+
currentUserId="user-1"
|
|
149
|
+
enableRootIntegrations
|
|
150
|
+
onCreated={vi.fn()}
|
|
151
|
+
onOpenChange={vi.fn()}
|
|
152
|
+
open
|
|
153
|
+
wsId="workspace-1"
|
|
154
|
+
/>
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
expect(screen.queryByText('tab_integrations')).toBeNull();
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('shows the integrations tab in the internal root workspace', () => {
|
|
161
|
+
render(
|
|
162
|
+
<CreateConversationDialog
|
|
163
|
+
currentUserId="user-1"
|
|
164
|
+
enableRootIntegrations
|
|
165
|
+
onCreated={vi.fn()}
|
|
166
|
+
onOpenChange={vi.fn()}
|
|
167
|
+
open
|
|
168
|
+
wsId={ROOT_WORKSPACE_ID}
|
|
169
|
+
/>
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
expect(screen.getByRole('tab', { name: 'tab_integrations' })).toBeTruthy();
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('selects the returned virtual agent conversation after integration setup', () => {
|
|
176
|
+
const onCreated = vi.fn();
|
|
177
|
+
|
|
178
|
+
render(
|
|
179
|
+
<CreateIntegrationPanel onCreated={(result) => onCreated(result)} />
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
fireEvent.click(screen.getByText('integration_zalo_personal'));
|
|
183
|
+
|
|
184
|
+
expect(mocks.createIntegration).toHaveBeenCalledWith(
|
|
185
|
+
{ kind: 'zalo-personal' },
|
|
186
|
+
expect.any(Object)
|
|
187
|
+
);
|
|
188
|
+
expect(onCreated).toHaveBeenCalledWith(
|
|
189
|
+
expect.objectContaining({
|
|
190
|
+
conversationId: 'ai-agent-chat-integrations-chat-zalo-personal',
|
|
191
|
+
})
|
|
192
|
+
);
|
|
193
|
+
});
|
|
89
194
|
});
|
|
@@ -5,8 +5,9 @@ import type {
|
|
|
5
5
|
ChatConversation,
|
|
6
6
|
ChatConversationType,
|
|
7
7
|
ChatUserProfile,
|
|
8
|
+
CreateChatIntegrationResponse,
|
|
8
9
|
} from '@tuturuuu/internal-api';
|
|
9
|
-
import {
|
|
10
|
+
import { ROOT_WORKSPACE_ID } from '@tuturuuu/utils/constants';
|
|
10
11
|
import { useTranslations } from 'next-intl';
|
|
11
12
|
import { type FormEvent, useEffect, useMemo, useState } from 'react';
|
|
12
13
|
import { Button } from '../button';
|
|
@@ -20,8 +21,15 @@ import {
|
|
|
20
21
|
} from '../dialog';
|
|
21
22
|
import { Input } from '../input';
|
|
22
23
|
import { toast } from '../sonner';
|
|
24
|
+
import { Tabs, TabsContent, TabsList, TabsTrigger } from '../tabs';
|
|
23
25
|
import { Textarea } from '../textarea';
|
|
24
26
|
import { ConversationTypeSelector } from './conversation-type-selector';
|
|
27
|
+
import {
|
|
28
|
+
getConversationMetadata,
|
|
29
|
+
getCreateConversationErrorDescription,
|
|
30
|
+
StepTitle,
|
|
31
|
+
} from './create-conversation-dialog-utils';
|
|
32
|
+
import { CreateIntegrationPanel } from './create-integration-panel';
|
|
25
33
|
import { DirectoryUserPicker } from './directory-user-picker';
|
|
26
34
|
import {
|
|
27
35
|
useChatDirectory,
|
|
@@ -34,6 +42,7 @@ import {
|
|
|
34
42
|
} from './utils';
|
|
35
43
|
|
|
36
44
|
type CreateConversationStep = 'details' | 'members' | 'type';
|
|
45
|
+
type CreateConversationMode = 'conversation' | 'integrations';
|
|
37
46
|
|
|
38
47
|
const UUID_PATTERN =
|
|
39
48
|
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu;
|
|
@@ -43,7 +52,9 @@ interface CreateConversationDialogProps {
|
|
|
43
52
|
conversationScope?: ChatConversationScope;
|
|
44
53
|
currentUserId: string;
|
|
45
54
|
defaultType?: ChatConversationType;
|
|
55
|
+
enableRootIntegrations?: boolean;
|
|
46
56
|
onCreated: (conversation: ChatConversation) => void;
|
|
57
|
+
onIntegrationCreated?: (conversationId: string) => void;
|
|
47
58
|
onOpenChange: (open: boolean) => void;
|
|
48
59
|
open: boolean;
|
|
49
60
|
wsId: string;
|
|
@@ -54,7 +65,9 @@ export function CreateConversationDialog({
|
|
|
54
65
|
conversationScope,
|
|
55
66
|
currentUserId,
|
|
56
67
|
defaultType,
|
|
68
|
+
enableRootIntegrations,
|
|
57
69
|
onCreated,
|
|
70
|
+
onIntegrationCreated,
|
|
58
71
|
onOpenChange,
|
|
59
72
|
open,
|
|
60
73
|
wsId,
|
|
@@ -82,6 +95,7 @@ export function CreateConversationDialog({
|
|
|
82
95
|
const [directoryQuery, setDirectoryQuery] = useState('');
|
|
83
96
|
const [selectedUsers, setSelectedUsers] = useState<ChatUserProfile[]>([]);
|
|
84
97
|
const [step, setStep] = useState<CreateConversationStep>('type');
|
|
98
|
+
const [mode, setMode] = useState<CreateConversationMode>('conversation');
|
|
85
99
|
const { data: directoryUsers = [], isFetching } = useChatDirectory({
|
|
86
100
|
enabled: open && (type === 'direct' || type === 'group'),
|
|
87
101
|
query: directoryQuery,
|
|
@@ -120,6 +134,8 @@ export function CreateConversationDialog({
|
|
|
120
134
|
const requiresTitle = type === 'group' || type === 'channel';
|
|
121
135
|
const needsMembers = type === 'direct' || type === 'group';
|
|
122
136
|
const needsDetails = type !== 'direct';
|
|
137
|
+
const showRootIntegrations =
|
|
138
|
+
Boolean(enableRootIntegrations) && wsId === ROOT_WORKSPACE_ID;
|
|
123
139
|
const missingParticipants =
|
|
124
140
|
(type === 'direct' && validSelectedUsers.length !== 1) ||
|
|
125
141
|
(type === 'group' && validSelectedUsers.length < 1);
|
|
@@ -135,6 +151,7 @@ export function CreateConversationDialog({
|
|
|
135
151
|
setDirectoryQuery('');
|
|
136
152
|
setSelectedUsers([]);
|
|
137
153
|
setStep('type');
|
|
154
|
+
setMode('conversation');
|
|
138
155
|
}
|
|
139
156
|
|
|
140
157
|
async function handleCreateFriendRequest(email: string) {
|
|
@@ -155,10 +172,7 @@ export function CreateConversationDialog({
|
|
|
155
172
|
const { conversation } = await createConversation.mutateAsync({
|
|
156
173
|
aiEnabled: type === 'ai',
|
|
157
174
|
description: description.trim() || null,
|
|
158
|
-
metadata:
|
|
159
|
-
conversationScope === 'personal' && type === 'ai'
|
|
160
|
-
? { source: 'personal-ai-chat' }
|
|
161
|
-
: undefined,
|
|
175
|
+
metadata: getConversationMetadata(conversationScope, type),
|
|
162
176
|
participantUserIds: validSelectedUsers.map((user) => user.id),
|
|
163
177
|
title: title.trim() || null,
|
|
164
178
|
type,
|
|
@@ -173,6 +187,12 @@ export function CreateConversationDialog({
|
|
|
173
187
|
}
|
|
174
188
|
}
|
|
175
189
|
|
|
190
|
+
function handleIntegrationCreated(result: CreateChatIntegrationResponse) {
|
|
191
|
+
reset();
|
|
192
|
+
onIntegrationCreated?.(result.conversationId);
|
|
193
|
+
onOpenChange(false);
|
|
194
|
+
}
|
|
195
|
+
|
|
176
196
|
function getNextStep() {
|
|
177
197
|
if (step === 'type') return needsMembers ? 'members' : 'details';
|
|
178
198
|
if (step === 'members' && needsDetails) return 'details';
|
|
@@ -195,6 +215,127 @@ export function CreateConversationDialog({
|
|
|
195
215
|
(type === 'group' && validSelectedUsers.length < 1)
|
|
196
216
|
));
|
|
197
217
|
const showSubmit = !nextStep;
|
|
218
|
+
const conversationForm = (
|
|
219
|
+
<form className="flex min-h-0 flex-col gap-4" onSubmit={handleSubmit}>
|
|
220
|
+
{step === 'type' ? (
|
|
221
|
+
<div className="grid gap-3">
|
|
222
|
+
<StepTitle
|
|
223
|
+
description={t('step_type_description')}
|
|
224
|
+
title={t('step_type')}
|
|
225
|
+
/>
|
|
226
|
+
<ConversationTypeSelector
|
|
227
|
+
allowedTypes={effectiveAllowedTypes}
|
|
228
|
+
onTypeChange={(nextType) => {
|
|
229
|
+
setType(nextType);
|
|
230
|
+
if (nextType === 'direct') {
|
|
231
|
+
setSelectedUsers((current) => current.slice(0, 1));
|
|
232
|
+
}
|
|
233
|
+
}}
|
|
234
|
+
type={type}
|
|
235
|
+
/>
|
|
236
|
+
</div>
|
|
237
|
+
) : null}
|
|
238
|
+
|
|
239
|
+
{step === 'members' ? (
|
|
240
|
+
<div className="grid gap-3">
|
|
241
|
+
<StepTitle
|
|
242
|
+
description={
|
|
243
|
+
type === 'direct'
|
|
244
|
+
? t('step_members_direct_description')
|
|
245
|
+
: t('step_members_group_description')
|
|
246
|
+
}
|
|
247
|
+
title={t('step_members')}
|
|
248
|
+
/>
|
|
249
|
+
<DirectoryUserPicker
|
|
250
|
+
canCreateFriendRequest={conversationScope !== 'workspaces'}
|
|
251
|
+
directoryQuery={directoryQuery}
|
|
252
|
+
filteredUsers={filteredUsers}
|
|
253
|
+
isFetching={isFetching}
|
|
254
|
+
isCreatingFriendRequest={createFriendRequest.isPending}
|
|
255
|
+
onDirectoryQueryChange={setDirectoryQuery}
|
|
256
|
+
onCreateFriendRequest={handleCreateFriendRequest}
|
|
257
|
+
onRemoveUser={(userId) =>
|
|
258
|
+
setSelectedUsers((current) =>
|
|
259
|
+
current.filter((item) => item.id !== userId)
|
|
260
|
+
)
|
|
261
|
+
}
|
|
262
|
+
onSelectUser={(user) =>
|
|
263
|
+
setSelectedUsers((current) =>
|
|
264
|
+
type === 'direct' ? [user] : [...current, user]
|
|
265
|
+
)
|
|
266
|
+
}
|
|
267
|
+
selectedUsers={selectedUsers}
|
|
268
|
+
/>
|
|
269
|
+
</div>
|
|
270
|
+
) : null}
|
|
271
|
+
|
|
272
|
+
{step === 'details' ? (
|
|
273
|
+
<div className="grid gap-3">
|
|
274
|
+
<StepTitle
|
|
275
|
+
description={t('step_details_description')}
|
|
276
|
+
title={t('step_details')}
|
|
277
|
+
/>
|
|
278
|
+
<Input
|
|
279
|
+
onChange={(event) => setTitle(event.target.value)}
|
|
280
|
+
placeholder={
|
|
281
|
+
type === 'channel'
|
|
282
|
+
? t('channel_name_placeholder')
|
|
283
|
+
: type === 'ai'
|
|
284
|
+
? t('agent_name_placeholder')
|
|
285
|
+
: t('group_name_placeholder')
|
|
286
|
+
}
|
|
287
|
+
value={title}
|
|
288
|
+
/>
|
|
289
|
+
{(type === 'group' || type === 'channel' || type === 'ai') && (
|
|
290
|
+
<Textarea
|
|
291
|
+
className="min-h-24"
|
|
292
|
+
onChange={(event) => setDescription(event.target.value)}
|
|
293
|
+
placeholder={t('conversation_description_placeholder')}
|
|
294
|
+
value={description}
|
|
295
|
+
/>
|
|
296
|
+
)}
|
|
297
|
+
</div>
|
|
298
|
+
) : null}
|
|
299
|
+
|
|
300
|
+
<DialogFooter>
|
|
301
|
+
<Button
|
|
302
|
+
onClick={() => {
|
|
303
|
+
reset();
|
|
304
|
+
onOpenChange(false);
|
|
305
|
+
}}
|
|
306
|
+
type="button"
|
|
307
|
+
variant="outline"
|
|
308
|
+
>
|
|
309
|
+
{t('cancel')}
|
|
310
|
+
</Button>
|
|
311
|
+
{previousStep ? (
|
|
312
|
+
<Button
|
|
313
|
+
onClick={() => setStep(previousStep)}
|
|
314
|
+
type="button"
|
|
315
|
+
variant="outline"
|
|
316
|
+
>
|
|
317
|
+
{t('back')}
|
|
318
|
+
</Button>
|
|
319
|
+
) : null}
|
|
320
|
+
{showSubmit ? (
|
|
321
|
+
<Button disabled={shouldDisableSubmit} type="submit">
|
|
322
|
+
{createConversation.isPending && (
|
|
323
|
+
<LoaderCircle className="size-4 animate-spin" />
|
|
324
|
+
)}
|
|
325
|
+
{t('create')}
|
|
326
|
+
</Button>
|
|
327
|
+
) : (
|
|
328
|
+
<Button
|
|
329
|
+
disabled={!canContinue}
|
|
330
|
+
onClick={() => nextStep && setStep(nextStep)}
|
|
331
|
+
type="button"
|
|
332
|
+
>
|
|
333
|
+
{t('next')}
|
|
334
|
+
</Button>
|
|
335
|
+
)}
|
|
336
|
+
</DialogFooter>
|
|
337
|
+
</form>
|
|
338
|
+
);
|
|
198
339
|
|
|
199
340
|
return (
|
|
200
341
|
<Dialog
|
|
@@ -205,173 +346,38 @@ export function CreateConversationDialog({
|
|
|
205
346
|
}}
|
|
206
347
|
>
|
|
207
348
|
<DialogContent className="max-h-[min(42rem,calc(100vh-2rem))] overflow-hidden sm:max-w-2xl">
|
|
208
|
-
<
|
|
209
|
-
<
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
</DialogHeader>
|
|
215
|
-
|
|
216
|
-
{step === 'type' ? (
|
|
217
|
-
<div className="grid gap-3">
|
|
218
|
-
<StepTitle
|
|
219
|
-
description={t('step_type_description')}
|
|
220
|
-
title={t('step_type')}
|
|
221
|
-
/>
|
|
222
|
-
<ConversationTypeSelector
|
|
223
|
-
allowedTypes={effectiveAllowedTypes}
|
|
224
|
-
onTypeChange={(nextType) => {
|
|
225
|
-
setType(nextType);
|
|
226
|
-
if (nextType === 'direct') {
|
|
227
|
-
setSelectedUsers((current) => current.slice(0, 1));
|
|
228
|
-
}
|
|
229
|
-
}}
|
|
230
|
-
type={type}
|
|
231
|
-
/>
|
|
232
|
-
</div>
|
|
233
|
-
) : null}
|
|
234
|
-
|
|
235
|
-
{step === 'members' ? (
|
|
236
|
-
<div className="grid gap-3">
|
|
237
|
-
<StepTitle
|
|
238
|
-
description={
|
|
239
|
-
type === 'direct'
|
|
240
|
-
? t('step_members_direct_description')
|
|
241
|
-
: t('step_members_group_description')
|
|
242
|
-
}
|
|
243
|
-
title={t('step_members')}
|
|
244
|
-
/>
|
|
245
|
-
<DirectoryUserPicker
|
|
246
|
-
canCreateFriendRequest={conversationScope !== 'workspaces'}
|
|
247
|
-
directoryQuery={directoryQuery}
|
|
248
|
-
filteredUsers={filteredUsers}
|
|
249
|
-
isFetching={isFetching}
|
|
250
|
-
isCreatingFriendRequest={createFriendRequest.isPending}
|
|
251
|
-
onDirectoryQueryChange={setDirectoryQuery}
|
|
252
|
-
onCreateFriendRequest={handleCreateFriendRequest}
|
|
253
|
-
onRemoveUser={(userId) =>
|
|
254
|
-
setSelectedUsers((current) =>
|
|
255
|
-
current.filter((item) => item.id !== userId)
|
|
256
|
-
)
|
|
257
|
-
}
|
|
258
|
-
onSelectUser={(user) =>
|
|
259
|
-
setSelectedUsers((current) =>
|
|
260
|
-
type === 'direct' ? [user] : [...current, user]
|
|
261
|
-
)
|
|
262
|
-
}
|
|
263
|
-
selectedUsers={selectedUsers}
|
|
264
|
-
/>
|
|
265
|
-
</div>
|
|
266
|
-
) : null}
|
|
267
|
-
|
|
268
|
-
{step === 'details' ? (
|
|
269
|
-
<div className="grid gap-3">
|
|
270
|
-
<StepTitle
|
|
271
|
-
description={t('step_details_description')}
|
|
272
|
-
title={t('step_details')}
|
|
273
|
-
/>
|
|
274
|
-
<Input
|
|
275
|
-
onChange={(event) => setTitle(event.target.value)}
|
|
276
|
-
placeholder={
|
|
277
|
-
type === 'channel'
|
|
278
|
-
? t('channel_name_placeholder')
|
|
279
|
-
: type === 'ai'
|
|
280
|
-
? t('agent_name_placeholder')
|
|
281
|
-
: t('group_name_placeholder')
|
|
282
|
-
}
|
|
283
|
-
value={title}
|
|
284
|
-
/>
|
|
285
|
-
{(type === 'group' || type === 'channel' || type === 'ai') && (
|
|
286
|
-
<Textarea
|
|
287
|
-
className="min-h-24"
|
|
288
|
-
onChange={(event) => setDescription(event.target.value)}
|
|
289
|
-
placeholder={t('conversation_description_placeholder')}
|
|
290
|
-
value={description}
|
|
291
|
-
/>
|
|
292
|
-
)}
|
|
293
|
-
</div>
|
|
294
|
-
) : null}
|
|
349
|
+
<DialogHeader>
|
|
350
|
+
<DialogTitle>{t('new_conversation')}</DialogTitle>
|
|
351
|
+
<DialogDescription>
|
|
352
|
+
{t('new_conversation_description')}
|
|
353
|
+
</DialogDescription>
|
|
354
|
+
</DialogHeader>
|
|
295
355
|
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
)}
|
|
321
|
-
{t('create')}
|
|
322
|
-
</Button>
|
|
323
|
-
) : (
|
|
324
|
-
<Button
|
|
325
|
-
disabled={!canContinue}
|
|
326
|
-
onClick={() => nextStep && setStep(nextStep)}
|
|
327
|
-
type="button"
|
|
328
|
-
>
|
|
329
|
-
{t('next')}
|
|
330
|
-
</Button>
|
|
331
|
-
)}
|
|
332
|
-
</DialogFooter>
|
|
333
|
-
</form>
|
|
356
|
+
{showRootIntegrations ? (
|
|
357
|
+
<Tabs
|
|
358
|
+
className="min-h-0"
|
|
359
|
+
onValueChange={(value) => setMode(value as CreateConversationMode)}
|
|
360
|
+
value={mode}
|
|
361
|
+
>
|
|
362
|
+
<TabsList className="grid w-full grid-cols-2">
|
|
363
|
+
<TabsTrigger value="conversation">
|
|
364
|
+
{t('tab_conversations')}
|
|
365
|
+
</TabsTrigger>
|
|
366
|
+
<TabsTrigger value="integrations">
|
|
367
|
+
{t('tab_integrations')}
|
|
368
|
+
</TabsTrigger>
|
|
369
|
+
</TabsList>
|
|
370
|
+
<TabsContent className="mt-4" value="conversation">
|
|
371
|
+
{conversationForm}
|
|
372
|
+
</TabsContent>
|
|
373
|
+
<TabsContent className="mt-4" value="integrations">
|
|
374
|
+
<CreateIntegrationPanel onCreated={handleIntegrationCreated} />
|
|
375
|
+
</TabsContent>
|
|
376
|
+
</Tabs>
|
|
377
|
+
) : (
|
|
378
|
+
conversationForm
|
|
379
|
+
)}
|
|
334
380
|
</DialogContent>
|
|
335
381
|
</Dialog>
|
|
336
382
|
);
|
|
337
383
|
}
|
|
338
|
-
|
|
339
|
-
function StepTitle({
|
|
340
|
-
description,
|
|
341
|
-
title,
|
|
342
|
-
}: {
|
|
343
|
-
description: string;
|
|
344
|
-
title: string;
|
|
345
|
-
}) {
|
|
346
|
-
return (
|
|
347
|
-
<div>
|
|
348
|
-
<h3 className="font-medium text-sm">{title}</h3>
|
|
349
|
-
<p className="mt-1 text-muted-foreground text-xs">{description}</p>
|
|
350
|
-
</div>
|
|
351
|
-
);
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
function getCreateConversationErrorDescription(
|
|
355
|
-
error: unknown,
|
|
356
|
-
t: ReturnType<typeof useTranslations>
|
|
357
|
-
) {
|
|
358
|
-
if (!(error instanceof InternalApiError)) return undefined;
|
|
359
|
-
|
|
360
|
-
if (error.message.includes('chat_target_not_invitable')) {
|
|
361
|
-
return t('conversation_create_target_not_invitable');
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
if (error.message.includes('chat_direct_requires_one_target')) {
|
|
365
|
-
return t('conversation_create_direct_requires_one_target');
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
if (error.message.includes('chat_group_requires_members')) {
|
|
369
|
-
return t('conversation_create_group_requires_members');
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
if (error.message.includes('chat_permission_required')) {
|
|
373
|
-
return t('conversation_create_permission_required');
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
return error.message;
|
|
377
|
-
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { LoaderCircle, MessageCircle, QrCode, Radio } from '@tuturuuu/icons';
|
|
4
|
+
import type {
|
|
5
|
+
ChatIntegrationKind,
|
|
6
|
+
CreateChatIntegrationResponse,
|
|
7
|
+
} from '@tuturuuu/internal-api';
|
|
8
|
+
import { useTranslations } from 'next-intl';
|
|
9
|
+
import { toast } from '../sonner';
|
|
10
|
+
import { useCreateChatIntegration } from './hooks';
|
|
11
|
+
|
|
12
|
+
type IntegrationCard = {
|
|
13
|
+
descriptionKey:
|
|
14
|
+
| 'integration_discord_description'
|
|
15
|
+
| 'integration_zalo_official_description'
|
|
16
|
+
| 'integration_zalo_personal_description';
|
|
17
|
+
icon: typeof MessageCircle;
|
|
18
|
+
kind: ChatIntegrationKind;
|
|
19
|
+
titleKey:
|
|
20
|
+
| 'integration_discord'
|
|
21
|
+
| 'integration_zalo_official'
|
|
22
|
+
| 'integration_zalo_personal';
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const INTEGRATION_CARDS = [
|
|
26
|
+
{
|
|
27
|
+
descriptionKey: 'integration_discord_description',
|
|
28
|
+
icon: MessageCircle,
|
|
29
|
+
kind: 'discord',
|
|
30
|
+
titleKey: 'integration_discord',
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
descriptionKey: 'integration_zalo_official_description',
|
|
34
|
+
icon: Radio,
|
|
35
|
+
kind: 'zalo-official',
|
|
36
|
+
titleKey: 'integration_zalo_official',
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
descriptionKey: 'integration_zalo_personal_description',
|
|
40
|
+
icon: QrCode,
|
|
41
|
+
kind: 'zalo-personal',
|
|
42
|
+
titleKey: 'integration_zalo_personal',
|
|
43
|
+
},
|
|
44
|
+
] as const satisfies IntegrationCard[];
|
|
45
|
+
|
|
46
|
+
interface CreateIntegrationPanelProps {
|
|
47
|
+
onCreated: (result: CreateChatIntegrationResponse) => void;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function CreateIntegrationPanel({
|
|
51
|
+
onCreated,
|
|
52
|
+
}: CreateIntegrationPanelProps) {
|
|
53
|
+
const t = useTranslations('chat');
|
|
54
|
+
const createIntegration = useCreateChatIntegration();
|
|
55
|
+
const pendingKind = createIntegration.variables?.kind ?? null;
|
|
56
|
+
|
|
57
|
+
function create(kind: ChatIntegrationKind) {
|
|
58
|
+
createIntegration.mutate(
|
|
59
|
+
{ kind },
|
|
60
|
+
{
|
|
61
|
+
onError: () => {
|
|
62
|
+
toast.error(t('integration_create_failed'));
|
|
63
|
+
},
|
|
64
|
+
onSuccess: (result) => {
|
|
65
|
+
toast.success(t('integration_create_success'));
|
|
66
|
+
onCreated(result);
|
|
67
|
+
},
|
|
68
|
+
}
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<div className="grid gap-3">
|
|
74
|
+
{INTEGRATION_CARDS.map((item) => {
|
|
75
|
+
const Icon = item.icon;
|
|
76
|
+
const isPending =
|
|
77
|
+
createIntegration.isPending && pendingKind === item.kind;
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
<button
|
|
81
|
+
className="group flex min-h-24 w-full items-start gap-3 rounded-md border bg-background p-4 text-left transition-colors hover:border-foreground/35 hover:bg-muted/35 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-60"
|
|
82
|
+
disabled={createIntegration.isPending}
|
|
83
|
+
key={item.kind}
|
|
84
|
+
onClick={() => create(item.kind)}
|
|
85
|
+
type="button"
|
|
86
|
+
>
|
|
87
|
+
<span className="flex size-9 shrink-0 items-center justify-center rounded-md border bg-muted text-muted-foreground group-hover:text-foreground">
|
|
88
|
+
{isPending ? (
|
|
89
|
+
<LoaderCircle className="size-4 animate-spin" />
|
|
90
|
+
) : (
|
|
91
|
+
<Icon className="size-4" />
|
|
92
|
+
)}
|
|
93
|
+
</span>
|
|
94
|
+
<span className="min-w-0 flex-1">
|
|
95
|
+
<span className="block font-medium text-sm">
|
|
96
|
+
{t(item.titleKey)}
|
|
97
|
+
</span>
|
|
98
|
+
<span className="mt-1 block text-muted-foreground text-xs leading-5">
|
|
99
|
+
{t(item.descriptionKey)}
|
|
100
|
+
</span>
|
|
101
|
+
</span>
|
|
102
|
+
<span className="shrink-0 self-center rounded-md border px-2.5 py-1 font-medium text-xs">
|
|
103
|
+
{t('integration_setup')}
|
|
104
|
+
</span>
|
|
105
|
+
</button>
|
|
106
|
+
);
|
|
107
|
+
})}
|
|
108
|
+
</div>
|
|
109
|
+
);
|
|
110
|
+
}
|