@traky-app/ui 1.6.0 → 1.6.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.
- package/package.json +1 -1
- package/registry/traky/json/internal-chat-mention.json +17 -0
- package/registry/traky/json/internal-chat-panel.json +31 -0
- package/registry/traky/registry.json +20 -0
- package/registry/traky/ui/internal-chat-mention.ts +202 -0
- package/registry/traky/ui/internal-chat-panel.tsx +1115 -0
- package/src/ui/internal-chat-mention.ts +202 -0
- package/src/ui/internal-chat-panel.tsx +1115 -0
|
@@ -0,0 +1,1115 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
IconArrowUp,
|
|
5
|
+
IconDotsVertical,
|
|
6
|
+
IconLoader2,
|
|
7
|
+
IconMessages,
|
|
8
|
+
IconPackage,
|
|
9
|
+
IconPaperclip,
|
|
10
|
+
IconPlus,
|
|
11
|
+
IconTrash,
|
|
12
|
+
IconTruckLoading,
|
|
13
|
+
IconX,
|
|
14
|
+
type Icon,
|
|
15
|
+
} from "@tabler/icons-react";
|
|
16
|
+
import {
|
|
17
|
+
useCallback,
|
|
18
|
+
useEffect,
|
|
19
|
+
useLayoutEffect,
|
|
20
|
+
useMemo,
|
|
21
|
+
useRef,
|
|
22
|
+
useState,
|
|
23
|
+
type FormEvent,
|
|
24
|
+
type ReactNode,
|
|
25
|
+
} from "react";
|
|
26
|
+
import {
|
|
27
|
+
AlertDialog,
|
|
28
|
+
AlertDialogAction,
|
|
29
|
+
AlertDialogCancel,
|
|
30
|
+
AlertDialogContent,
|
|
31
|
+
AlertDialogDescription,
|
|
32
|
+
AlertDialogFooter,
|
|
33
|
+
AlertDialogHeader,
|
|
34
|
+
AlertDialogTitle,
|
|
35
|
+
} from "./alert-dialog";
|
|
36
|
+
import { Bubble, BubbleContent } from "./bubble";
|
|
37
|
+
import { Button } from "./button";
|
|
38
|
+
import {
|
|
39
|
+
Dialog,
|
|
40
|
+
DialogContent,
|
|
41
|
+
DialogDescription,
|
|
42
|
+
DialogFooter,
|
|
43
|
+
DialogHeader,
|
|
44
|
+
DialogTitle,
|
|
45
|
+
} from "./dialog";
|
|
46
|
+
import {
|
|
47
|
+
Drawer,
|
|
48
|
+
DrawerClose,
|
|
49
|
+
DrawerContent,
|
|
50
|
+
DrawerDescription,
|
|
51
|
+
DrawerTitle,
|
|
52
|
+
} from "./drawer";
|
|
53
|
+
import {
|
|
54
|
+
DropdownMenu,
|
|
55
|
+
DropdownMenuContent,
|
|
56
|
+
DropdownMenuItem,
|
|
57
|
+
DropdownMenuSeparator,
|
|
58
|
+
DropdownMenuTrigger,
|
|
59
|
+
} from "./dropdown-menu";
|
|
60
|
+
import {
|
|
61
|
+
Empty,
|
|
62
|
+
EmptyDescription,
|
|
63
|
+
EmptyHeader,
|
|
64
|
+
EmptyMedia,
|
|
65
|
+
EmptyTitle,
|
|
66
|
+
} from "./empty";
|
|
67
|
+
import {
|
|
68
|
+
InputGroup,
|
|
69
|
+
InputGroupAddon,
|
|
70
|
+
InputGroupButton,
|
|
71
|
+
InputGroupTextarea,
|
|
72
|
+
} from "./input-group";
|
|
73
|
+
import { Input } from "./input";
|
|
74
|
+
import {
|
|
75
|
+
Message,
|
|
76
|
+
MessageAvatar,
|
|
77
|
+
MessageContent,
|
|
78
|
+
MessageFooter,
|
|
79
|
+
MessageHeader,
|
|
80
|
+
} from "./message";
|
|
81
|
+
import {
|
|
82
|
+
Tooltip,
|
|
83
|
+
TooltipContent,
|
|
84
|
+
TooltipTrigger,
|
|
85
|
+
} from "./tooltip";
|
|
86
|
+
import { cn } from "../lib/utils";
|
|
87
|
+
import {
|
|
88
|
+
buildMentionList,
|
|
89
|
+
DEFAULT_MENTION_KIND_LABEL,
|
|
90
|
+
DEFAULT_MENTION_KIND_ORDER,
|
|
91
|
+
formatMessageTime,
|
|
92
|
+
parseMentionSegment,
|
|
93
|
+
parseSlashMention,
|
|
94
|
+
roomInitialLetter,
|
|
95
|
+
type InternalChatMentionConfig,
|
|
96
|
+
type InternalChatMentionItem,
|
|
97
|
+
type InternalChatMentionKind,
|
|
98
|
+
} from "./internal-chat-mention";
|
|
99
|
+
|
|
100
|
+
export type InternalChatRoom = {
|
|
101
|
+
id: string;
|
|
102
|
+
name: string;
|
|
103
|
+
isOrganization?: boolean;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
export type InternalChatMessageLine = {
|
|
107
|
+
id: string;
|
|
108
|
+
role: "me" | "team";
|
|
109
|
+
author: string;
|
|
110
|
+
initials: string;
|
|
111
|
+
text: string;
|
|
112
|
+
at: string;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
export type InternalChatMessagesStatus =
|
|
116
|
+
| "idle"
|
|
117
|
+
| "loading"
|
|
118
|
+
| "error"
|
|
119
|
+
| "empty"
|
|
120
|
+
| "ready";
|
|
121
|
+
|
|
122
|
+
export type InternalChatPanelLabels = {
|
|
123
|
+
openChat: string;
|
|
124
|
+
newRoom: string;
|
|
125
|
+
roomMenu: string;
|
|
126
|
+
deleteRoom: string;
|
|
127
|
+
closeChat: string;
|
|
128
|
+
newRoomTitle: string;
|
|
129
|
+
newRoomDescription: string;
|
|
130
|
+
newRoomPlaceholder: string;
|
|
131
|
+
cancel: string;
|
|
132
|
+
create: string;
|
|
133
|
+
deleteRoomTitle: string;
|
|
134
|
+
deleteRoomDescription: (roomName: string) => string;
|
|
135
|
+
deleteConfirm: string;
|
|
136
|
+
noRoomTitle: string;
|
|
137
|
+
noRoomDescription: string;
|
|
138
|
+
loadingMessages: string;
|
|
139
|
+
loadMessagesErrorTitle: string;
|
|
140
|
+
loadMessagesErrorFallback: string;
|
|
141
|
+
emptyThreadTitle: string;
|
|
142
|
+
emptyThreadDescription: (roomName: string) => string;
|
|
143
|
+
loadingOlderMessages: string;
|
|
144
|
+
mentionListLabel: string;
|
|
145
|
+
messagePlaceholder: string;
|
|
146
|
+
messagePlaceholderNoRoom: string;
|
|
147
|
+
messageAriaLabel: string;
|
|
148
|
+
attachAriaLabel: string;
|
|
149
|
+
attachPhotos: string;
|
|
150
|
+
sendSrOnly: string;
|
|
151
|
+
orgRoomSuffix: string;
|
|
152
|
+
orgRoomTooltip: string;
|
|
153
|
+
slashMentionHint: string;
|
|
154
|
+
selectRoomHint: string;
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const DEFAULT_LABELS: InternalChatPanelLabels = {
|
|
158
|
+
openChat: "Abrir chat de la empresa",
|
|
159
|
+
newRoom: "Nueva sala",
|
|
160
|
+
roomMenu: "Más acciones de salas",
|
|
161
|
+
deleteRoom: "Eliminar sala actual",
|
|
162
|
+
closeChat: "Cerrar chat",
|
|
163
|
+
newRoomTitle: "Nueva sala",
|
|
164
|
+
newRoomDescription:
|
|
165
|
+
"Crea un espacio para conversar con tu equipo en tiempo real.",
|
|
166
|
+
newRoomPlaceholder: "Nombre de la sala",
|
|
167
|
+
cancel: "Cancelar",
|
|
168
|
+
create: "Crear",
|
|
169
|
+
deleteRoomTitle: "¿Eliminar esta sala?",
|
|
170
|
+
deleteRoomDescription: (roomName) =>
|
|
171
|
+
`Se borrarán los mensajes de «${roomName}» en el servidor. Esta acción no se puede deshacer.`,
|
|
172
|
+
deleteConfirm: "Eliminar",
|
|
173
|
+
noRoomTitle: "Sin sala",
|
|
174
|
+
noRoomDescription: "Crea una sala con el botón + a la izquierda.",
|
|
175
|
+
loadingMessages: "Cargando mensajes…",
|
|
176
|
+
loadMessagesErrorTitle: "No se pudieron cargar los mensajes",
|
|
177
|
+
loadMessagesErrorFallback:
|
|
178
|
+
"Revisa que la API esté en marcha y que el módulo de chat interno esté migrado.",
|
|
179
|
+
emptyThreadTitle: "¿De qué hablamos?",
|
|
180
|
+
emptyThreadDescription: (roomName) =>
|
|
181
|
+
`Escribe el primer mensaje en «${roomName}».`,
|
|
182
|
+
loadingOlderMessages: "Cargando mensajes anteriores…",
|
|
183
|
+
mentionListLabel: "Insertar mención",
|
|
184
|
+
messagePlaceholder: "Mensaje… · / comandos",
|
|
185
|
+
messagePlaceholderNoRoom: "Selecciona una sala…",
|
|
186
|
+
messageAriaLabel: "Mensaje",
|
|
187
|
+
attachAriaLabel: "Adjuntar",
|
|
188
|
+
attachPhotos: "Fotos y archivos",
|
|
189
|
+
sendSrOnly: "Enviar",
|
|
190
|
+
orgRoomSuffix: " · Sala de toda la organización",
|
|
191
|
+
orgRoomTooltip: " — toda la organización (no se elimina)",
|
|
192
|
+
slashMentionHint: "menciones (guías y paquetes)",
|
|
193
|
+
selectRoomHint: "Selecciona una sala o crea una nueva.",
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
const DEFAULT_MENTION_CONFIG: InternalChatMentionConfig = {
|
|
197
|
+
kindOrder: DEFAULT_MENTION_KIND_ORDER,
|
|
198
|
+
kindIcons: {
|
|
199
|
+
guide: IconTruckLoading,
|
|
200
|
+
package: IconPackage,
|
|
201
|
+
},
|
|
202
|
+
categoryMeta: {
|
|
203
|
+
guide: {
|
|
204
|
+
label: "Guía",
|
|
205
|
+
hint: "Guías de envío",
|
|
206
|
+
keywords: ["guía", "guia", "guide", "envío", "envio"],
|
|
207
|
+
},
|
|
208
|
+
package: {
|
|
209
|
+
label: "Paquete",
|
|
210
|
+
hint: "Paquetes de la empresa (WR / tracking)",
|
|
211
|
+
keywords: ["paquete", "paquetes", "package", "trk", "wr"],
|
|
212
|
+
},
|
|
213
|
+
},
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
function renderMessageTextWithMentions(text: string): ReactNode {
|
|
217
|
+
const parts = text.split(/(@\[[^\]]+\])/g);
|
|
218
|
+
return parts.map((part, index) => {
|
|
219
|
+
const mention = /^@\[(.+)\]$/.exec(part);
|
|
220
|
+
if (mention) {
|
|
221
|
+
return (
|
|
222
|
+
<span
|
|
223
|
+
key={index}
|
|
224
|
+
className="inline-flex max-w-full items-center rounded-md border border-primary/35 bg-primary/12 px-1.5 py-0.5 font-medium text-primary"
|
|
225
|
+
>
|
|
226
|
+
{mention[1]}
|
|
227
|
+
</span>
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
return <span key={index}>{part}</span>;
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export type InternalChatPanelProps = {
|
|
235
|
+
title: string;
|
|
236
|
+
rooms: InternalChatRoom[];
|
|
237
|
+
activeRoomId: string | null;
|
|
238
|
+
onActiveRoomChange: (roomId: string) => void;
|
|
239
|
+
messages: InternalChatMessageLine[];
|
|
240
|
+
messagesStatus: InternalChatMessagesStatus;
|
|
241
|
+
messagesError?: string | null;
|
|
242
|
+
onLoadOlderMessages?: () => void | Promise<void>;
|
|
243
|
+
isLoadingOlder?: boolean;
|
|
244
|
+
hasOlderMessages?: boolean;
|
|
245
|
+
mentionGuideItems?: InternalChatMentionItem[];
|
|
246
|
+
mentionPackageItems?: InternalChatMentionItem[];
|
|
247
|
+
mentionConfig?: InternalChatMentionConfig;
|
|
248
|
+
typingLine?: string | null;
|
|
249
|
+
onSendMessage: (text: string) => void | Promise<void>;
|
|
250
|
+
onCreateRoom: (name: string) => void | Promise<void>;
|
|
251
|
+
onDeleteRoom: (roomId: string) => void | Promise<void>;
|
|
252
|
+
onOpenChange?: (open: boolean) => void;
|
|
253
|
+
onDraftChange?: (draft: string) => void;
|
|
254
|
+
onUserGesture?: () => void;
|
|
255
|
+
isSending?: boolean;
|
|
256
|
+
isCreatingRoom?: boolean;
|
|
257
|
+
isDeletingRoom?: boolean;
|
|
258
|
+
onDeleteRoomBlocked?: (reason: string) => void;
|
|
259
|
+
labels?: Partial<InternalChatPanelLabels>;
|
|
260
|
+
className?: string;
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
function InternalChatPanel({
|
|
264
|
+
title,
|
|
265
|
+
rooms,
|
|
266
|
+
activeRoomId,
|
|
267
|
+
onActiveRoomChange,
|
|
268
|
+
messages,
|
|
269
|
+
messagesStatus,
|
|
270
|
+
messagesError = null,
|
|
271
|
+
onLoadOlderMessages,
|
|
272
|
+
isLoadingOlder = false,
|
|
273
|
+
hasOlderMessages = false,
|
|
274
|
+
mentionGuideItems = [],
|
|
275
|
+
mentionPackageItems = [],
|
|
276
|
+
mentionConfig = DEFAULT_MENTION_CONFIG,
|
|
277
|
+
typingLine = null,
|
|
278
|
+
onSendMessage,
|
|
279
|
+
onCreateRoom,
|
|
280
|
+
onDeleteRoom,
|
|
281
|
+
onOpenChange,
|
|
282
|
+
onDraftChange,
|
|
283
|
+
onUserGesture,
|
|
284
|
+
isSending = false,
|
|
285
|
+
isCreatingRoom = false,
|
|
286
|
+
isDeletingRoom = false,
|
|
287
|
+
onDeleteRoomBlocked,
|
|
288
|
+
labels: labelsPartial,
|
|
289
|
+
className,
|
|
290
|
+
}: InternalChatPanelProps) {
|
|
291
|
+
const labels = { ...DEFAULT_LABELS, ...labelsPartial };
|
|
292
|
+
|
|
293
|
+
const [open, setOpen] = useState(false);
|
|
294
|
+
const [draft, setDraft] = useState("");
|
|
295
|
+
const [createOpen, setCreateOpen] = useState(false);
|
|
296
|
+
const [newRoomName, setNewRoomName] = useState("");
|
|
297
|
+
const [deleteOpen, setDeleteOpen] = useState(false);
|
|
298
|
+
const [mentionStart, setMentionStart] = useState<number | null>(null);
|
|
299
|
+
const [mentionQuery, setMentionQuery] = useState("");
|
|
300
|
+
const [mentionHighlight, setMentionHighlight] = useState(0);
|
|
301
|
+
const lastMentionQueryRef = useRef("");
|
|
302
|
+
const loadingOlderMessagesRef = useRef(false);
|
|
303
|
+
|
|
304
|
+
const formRef = useRef<HTMLFormElement>(null);
|
|
305
|
+
const scrollerRef = useRef<HTMLDivElement>(null);
|
|
306
|
+
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
307
|
+
|
|
308
|
+
const activeRoom = useMemo(
|
|
309
|
+
() => rooms.find((r) => r.id === activeRoomId) ?? null,
|
|
310
|
+
[rooms, activeRoomId],
|
|
311
|
+
);
|
|
312
|
+
|
|
313
|
+
const mentionEntries = useMemo(
|
|
314
|
+
() =>
|
|
315
|
+
buildMentionList(
|
|
316
|
+
mentionQuery,
|
|
317
|
+
mentionGuideItems,
|
|
318
|
+
mentionPackageItems,
|
|
319
|
+
mentionConfig,
|
|
320
|
+
),
|
|
321
|
+
[mentionQuery, mentionGuideItems, mentionPackageItems, mentionConfig],
|
|
322
|
+
);
|
|
323
|
+
|
|
324
|
+
const mentionSeg = useMemo(
|
|
325
|
+
() => parseMentionSegment(mentionQuery, mentionConfig),
|
|
326
|
+
[mentionQuery, mentionConfig],
|
|
327
|
+
);
|
|
328
|
+
|
|
329
|
+
const mentionOpen =
|
|
330
|
+
Boolean(activeRoom) &&
|
|
331
|
+
mentionStart !== null &&
|
|
332
|
+
mentionEntries.length > 0;
|
|
333
|
+
|
|
334
|
+
const canSend =
|
|
335
|
+
draft.trim().length > 0 &&
|
|
336
|
+
!isSending &&
|
|
337
|
+
Boolean(activeRoomId);
|
|
338
|
+
|
|
339
|
+
const setOpenWithNotify = useCallback(
|
|
340
|
+
(next: boolean) => {
|
|
341
|
+
if (next) onUserGesture?.();
|
|
342
|
+
setOpen(next);
|
|
343
|
+
onOpenChange?.(next);
|
|
344
|
+
},
|
|
345
|
+
[onOpenChange, onUserGesture],
|
|
346
|
+
);
|
|
347
|
+
|
|
348
|
+
const handleMessagesScroll = useCallback(() => {
|
|
349
|
+
const node = scrollerRef.current;
|
|
350
|
+
if (!node || !activeRoomId || !onLoadOlderMessages) return;
|
|
351
|
+
if (loadingOlderMessagesRef.current) return;
|
|
352
|
+
if (node.scrollTop > 120) return;
|
|
353
|
+
if (!hasOlderMessages || isLoadingOlder) return;
|
|
354
|
+
loadingOlderMessagesRef.current = true;
|
|
355
|
+
const prevHeight = node.scrollHeight;
|
|
356
|
+
const prevTop = node.scrollTop;
|
|
357
|
+
void Promise.resolve(onLoadOlderMessages()).finally(() => {
|
|
358
|
+
loadingOlderMessagesRef.current = false;
|
|
359
|
+
requestAnimationFrame(() => {
|
|
360
|
+
const n = scrollerRef.current;
|
|
361
|
+
if (!n) return;
|
|
362
|
+
const delta = n.scrollHeight - prevHeight;
|
|
363
|
+
n.scrollTop = prevTop + delta;
|
|
364
|
+
});
|
|
365
|
+
});
|
|
366
|
+
}, [
|
|
367
|
+
activeRoomId,
|
|
368
|
+
hasOlderMessages,
|
|
369
|
+
isLoadingOlder,
|
|
370
|
+
onLoadOlderMessages,
|
|
371
|
+
]);
|
|
372
|
+
|
|
373
|
+
useLayoutEffect(() => {
|
|
374
|
+
if (!open) return;
|
|
375
|
+
const scrollToEnd = () => {
|
|
376
|
+
const node = scrollerRef.current;
|
|
377
|
+
if (!node) return;
|
|
378
|
+
node.scrollTop = node.scrollHeight;
|
|
379
|
+
};
|
|
380
|
+
scrollToEnd();
|
|
381
|
+
let rafNested = 0;
|
|
382
|
+
const rafOuter = requestAnimationFrame(() => {
|
|
383
|
+
scrollToEnd();
|
|
384
|
+
rafNested = requestAnimationFrame(scrollToEnd);
|
|
385
|
+
});
|
|
386
|
+
const timeouts = [0, 50, 150].map((ms) =>
|
|
387
|
+
window.setTimeout(scrollToEnd, ms),
|
|
388
|
+
);
|
|
389
|
+
return () => {
|
|
390
|
+
cancelAnimationFrame(rafOuter);
|
|
391
|
+
if (rafNested) cancelAnimationFrame(rafNested);
|
|
392
|
+
timeouts.forEach(clearTimeout);
|
|
393
|
+
};
|
|
394
|
+
}, [open, activeRoomId, messages.length]);
|
|
395
|
+
|
|
396
|
+
useEffect(() => {
|
|
397
|
+
if (!mentionOpen || mentionEntries.length === 0) return;
|
|
398
|
+
setMentionHighlight((h) => Math.min(h, mentionEntries.length - 1));
|
|
399
|
+
}, [mentionOpen, mentionEntries.length]);
|
|
400
|
+
|
|
401
|
+
useEffect(() => {
|
|
402
|
+
setMentionStart(null);
|
|
403
|
+
setMentionQuery("");
|
|
404
|
+
lastMentionQueryRef.current = "";
|
|
405
|
+
}, [activeRoomId]);
|
|
406
|
+
|
|
407
|
+
function closeMentionMenu() {
|
|
408
|
+
setMentionStart(null);
|
|
409
|
+
setMentionQuery("");
|
|
410
|
+
lastMentionQueryRef.current = "";
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function syncMentionMenu(nextDraft: string, cursor: number) {
|
|
414
|
+
const m = parseSlashMention(nextDraft, cursor);
|
|
415
|
+
if (!m) {
|
|
416
|
+
closeMentionMenu();
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
const entries = buildMentionList(
|
|
420
|
+
m.query,
|
|
421
|
+
mentionGuideItems,
|
|
422
|
+
mentionPackageItems,
|
|
423
|
+
mentionConfig,
|
|
424
|
+
);
|
|
425
|
+
if (entries.length === 0) {
|
|
426
|
+
closeMentionMenu();
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
if (m.query !== lastMentionQueryRef.current) {
|
|
430
|
+
setMentionHighlight(0);
|
|
431
|
+
lastMentionQueryRef.current = m.query;
|
|
432
|
+
}
|
|
433
|
+
setMentionStart(m.start);
|
|
434
|
+
setMentionQuery(m.query);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function replaceSlashSegment(newSegment: string) {
|
|
438
|
+
const el = textareaRef.current;
|
|
439
|
+
if (el === null || mentionStart === null) return;
|
|
440
|
+
const cursor = el.selectionStart ?? draft.length;
|
|
441
|
+
const before = draft.slice(0, mentionStart);
|
|
442
|
+
const after = draft.slice(cursor);
|
|
443
|
+
const newDraft = `${before}/${newSegment}${after}`;
|
|
444
|
+
const slashEnd = before.length + 1 + newSegment.length;
|
|
445
|
+
setDraft(newDraft);
|
|
446
|
+
onDraftChange?.(newDraft);
|
|
447
|
+
setMentionQuery(newSegment);
|
|
448
|
+
lastMentionQueryRef.current = newSegment;
|
|
449
|
+
requestAnimationFrame(() => {
|
|
450
|
+
el.focus();
|
|
451
|
+
el.setSelectionRange(slashEnd, slashEnd);
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function applyCategoryPick(kind: InternalChatMentionKind) {
|
|
456
|
+
replaceSlashSegment(`${kind}:`);
|
|
457
|
+
setMentionHighlight(0);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function applyItemPick(item: InternalChatMentionItem) {
|
|
461
|
+
const el = textareaRef.current;
|
|
462
|
+
if (el === null || mentionStart === null) return;
|
|
463
|
+
const cursor = el.selectionStart ?? draft.length;
|
|
464
|
+
const before = draft.slice(0, mentionStart);
|
|
465
|
+
const after = draft.slice(cursor);
|
|
466
|
+
const token = `@[${item.insertLabel}]`;
|
|
467
|
+
const needsSpace =
|
|
468
|
+
after.length > 0 && !/^\s/.test(after) && !after.startsWith("\n");
|
|
469
|
+
const newDraft = `${before}${token}${needsSpace ? " " : ""}${after}`;
|
|
470
|
+
setDraft(newDraft);
|
|
471
|
+
onDraftChange?.(newDraft);
|
|
472
|
+
closeMentionMenu();
|
|
473
|
+
const newPos = before.length + token.length + (needsSpace ? 1 : 0);
|
|
474
|
+
requestAnimationFrame(() => {
|
|
475
|
+
el.focus();
|
|
476
|
+
el.setSelectionRange(newPos, newPos);
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
async function onSubmit(event: FormEvent<HTMLFormElement>) {
|
|
481
|
+
event.preventDefault();
|
|
482
|
+
if (mentionOpen) return;
|
|
483
|
+
const text = draft.trim();
|
|
484
|
+
if (!text || !activeRoomId) return;
|
|
485
|
+
onUserGesture?.();
|
|
486
|
+
try {
|
|
487
|
+
await onSendMessage(text);
|
|
488
|
+
setDraft("");
|
|
489
|
+
onDraftChange?.("");
|
|
490
|
+
} catch {
|
|
491
|
+
/* errores vía contenedor */
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
async function handleCreateRoom() {
|
|
496
|
+
const name = newRoomName.trim();
|
|
497
|
+
if (!name) return;
|
|
498
|
+
try {
|
|
499
|
+
await onCreateRoom(name);
|
|
500
|
+
setNewRoomName("");
|
|
501
|
+
setCreateOpen(false);
|
|
502
|
+
} catch {
|
|
503
|
+
/* contenedor */
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
async function handleConfirmDeleteRoom() {
|
|
508
|
+
if (!activeRoom) return;
|
|
509
|
+
try {
|
|
510
|
+
await onDeleteRoom(activeRoom.id);
|
|
511
|
+
setDeleteOpen(false);
|
|
512
|
+
} catch {
|
|
513
|
+
/* contenedor */
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function requestDeleteRoom() {
|
|
518
|
+
if (!activeRoom) return;
|
|
519
|
+
if (activeRoom.isOrganization) {
|
|
520
|
+
onDeleteRoomBlocked?.("La sala Organización no se puede eliminar.");
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
if (rooms.length <= 1) {
|
|
524
|
+
onDeleteRoomBlocked?.("Debe existir al menos una sala.");
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
setDeleteOpen(true);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
return (
|
|
531
|
+
<>
|
|
532
|
+
<Button
|
|
533
|
+
type="button"
|
|
534
|
+
size="icon"
|
|
535
|
+
className={cn("relative size-9 text-primary-foreground", className)}
|
|
536
|
+
aria-label={labels.openChat}
|
|
537
|
+
onClick={() => setOpenWithNotify(true)}
|
|
538
|
+
>
|
|
539
|
+
<IconMessages className="size-4" />
|
|
540
|
+
</Button>
|
|
541
|
+
|
|
542
|
+
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
|
543
|
+
<DialogContent className="sm:max-w-md">
|
|
544
|
+
<DialogHeader>
|
|
545
|
+
<DialogTitle>{labels.newRoomTitle}</DialogTitle>
|
|
546
|
+
<DialogDescription>{labels.newRoomDescription}</DialogDescription>
|
|
547
|
+
</DialogHeader>
|
|
548
|
+
<Input
|
|
549
|
+
value={newRoomName}
|
|
550
|
+
onChange={(e) => setNewRoomName(e.target.value)}
|
|
551
|
+
placeholder={labels.newRoomPlaceholder}
|
|
552
|
+
autoFocus
|
|
553
|
+
onKeyDown={(e) => {
|
|
554
|
+
if (e.key === "Enter") {
|
|
555
|
+
e.preventDefault();
|
|
556
|
+
void handleCreateRoom();
|
|
557
|
+
}
|
|
558
|
+
}}
|
|
559
|
+
/>
|
|
560
|
+
<DialogFooter className="gap-2 sm:gap-0">
|
|
561
|
+
<Button
|
|
562
|
+
type="button"
|
|
563
|
+
variant="outline"
|
|
564
|
+
onClick={() => {
|
|
565
|
+
setCreateOpen(false);
|
|
566
|
+
setNewRoomName("");
|
|
567
|
+
}}
|
|
568
|
+
>
|
|
569
|
+
{labels.cancel}
|
|
570
|
+
</Button>
|
|
571
|
+
<Button
|
|
572
|
+
type="button"
|
|
573
|
+
onClick={() => void handleCreateRoom()}
|
|
574
|
+
disabled={isCreatingRoom}
|
|
575
|
+
>
|
|
576
|
+
{labels.create}
|
|
577
|
+
</Button>
|
|
578
|
+
</DialogFooter>
|
|
579
|
+
</DialogContent>
|
|
580
|
+
</Dialog>
|
|
581
|
+
|
|
582
|
+
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
|
583
|
+
<AlertDialogContent>
|
|
584
|
+
<AlertDialogHeader>
|
|
585
|
+
<AlertDialogTitle>{labels.deleteRoomTitle}</AlertDialogTitle>
|
|
586
|
+
<AlertDialogDescription>
|
|
587
|
+
{labels.deleteRoomDescription(activeRoom?.name ?? "la sala")}
|
|
588
|
+
</AlertDialogDescription>
|
|
589
|
+
</AlertDialogHeader>
|
|
590
|
+
<AlertDialogFooter>
|
|
591
|
+
<AlertDialogCancel>{labels.cancel}</AlertDialogCancel>
|
|
592
|
+
<AlertDialogAction
|
|
593
|
+
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
|
594
|
+
onClick={() => void handleConfirmDeleteRoom()}
|
|
595
|
+
disabled={isDeletingRoom}
|
|
596
|
+
>
|
|
597
|
+
{labels.deleteConfirm}
|
|
598
|
+
</AlertDialogAction>
|
|
599
|
+
</AlertDialogFooter>
|
|
600
|
+
</AlertDialogContent>
|
|
601
|
+
</AlertDialog>
|
|
602
|
+
|
|
603
|
+
<Drawer
|
|
604
|
+
open={open}
|
|
605
|
+
onOpenChange={setOpenWithNotify}
|
|
606
|
+
direction="right"
|
|
607
|
+
shouldScaleBackground={false}
|
|
608
|
+
>
|
|
609
|
+
<DrawerContent
|
|
610
|
+
className={cn(
|
|
611
|
+
"gap-0 overflow-hidden p-0 shadow-xl",
|
|
612
|
+
"data-[vaul-drawer-direction=right]:inset-y-3",
|
|
613
|
+
"data-[vaul-drawer-direction=right]:right-3",
|
|
614
|
+
"data-[vaul-drawer-direction=right]:left-auto",
|
|
615
|
+
"data-[vaul-drawer-direction=right]:h-[calc(100%-1.5rem)]",
|
|
616
|
+
"data-[vaul-drawer-direction=right]:max-h-[calc(100%-1.5rem)]",
|
|
617
|
+
"data-[vaul-drawer-direction=right]:w-[min(36rem,calc(100vw-1.5rem))]",
|
|
618
|
+
"data-[vaul-drawer-direction=right]:max-w-[min(36rem,calc(100vw-1.5rem))]",
|
|
619
|
+
"data-[vaul-drawer-direction=right]:sm:max-w-[min(36rem,calc(100vw-1.5rem))]",
|
|
620
|
+
"data-[vaul-drawer-direction=right]:rounded-2xl",
|
|
621
|
+
"data-[vaul-drawer-direction=right]:border",
|
|
622
|
+
)}
|
|
623
|
+
>
|
|
624
|
+
<div className="flex h-full min-h-0 flex-row">
|
|
625
|
+
<div className="flex w-[3.25rem] shrink-0 flex-col items-center gap-2 border-r border-border py-4">
|
|
626
|
+
<Tooltip delayDuration={300}>
|
|
627
|
+
<TooltipTrigger asChild>
|
|
628
|
+
<Button
|
|
629
|
+
type="button"
|
|
630
|
+
variant="ghost"
|
|
631
|
+
size="icon"
|
|
632
|
+
className="size-9 shrink-0 rounded-full"
|
|
633
|
+
aria-label={labels.newRoom}
|
|
634
|
+
onClick={() => setCreateOpen(true)}
|
|
635
|
+
>
|
|
636
|
+
<IconPlus className="size-4" />
|
|
637
|
+
</Button>
|
|
638
|
+
</TooltipTrigger>
|
|
639
|
+
<TooltipContent side="left">{labels.newRoom}</TooltipContent>
|
|
640
|
+
</Tooltip>
|
|
641
|
+
|
|
642
|
+
<div className="flex min-h-0 flex-1 flex-col items-center gap-2 overflow-y-auto px-1 py-1">
|
|
643
|
+
{rooms.map((room) => {
|
|
644
|
+
const selected = room.id === activeRoomId;
|
|
645
|
+
return (
|
|
646
|
+
<Tooltip key={room.id} delayDuration={300}>
|
|
647
|
+
<TooltipTrigger asChild>
|
|
648
|
+
<button
|
|
649
|
+
type="button"
|
|
650
|
+
onClick={() => onActiveRoomChange(room.id)}
|
|
651
|
+
className={cn(
|
|
652
|
+
"relative flex size-9 shrink-0 items-center justify-center rounded-full border-2 transition-colors",
|
|
653
|
+
selected
|
|
654
|
+
? "border-primary bg-primary/15"
|
|
655
|
+
: "border-transparent bg-muted hover:bg-muted/80",
|
|
656
|
+
)}
|
|
657
|
+
aria-label={`Sala ${room.name}${room.isOrganization ? " (toda la organización)" : ""}`}
|
|
658
|
+
aria-current={selected ? "true" : undefined}
|
|
659
|
+
>
|
|
660
|
+
<span className="text-xs font-semibold leading-none">
|
|
661
|
+
{roomInitialLetter(room.name)}
|
|
662
|
+
</span>
|
|
663
|
+
</button>
|
|
664
|
+
</TooltipTrigger>
|
|
665
|
+
<TooltipContent side="left">
|
|
666
|
+
{room.isOrganization
|
|
667
|
+
? `${room.name}${labels.orgRoomTooltip}`
|
|
668
|
+
: room.name}
|
|
669
|
+
</TooltipContent>
|
|
670
|
+
</Tooltip>
|
|
671
|
+
);
|
|
672
|
+
})}
|
|
673
|
+
</div>
|
|
674
|
+
|
|
675
|
+
<DropdownMenu>
|
|
676
|
+
<DropdownMenuTrigger asChild>
|
|
677
|
+
<Button
|
|
678
|
+
type="button"
|
|
679
|
+
variant="ghost"
|
|
680
|
+
size="icon"
|
|
681
|
+
className="size-9 shrink-0 rounded-full"
|
|
682
|
+
aria-label={labels.roomMenu}
|
|
683
|
+
>
|
|
684
|
+
<IconDotsVertical className="size-4" />
|
|
685
|
+
</Button>
|
|
686
|
+
</DropdownMenuTrigger>
|
|
687
|
+
<DropdownMenuContent side="left" align="end" className="w-48">
|
|
688
|
+
<DropdownMenuItem onClick={() => setCreateOpen(true)}>
|
|
689
|
+
<IconPlus className="size-4" />
|
|
690
|
+
{labels.newRoom}
|
|
691
|
+
</DropdownMenuItem>
|
|
692
|
+
<DropdownMenuSeparator />
|
|
693
|
+
<DropdownMenuItem
|
|
694
|
+
disabled={
|
|
695
|
+
rooms.length <= 1 || activeRoom?.isOrganization
|
|
696
|
+
}
|
|
697
|
+
className="text-destructive focus:text-destructive"
|
|
698
|
+
onClick={requestDeleteRoom}
|
|
699
|
+
>
|
|
700
|
+
<IconTrash className="size-4" />
|
|
701
|
+
{labels.deleteRoom}
|
|
702
|
+
</DropdownMenuItem>
|
|
703
|
+
</DropdownMenuContent>
|
|
704
|
+
</DropdownMenu>
|
|
705
|
+
</div>
|
|
706
|
+
|
|
707
|
+
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
|
|
708
|
+
<div className="flex items-start justify-between gap-4 px-5 pt-5 pb-3">
|
|
709
|
+
<div className="flex min-w-0 flex-col gap-1">
|
|
710
|
+
<DrawerTitle className="flex items-center gap-2 font-medium">
|
|
711
|
+
<IconMessages className="size-5 shrink-0" aria-hidden />
|
|
712
|
+
<span className="min-w-0 truncate">{title}</span>
|
|
713
|
+
</DrawerTitle>
|
|
714
|
+
<DrawerDescription className="line-clamp-2">
|
|
715
|
+
{activeRoom ? (
|
|
716
|
+
<>
|
|
717
|
+
<span className="font-medium text-foreground">
|
|
718
|
+
{activeRoom.name}
|
|
719
|
+
</span>
|
|
720
|
+
{activeRoom.isOrganization ? (
|
|
721
|
+
<>{labels.orgRoomSuffix}</>
|
|
722
|
+
) : null}
|
|
723
|
+
{" · "}
|
|
724
|
+
<kbd className="pointer-events-none rounded border bg-muted px-0.5 font-mono text-[0.65rem]">
|
|
725
|
+
/
|
|
726
|
+
</kbd>{" "}
|
|
727
|
+
{labels.slashMentionHint}
|
|
728
|
+
</>
|
|
729
|
+
) : (
|
|
730
|
+
labels.selectRoomHint
|
|
731
|
+
)}
|
|
732
|
+
</DrawerDescription>
|
|
733
|
+
</div>
|
|
734
|
+
<DrawerClose asChild>
|
|
735
|
+
<Button
|
|
736
|
+
type="button"
|
|
737
|
+
variant="outline"
|
|
738
|
+
size="icon"
|
|
739
|
+
className="size-8 shrink-0"
|
|
740
|
+
aria-label={labels.closeChat}
|
|
741
|
+
>
|
|
742
|
+
<IconX className="size-4" />
|
|
743
|
+
</Button>
|
|
744
|
+
</DrawerClose>
|
|
745
|
+
</div>
|
|
746
|
+
|
|
747
|
+
<div
|
|
748
|
+
ref={scrollerRef}
|
|
749
|
+
className="min-h-0 flex-1 overflow-y-auto"
|
|
750
|
+
onScroll={handleMessagesScroll}
|
|
751
|
+
>
|
|
752
|
+
{!activeRoom ? (
|
|
753
|
+
<Empty className="h-full border-0">
|
|
754
|
+
<EmptyHeader>
|
|
755
|
+
<EmptyMedia variant="icon">
|
|
756
|
+
<IconMessages />
|
|
757
|
+
</EmptyMedia>
|
|
758
|
+
<EmptyTitle>{labels.noRoomTitle}</EmptyTitle>
|
|
759
|
+
<EmptyDescription>
|
|
760
|
+
{labels.noRoomDescription}
|
|
761
|
+
</EmptyDescription>
|
|
762
|
+
</EmptyHeader>
|
|
763
|
+
</Empty>
|
|
764
|
+
) : messagesStatus === "loading" ? (
|
|
765
|
+
<div className="flex h-full flex-col items-center justify-center gap-2 text-muted-foreground">
|
|
766
|
+
<IconLoader2 className="size-6 animate-spin" aria-hidden />
|
|
767
|
+
<span className="text-sm">{labels.loadingMessages}</span>
|
|
768
|
+
</div>
|
|
769
|
+
) : messagesStatus === "error" ? (
|
|
770
|
+
<Empty className="h-full border-0">
|
|
771
|
+
<EmptyHeader>
|
|
772
|
+
<EmptyMedia variant="icon">
|
|
773
|
+
<IconMessages />
|
|
774
|
+
</EmptyMedia>
|
|
775
|
+
<EmptyTitle>{labels.loadMessagesErrorTitle}</EmptyTitle>
|
|
776
|
+
<EmptyDescription className="text-left">
|
|
777
|
+
{messagesError ?? labels.loadMessagesErrorFallback}
|
|
778
|
+
</EmptyDescription>
|
|
779
|
+
</EmptyHeader>
|
|
780
|
+
</Empty>
|
|
781
|
+
) : messagesStatus === "empty" ? (
|
|
782
|
+
<Empty className="h-full border-0">
|
|
783
|
+
<EmptyHeader>
|
|
784
|
+
<EmptyMedia variant="icon">
|
|
785
|
+
<IconMessages />
|
|
786
|
+
</EmptyMedia>
|
|
787
|
+
<EmptyTitle>{labels.emptyThreadTitle}</EmptyTitle>
|
|
788
|
+
<EmptyDescription>
|
|
789
|
+
{labels.emptyThreadDescription(activeRoom.name)}
|
|
790
|
+
</EmptyDescription>
|
|
791
|
+
</EmptyHeader>
|
|
792
|
+
</Empty>
|
|
793
|
+
) : (
|
|
794
|
+
<div className="flex flex-col gap-6 px-5 py-2">
|
|
795
|
+
{isLoadingOlder ? (
|
|
796
|
+
<div className="flex items-center justify-center gap-2 py-2 text-xs text-muted-foreground">
|
|
797
|
+
<IconLoader2
|
|
798
|
+
className="size-3.5 shrink-0 animate-spin"
|
|
799
|
+
aria-hidden
|
|
800
|
+
/>
|
|
801
|
+
{labels.loadingOlderMessages}
|
|
802
|
+
</div>
|
|
803
|
+
) : null}
|
|
804
|
+
{messages.map((message) => {
|
|
805
|
+
const mine = message.role === "me";
|
|
806
|
+
return (
|
|
807
|
+
<Message
|
|
808
|
+
key={message.id}
|
|
809
|
+
align={mine ? "end" : "start"}
|
|
810
|
+
>
|
|
811
|
+
{mine ? null : (
|
|
812
|
+
<MessageAvatar className="size-8 text-xs font-medium">
|
|
813
|
+
{message.initials}
|
|
814
|
+
</MessageAvatar>
|
|
815
|
+
)}
|
|
816
|
+
<MessageContent>
|
|
817
|
+
{mine ? null : (
|
|
818
|
+
<MessageHeader>{message.author}</MessageHeader>
|
|
819
|
+
)}
|
|
820
|
+
<Bubble
|
|
821
|
+
align={mine ? "end" : "start"}
|
|
822
|
+
variant={mine ? "muted" : "outline"}
|
|
823
|
+
>
|
|
824
|
+
<BubbleContent className="break-words whitespace-pre-wrap">
|
|
825
|
+
{renderMessageTextWithMentions(message.text)}
|
|
826
|
+
</BubbleContent>
|
|
827
|
+
</Bubble>
|
|
828
|
+
<MessageFooter>
|
|
829
|
+
{formatMessageTime(message.at)}
|
|
830
|
+
</MessageFooter>
|
|
831
|
+
</MessageContent>
|
|
832
|
+
</Message>
|
|
833
|
+
);
|
|
834
|
+
})}
|
|
835
|
+
</div>
|
|
836
|
+
)}
|
|
837
|
+
{activeRoom && typingLine ? (
|
|
838
|
+
<div
|
|
839
|
+
className="border-t border-border px-5 py-2 text-xs text-muted-foreground"
|
|
840
|
+
aria-live="polite"
|
|
841
|
+
>
|
|
842
|
+
{typingLine}
|
|
843
|
+
</div>
|
|
844
|
+
) : null}
|
|
845
|
+
</div>
|
|
846
|
+
|
|
847
|
+
<form
|
|
848
|
+
ref={formRef}
|
|
849
|
+
onSubmit={onSubmit}
|
|
850
|
+
className="relative px-5 pt-2 pb-5"
|
|
851
|
+
>
|
|
852
|
+
{mentionOpen ? (
|
|
853
|
+
<div
|
|
854
|
+
role="listbox"
|
|
855
|
+
aria-label={labels.mentionListLabel}
|
|
856
|
+
className="absolute bottom-full left-5 right-5 z-50 mb-1 flex max-h-60 flex-col overflow-hidden rounded-xl border border-border bg-popover text-popover-foreground shadow-md"
|
|
857
|
+
>
|
|
858
|
+
<div className="shrink-0 border-b border-border px-2 py-1.5 text-[0.7rem] text-muted-foreground">
|
|
859
|
+
{mentionSeg.phase === "category" ? (
|
|
860
|
+
<>
|
|
861
|
+
<span className="font-medium text-foreground">
|
|
862
|
+
Tipo
|
|
863
|
+
</span>
|
|
864
|
+
{" · "}
|
|
865
|
+
<kbd className="rounded bg-muted px-1 font-mono text-[0.65rem]">
|
|
866
|
+
Tab
|
|
867
|
+
</kbd>
|
|
868
|
+
/
|
|
869
|
+
<kbd className="rounded bg-muted px-1 font-mono text-[0.65rem]">
|
|
870
|
+
↵
|
|
871
|
+
</kbd>
|
|
872
|
+
</>
|
|
873
|
+
) : (
|
|
874
|
+
<>
|
|
875
|
+
<span className="font-medium text-foreground">
|
|
876
|
+
{DEFAULT_MENTION_KIND_LABEL[mentionSeg.kind]}
|
|
877
|
+
</span>
|
|
878
|
+
{" · "}
|
|
879
|
+
<kbd className="rounded bg-muted px-1 font-mono text-[0.65rem]">
|
|
880
|
+
←
|
|
881
|
+
</kbd>
|
|
882
|
+
</>
|
|
883
|
+
)}
|
|
884
|
+
</div>
|
|
885
|
+
<div className="min-h-0 flex-1 overflow-y-auto p-1">
|
|
886
|
+
{mentionEntries.map((entry, index) => {
|
|
887
|
+
if (entry.type === "category") {
|
|
888
|
+
const IconComp = entry.icon;
|
|
889
|
+
return (
|
|
890
|
+
<button
|
|
891
|
+
key={`cat-${entry.kind}`}
|
|
892
|
+
type="button"
|
|
893
|
+
role="option"
|
|
894
|
+
aria-selected={index === mentionHighlight}
|
|
895
|
+
className={cn(
|
|
896
|
+
"flex w-full items-start gap-2 rounded-lg px-2 py-2 text-left text-sm",
|
|
897
|
+
index === mentionHighlight
|
|
898
|
+
? "bg-accent text-accent-foreground"
|
|
899
|
+
: "hover:bg-muted/80",
|
|
900
|
+
)}
|
|
901
|
+
onMouseEnter={() => setMentionHighlight(index)}
|
|
902
|
+
onMouseDown={(event) => {
|
|
903
|
+
event.preventDefault();
|
|
904
|
+
applyCategoryPick(entry.kind);
|
|
905
|
+
}}
|
|
906
|
+
>
|
|
907
|
+
<IconComp className="mt-0.5 size-4 shrink-0 opacity-80" />
|
|
908
|
+
<span className="min-w-0 flex-1">
|
|
909
|
+
<span className="block font-medium leading-tight">
|
|
910
|
+
{entry.label}
|
|
911
|
+
</span>
|
|
912
|
+
<span className="mt-0.5 block text-xs text-muted-foreground">
|
|
913
|
+
{entry.hint}
|
|
914
|
+
</span>
|
|
915
|
+
</span>
|
|
916
|
+
</button>
|
|
917
|
+
);
|
|
918
|
+
}
|
|
919
|
+
const item = entry.data;
|
|
920
|
+
const IconComp = item.icon;
|
|
921
|
+
return (
|
|
922
|
+
<button
|
|
923
|
+
key={item.id}
|
|
924
|
+
type="button"
|
|
925
|
+
role="option"
|
|
926
|
+
aria-selected={index === mentionHighlight}
|
|
927
|
+
className={cn(
|
|
928
|
+
"flex w-full items-start gap-2 rounded-lg px-2 py-2 text-left text-sm",
|
|
929
|
+
index === mentionHighlight
|
|
930
|
+
? "bg-accent text-accent-foreground"
|
|
931
|
+
: "hover:bg-muted/80",
|
|
932
|
+
)}
|
|
933
|
+
onMouseEnter={() => setMentionHighlight(index)}
|
|
934
|
+
onMouseDown={(event) => {
|
|
935
|
+
event.preventDefault();
|
|
936
|
+
applyItemPick(item);
|
|
937
|
+
}}
|
|
938
|
+
>
|
|
939
|
+
<IconComp className="mt-0.5 size-4 shrink-0 opacity-80" />
|
|
940
|
+
<span className="min-w-0 flex-1">
|
|
941
|
+
<span className="block font-medium leading-tight">
|
|
942
|
+
{item.label}
|
|
943
|
+
</span>
|
|
944
|
+
<span className="mt-0.5 block text-xs text-muted-foreground">
|
|
945
|
+
{item.subtitle}
|
|
946
|
+
</span>
|
|
947
|
+
</span>
|
|
948
|
+
</button>
|
|
949
|
+
);
|
|
950
|
+
})}
|
|
951
|
+
</div>
|
|
952
|
+
</div>
|
|
953
|
+
) : null}
|
|
954
|
+
|
|
955
|
+
<InputGroup>
|
|
956
|
+
<InputGroupTextarea
|
|
957
|
+
ref={textareaRef}
|
|
958
|
+
value={draft}
|
|
959
|
+
onChange={(event) => {
|
|
960
|
+
const v = event.target.value;
|
|
961
|
+
const cursor =
|
|
962
|
+
event.target.selectionStart ?? v.length;
|
|
963
|
+
setDraft(v);
|
|
964
|
+
onDraftChange?.(v);
|
|
965
|
+
syncMentionMenu(v, cursor);
|
|
966
|
+
}}
|
|
967
|
+
onSelect={(event) => {
|
|
968
|
+
const t = event.target as HTMLTextAreaElement;
|
|
969
|
+
syncMentionMenu(
|
|
970
|
+
t.value,
|
|
971
|
+
t.selectionStart ?? t.value.length,
|
|
972
|
+
);
|
|
973
|
+
}}
|
|
974
|
+
placeholder={
|
|
975
|
+
activeRoom
|
|
976
|
+
? labels.messagePlaceholder
|
|
977
|
+
: labels.messagePlaceholderNoRoom
|
|
978
|
+
}
|
|
979
|
+
aria-label={labels.messageAriaLabel}
|
|
980
|
+
className="min-h-14"
|
|
981
|
+
disabled={!activeRoom}
|
|
982
|
+
onKeyDown={(event) => {
|
|
983
|
+
onUserGesture?.();
|
|
984
|
+
if (mentionOpen) {
|
|
985
|
+
if (event.key === "ArrowDown") {
|
|
986
|
+
event.preventDefault();
|
|
987
|
+
setMentionHighlight((h) =>
|
|
988
|
+
(h + 1) % mentionEntries.length,
|
|
989
|
+
);
|
|
990
|
+
return;
|
|
991
|
+
}
|
|
992
|
+
if (event.key === "ArrowUp") {
|
|
993
|
+
event.preventDefault();
|
|
994
|
+
setMentionHighlight((h) =>
|
|
995
|
+
(h - 1 + mentionEntries.length) %
|
|
996
|
+
mentionEntries.length,
|
|
997
|
+
);
|
|
998
|
+
return;
|
|
999
|
+
}
|
|
1000
|
+
if (event.key === "Backspace") {
|
|
1001
|
+
const seg = parseMentionSegment(
|
|
1002
|
+
mentionQuery,
|
|
1003
|
+
mentionConfig,
|
|
1004
|
+
);
|
|
1005
|
+
if (seg.phase === "item" && seg.filter === "") {
|
|
1006
|
+
event.preventDefault();
|
|
1007
|
+
replaceSlashSegment("");
|
|
1008
|
+
setMentionHighlight(0);
|
|
1009
|
+
return;
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
if (event.key === "ArrowLeft") {
|
|
1013
|
+
const seg = parseMentionSegment(
|
|
1014
|
+
mentionQuery,
|
|
1015
|
+
mentionConfig,
|
|
1016
|
+
);
|
|
1017
|
+
if (seg.phase === "item" && seg.filter === "") {
|
|
1018
|
+
event.preventDefault();
|
|
1019
|
+
replaceSlashSegment("");
|
|
1020
|
+
setMentionHighlight(0);
|
|
1021
|
+
return;
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
if (event.key === "Enter" && !event.shiftKey) {
|
|
1025
|
+
event.preventDefault();
|
|
1026
|
+
const pick = mentionEntries[mentionHighlight];
|
|
1027
|
+
if (!pick) return;
|
|
1028
|
+
if (pick.type === "category") {
|
|
1029
|
+
applyCategoryPick(pick.kind);
|
|
1030
|
+
} else {
|
|
1031
|
+
applyItemPick(pick.data);
|
|
1032
|
+
}
|
|
1033
|
+
return;
|
|
1034
|
+
}
|
|
1035
|
+
if (event.key === "Tab") {
|
|
1036
|
+
event.preventDefault();
|
|
1037
|
+
const pick = mentionEntries[mentionHighlight];
|
|
1038
|
+
if (!pick) return;
|
|
1039
|
+
if (pick.type === "category") {
|
|
1040
|
+
applyCategoryPick(pick.kind);
|
|
1041
|
+
} else {
|
|
1042
|
+
applyItemPick(pick.data);
|
|
1043
|
+
}
|
|
1044
|
+
return;
|
|
1045
|
+
}
|
|
1046
|
+
if (event.key === "Escape") {
|
|
1047
|
+
event.preventDefault();
|
|
1048
|
+
closeMentionMenu();
|
|
1049
|
+
return;
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
if (event.key === "Enter" && !event.shiftKey) {
|
|
1053
|
+
event.preventDefault();
|
|
1054
|
+
formRef.current?.requestSubmit();
|
|
1055
|
+
}
|
|
1056
|
+
}}
|
|
1057
|
+
/>
|
|
1058
|
+
<InputGroupAddon align="block-end" className="pt-1">
|
|
1059
|
+
<DropdownMenu>
|
|
1060
|
+
<DropdownMenuTrigger asChild>
|
|
1061
|
+
<InputGroupButton
|
|
1062
|
+
aria-label={labels.attachAriaLabel}
|
|
1063
|
+
type="button"
|
|
1064
|
+
size="icon-sm"
|
|
1065
|
+
variant="outline"
|
|
1066
|
+
disabled={!activeRoom}
|
|
1067
|
+
>
|
|
1068
|
+
<IconPlus className="size-4" />
|
|
1069
|
+
</InputGroupButton>
|
|
1070
|
+
</DropdownMenuTrigger>
|
|
1071
|
+
<DropdownMenuContent
|
|
1072
|
+
align="start"
|
|
1073
|
+
side="top"
|
|
1074
|
+
className="w-44"
|
|
1075
|
+
>
|
|
1076
|
+
<DropdownMenuItem>
|
|
1077
|
+
<IconPaperclip className="size-4" />
|
|
1078
|
+
{labels.attachPhotos}
|
|
1079
|
+
</DropdownMenuItem>
|
|
1080
|
+
</DropdownMenuContent>
|
|
1081
|
+
</DropdownMenu>
|
|
1082
|
+
<InputGroupButton
|
|
1083
|
+
type="submit"
|
|
1084
|
+
variant="default"
|
|
1085
|
+
size="icon-sm"
|
|
1086
|
+
disabled={!canSend || !activeRoom}
|
|
1087
|
+
className="ml-auto"
|
|
1088
|
+
>
|
|
1089
|
+
<IconArrowUp className="size-4" />
|
|
1090
|
+
<span className="sr-only">{labels.sendSrOnly}</span>
|
|
1091
|
+
</InputGroupButton>
|
|
1092
|
+
</InputGroupAddon>
|
|
1093
|
+
</InputGroup>
|
|
1094
|
+
</form>
|
|
1095
|
+
</div>
|
|
1096
|
+
</div>
|
|
1097
|
+
</DrawerContent>
|
|
1098
|
+
</Drawer>
|
|
1099
|
+
</>
|
|
1100
|
+
);
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
export {
|
|
1104
|
+
InternalChatPanel,
|
|
1105
|
+
DEFAULT_MENTION_CONFIG,
|
|
1106
|
+
DEFAULT_LABELS as DEFAULT_INTERNAL_CHAT_PANEL_LABELS,
|
|
1107
|
+
};
|
|
1108
|
+
|
|
1109
|
+
export { initialsFromDisplayName } from "./internal-chat-mention";
|
|
1110
|
+
|
|
1111
|
+
export type {
|
|
1112
|
+
InternalChatMentionConfig,
|
|
1113
|
+
InternalChatMentionItem,
|
|
1114
|
+
InternalChatMentionKind,
|
|
1115
|
+
} from "./internal-chat-mention";
|