@anakin824/prdg-chat-ui 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs ADDED
@@ -0,0 +1,1922 @@
1
+ import { useQuery, QueryClient, QueryClientProvider, useQueryClient, useMutation } from '@tanstack/react-query';
2
+ import { createContext, useContext, useMemo, useRef, useEffect, useCallback, useState, useLayoutEffect } from 'react';
3
+ import { AnimatePresence, motion } from 'framer-motion';
4
+ import { format, parseISO } from 'date-fns';
5
+ import Lightbox from 'yet-another-react-lightbox';
6
+ import 'yet-another-react-lightbox/styles.css';
7
+ import WaveSurfer from 'wavesurfer.js';
8
+
9
+ // src/chat/provider/ChatProvider.tsx
10
+
11
+ // src/chat/lib/api.ts
12
+ var ChatAPI = class {
13
+ constructor(baseUrl, getToken, onAuthError) {
14
+ this.baseUrl = baseUrl;
15
+ this.getToken = getToken;
16
+ this.onAuthError = onAuthError;
17
+ }
18
+ baseUrl;
19
+ getToken;
20
+ onAuthError;
21
+ headers(tokenOverride) {
22
+ const t = tokenOverride ?? this.getToken();
23
+ const h = { "Content-Type": "application/json" };
24
+ if (t) h.Authorization = `Bearer ${t}`;
25
+ return h;
26
+ }
27
+ async json(path, init) {
28
+ let res = await fetch(`${this.baseUrl}${path}`, {
29
+ ...init,
30
+ headers: { ...this.headers(), ...init?.headers || {} }
31
+ });
32
+ if (res.status === 401 && this.onAuthError) {
33
+ const newToken = await this.onAuthError();
34
+ if (newToken) {
35
+ res = await fetch(`${this.baseUrl}${path}`, {
36
+ ...init,
37
+ headers: { ...this.headers(newToken), ...init?.headers || {} }
38
+ });
39
+ }
40
+ }
41
+ if (!res.ok) {
42
+ const err = await res.text();
43
+ throw new Error(err || res.statusText);
44
+ }
45
+ return res.json();
46
+ }
47
+ listConversations(cursor, limit = 50) {
48
+ const q = new URLSearchParams();
49
+ if (cursor) q.set("cursor", cursor);
50
+ q.set("limit", String(limit));
51
+ return this.json(`/conversations?${q}`);
52
+ }
53
+ listMessages(conversationId, cursor, limit = 50) {
54
+ const q = new URLSearchParams();
55
+ if (cursor) q.set("cursor", cursor);
56
+ q.set("limit", String(limit));
57
+ return this.json(`/conversations/${conversationId}/messages?${q}`);
58
+ }
59
+ findOrCreateDirect(peerUserId) {
60
+ return this.json("/conversations/direct", {
61
+ method: "POST",
62
+ body: JSON.stringify({ peer_user_id: peerUserId })
63
+ });
64
+ }
65
+ createGroup(title, members) {
66
+ return this.json("/conversations/group", {
67
+ method: "POST",
68
+ body: JSON.stringify({ title, members })
69
+ });
70
+ }
71
+ findOrCreateEntity(input) {
72
+ return this.json("/conversations/entity", {
73
+ method: "POST",
74
+ body: JSON.stringify({
75
+ title: input.title,
76
+ entity_ref: input.entity_ref,
77
+ entity_uuid: input.entity_uuid,
78
+ members: input.members ?? []
79
+ })
80
+ });
81
+ }
82
+ listConversationMembers(conversationId) {
83
+ return this.json(`/conversations/${conversationId}/members`);
84
+ }
85
+ listContacts(q, limit = 50) {
86
+ const params = new URLSearchParams();
87
+ params.set("limit", String(limit));
88
+ if (q) params.set("q", q);
89
+ return this.json(`/users?${params}`);
90
+ }
91
+ sendMessage(conversationId, body) {
92
+ return this.json(`/conversations/${conversationId}/messages`, {
93
+ method: "POST",
94
+ body: JSON.stringify(body)
95
+ });
96
+ }
97
+ presignPut(filename, contentType) {
98
+ return this.json("/storage/presign-put", {
99
+ method: "POST",
100
+ body: JSON.stringify({ filename, content_type: contentType })
101
+ });
102
+ }
103
+ presignGet(key) {
104
+ return this.json("/storage/presign-get", {
105
+ method: "POST",
106
+ body: JSON.stringify({ key })
107
+ });
108
+ }
109
+ };
110
+
111
+ // src/chat/lib/queryKeys.ts
112
+ var chatKeys = {
113
+ all: ["chat"],
114
+ conversations: () => [...chatKeys.all, "conversations"],
115
+ messages: (conversationId) => [...chatKeys.all, "messages", conversationId],
116
+ members: (conversationId) => [...chatKeys.all, "members", conversationId],
117
+ contacts: (q) => [...chatKeys.all, "contacts", q]
118
+ };
119
+ var ChatContext = createContext(null);
120
+ function useChat() {
121
+ const v = useContext(ChatContext);
122
+ if (!v) throw new Error("useChat must be used within ChatProvider");
123
+ return v;
124
+ }
125
+
126
+ // src/chat/hooks/useConversations.ts
127
+ function useConversations() {
128
+ const { api, wsConnected, config } = useChat();
129
+ const interval = config.pollIntervalMs ?? 3e4;
130
+ return useQuery({
131
+ queryKey: chatKeys.conversations(),
132
+ queryFn: () => api.listConversations(void 0, 50),
133
+ refetchInterval: wsConnected ? false : interval
134
+ });
135
+ }
136
+
137
+ // src/chat/lib/natsRealtime.ts
138
+ function natsUrlToWebSocketUrl(url) {
139
+ const u = url.trim();
140
+ if (!u) return u;
141
+ if (/^nats:\/\//i.test(u)) return u.replace(/^nats:\/\//i, "ws://");
142
+ if (/^tls:\/\//i.test(u)) return u.replace(/^tls:\/\//i, "wss://");
143
+ return u;
144
+ }
145
+ function parseNatsWebSocketServers(raw) {
146
+ return raw.split(",").map((s) => natsUrlToWebSocketUrl(s)).filter((s) => s.length > 0);
147
+ }
148
+ function userInboxSubject(natsTenantId, userId) {
149
+ return `chat.${natsTenantId}.user.${userId}`;
150
+ }
151
+ function entityConversationSubject(natsTenantId, conversationId) {
152
+ return `chat.${natsTenantId}.conversation.${conversationId}`;
153
+ }
154
+ function invalidateQueriesFromNatsPayload(payload, queryClient) {
155
+ if (!payload || typeof payload !== "object") return;
156
+ const p = payload;
157
+ if (p.type !== "chat.message.new") return;
158
+ const ed = p.event_data;
159
+ if (!ed || typeof ed !== "object") return;
160
+ const convId = ed.conversation_id;
161
+ if (typeof convId !== "string" || !convId.trim()) return;
162
+ void queryClient.invalidateQueries({ queryKey: chatKeys.messages(convId) });
163
+ void queryClient.invalidateQueries({ queryKey: chatKeys.conversations() });
164
+ }
165
+
166
+ // src/chat/provider/ChatNatsBridge.tsx
167
+ function ChatNatsBridge({ natsWsUrl, natsToken, onConnectedChange }) {
168
+ const { natsTenantId, userId } = useChat();
169
+ const queryClient = useQueryClient();
170
+ const { data: convData } = useConversations();
171
+ const entityConversationIds = useMemo(() => {
172
+ const items = convData?.items ?? [];
173
+ return items.filter((c) => String(c.kind).toLowerCase() === "entity").map((c) => c.id).sort();
174
+ }, [convData?.items]);
175
+ const entityKey = entityConversationIds.join(",");
176
+ const servers = useMemo(() => parseNatsWebSocketServers(natsWsUrl), [natsWsUrl]);
177
+ const onConnectedChangeRef = useRef(onConnectedChange);
178
+ onConnectedChangeRef.current = onConnectedChange;
179
+ const connRef = useRef(null);
180
+ useEffect(() => {
181
+ if (servers.length === 0) return;
182
+ let cancelled = false;
183
+ void (async () => {
184
+ try {
185
+ const { connect, JSONCodec } = await import('nats.ws');
186
+ const jc = JSONCodec();
187
+ const opts = {
188
+ servers,
189
+ maxReconnectAttempts: -1,
190
+ name: `prdg-chat-web:${userId}`
191
+ };
192
+ if (natsToken?.trim()) opts.token = natsToken.trim();
193
+ const conn = await connect(opts);
194
+ if (cancelled) {
195
+ await conn.drain();
196
+ return;
197
+ }
198
+ connRef.current = conn;
199
+ onConnectedChangeRef.current(true);
200
+ const handleMsg = (data, subject) => {
201
+ if (process.env.NODE_ENV === "development") {
202
+ console.debug("[NATS] raw message received on subject:", subject, "bytes:", data.length);
203
+ }
204
+ let payload;
205
+ try {
206
+ payload = jc.decode(data);
207
+ } catch (decodeErr) {
208
+ if (process.env.NODE_ENV === "development") {
209
+ console.error("[NATS] JSONCodec decode failed:", decodeErr, "raw:", new TextDecoder().decode(data));
210
+ }
211
+ return;
212
+ }
213
+ if (process.env.NODE_ENV === "development") {
214
+ console.debug("[NATS] decoded payload:", payload);
215
+ }
216
+ invalidateQueriesFromNatsPayload(payload, queryClient);
217
+ if (process.env.NODE_ENV === "development") {
218
+ if (payload && typeof payload === "object" && payload.type === "chat.message.new") {
219
+ const p = payload;
220
+ const ed = p.event_data;
221
+ const rawTs = typeof ed?.timestamp_utc === "string" ? ed.timestamp_utc : null;
222
+ const localTime = rawTs ? new Date(rawTs).toLocaleTimeString(void 0, {
223
+ hour: "numeric",
224
+ minute: "2-digit",
225
+ hour12: true
226
+ }) : (/* @__PURE__ */ new Date()).toLocaleTimeString(void 0, { hour: "numeric", minute: "2-digit", hour12: true });
227
+ console.log(
228
+ `%c[NATS] chat.message.new`,
229
+ "color:#7c3aed;font-weight:bold",
230
+ `
231
+ subject: ${subject}`,
232
+ `
233
+ conversation: ${typeof ed?.conversation_id === "string" ? ed.conversation_id : "?"}`,
234
+ `
235
+ sender: ${typeof ed?.sender_id === "string" ? ed.sender_id : "?"}`,
236
+ `
237
+ body: ${typeof ed?.body === "string" ? ed.body.slice(0, 200) : "(no body)"}`,
238
+ `
239
+ time (local): ${localTime}`
240
+ );
241
+ } else {
242
+ console.debug(
243
+ "[NATS] message received \u2014 unexpected type:",
244
+ typeof payload === "object" && payload ? payload.type : typeof payload,
245
+ payload
246
+ );
247
+ }
248
+ }
249
+ };
250
+ const inbox = userInboxSubject(natsTenantId, userId);
251
+ const subInbox = conn.subscribe(inbox);
252
+ void (async () => {
253
+ for await (const m of subInbox) {
254
+ if (cancelled) break;
255
+ handleMsg(m.data, m.subject);
256
+ }
257
+ })();
258
+ for (const convId of entityConversationIds) {
259
+ const subj = entityConversationSubject(natsTenantId, convId);
260
+ const sub = conn.subscribe(subj);
261
+ void (async () => {
262
+ for await (const m of sub) {
263
+ if (cancelled) break;
264
+ handleMsg(m.data, m.subject);
265
+ }
266
+ })();
267
+ }
268
+ if (process.env.NODE_ENV === "development") {
269
+ const subjectList = [
270
+ inbox,
271
+ ...entityConversationIds.map((id) => entityConversationSubject(natsTenantId, id))
272
+ ];
273
+ console.info("[NATS] WebSocket connected \u2014 servers:", servers, "subjects:", subjectList);
274
+ void (async () => {
275
+ for await (const s of conn.status()) {
276
+ if (cancelled) break;
277
+ console.debug("[NATS] connection status:", s.type, s.data ?? "");
278
+ }
279
+ })();
280
+ }
281
+ await conn.closed();
282
+ connRef.current = null;
283
+ if (!cancelled) onConnectedChangeRef.current(false);
284
+ } catch (err) {
285
+ connRef.current = null;
286
+ if (process.env.NODE_ENV === "development") {
287
+ console.error("[NATS] WebSocket connect failed \u2014 check ws:// URL, NATS websocket block, and CORS/mixed content:", err);
288
+ }
289
+ if (!cancelled) onConnectedChangeRef.current(false);
290
+ }
291
+ })();
292
+ return () => {
293
+ cancelled = true;
294
+ onConnectedChangeRef.current(false);
295
+ const c = connRef.current;
296
+ connRef.current = null;
297
+ void c?.drain();
298
+ };
299
+ }, [servers, natsToken, natsTenantId, userId, entityKey, queryClient, entityConversationIds]);
300
+ return null;
301
+ }
302
+
303
+ // src/chat/provider/ChatProvider.tsx
304
+ var defaultTheme = {
305
+ primary: "#0a84ff",
306
+ bubbleSent: "#0a84ff",
307
+ bubbleReceived: "#2c2c2e",
308
+ textOnSent: "#ffffff",
309
+ textOnReceived: "#f5f5f7",
310
+ radius: "18px",
311
+ fontFamily: "system-ui, -apple-system, sans-serif",
312
+ surface: "#1c1c1e",
313
+ border: "#3a3a3c",
314
+ mutedFill: "rgba(0, 0, 0, 0.2)",
315
+ composerShadow: "0 2px 16px rgba(0, 0, 0, 0.38)"
316
+ };
317
+ function ChatProvider({
318
+ apiUrl,
319
+ token,
320
+ userId,
321
+ tenantId,
322
+ conduitlyTenantId,
323
+ theme,
324
+ pollIntervalMs = 3e4,
325
+ onUnreadChange: _onUnreadChange,
326
+ onTokenRefresh,
327
+ callEnabled = false,
328
+ natsWsUrl,
329
+ natsToken,
330
+ children
331
+ }) {
332
+ const queryClient = useMemo(
333
+ () => new QueryClient({
334
+ defaultOptions: {
335
+ queries: { staleTime: 5e3 }
336
+ }
337
+ }),
338
+ []
339
+ );
340
+ const tokenRef = useRef(token);
341
+ useEffect(() => {
342
+ tokenRef.current = token;
343
+ }, [token]);
344
+ const handleAuthError = useCallback(async () => {
345
+ if (!onTokenRefresh) return null;
346
+ const newToken = await onTokenRefresh();
347
+ if (newToken) tokenRef.current = newToken;
348
+ return newToken;
349
+ }, [onTokenRefresh]);
350
+ const api = useMemo(
351
+ () => new ChatAPI(apiUrl.replace(/\/$/, ""), () => tokenRef.current, handleAuthError),
352
+ [apiUrl, handleAuthError]
353
+ );
354
+ const mergedTheme = useMemo(() => ({ ...defaultTheme, ...theme }), [theme]);
355
+ const styleTag = useMemo(() => {
356
+ const t = mergedTheme;
357
+ return `[data-chat-root] {
358
+ --chat-primary: ${t.primary};
359
+ --chat-bubble-sent: ${t.bubbleSent};
360
+ --chat-bubble-received: ${t.bubbleReceived};
361
+ --chat-text-on-sent: ${t.textOnSent};
362
+ --chat-text-on-received: ${t.textOnReceived};
363
+ --chat-radius: ${t.radius};
364
+ --chat-font-family: ${t.fontFamily};
365
+ --chat-surface: ${t.surface};
366
+ --chat-border: ${t.border};
367
+ --chat-muted-fill: ${t.mutedFill};
368
+ ${t.composerShadow ? `--chat-composer-shadow: ${t.composerShadow};` : ""}
369
+ }`;
370
+ }, [mergedTheme]);
371
+ const [natsConnected, setNatsConnected] = useState(false);
372
+ const natsUrl = natsWsUrl?.trim() ?? "";
373
+ const wsConnected = Boolean(natsUrl) && natsConnected;
374
+ const natsTenantId = useMemo(() => {
375
+ const c = conduitlyTenantId?.trim();
376
+ if (c) return c;
377
+ if (process.env.NODE_ENV === "development" && natsUrl) {
378
+ console.warn(
379
+ "[NATS] Missing conduitly_tenant_id \u2014 subjects will not match Conduitly. Set `conduitly_tenant_id` in public/dev.json or NEXT_PUBLIC_CONDUITLY_TENANT_ID (e.g. hdmme-20261703)."
380
+ );
381
+ }
382
+ return tenantId.trim();
383
+ }, [conduitlyTenantId, tenantId, natsUrl]);
384
+ const typingByConversation = useMemo(() => ({}), []);
385
+ const sendTyping = useCallback((_conversationId) => {
386
+ }, []);
387
+ const sendTypingStop = useCallback((_conversationId) => {
388
+ }, []);
389
+ const sendRead = useCallback((_conversationId, _messageId) => {
390
+ }, []);
391
+ const config = useMemo(
392
+ () => ({
393
+ apiUrl: apiUrl.replace(/\/$/, ""),
394
+ token,
395
+ userId,
396
+ tenantId,
397
+ pollIntervalMs,
398
+ callEnabled: Boolean(callEnabled)
399
+ }),
400
+ [apiUrl, token, userId, tenantId, pollIntervalMs, callEnabled]
401
+ );
402
+ const value = useMemo(
403
+ () => ({
404
+ api,
405
+ config,
406
+ theme: mergedTheme,
407
+ queryClient,
408
+ userId,
409
+ tenantId,
410
+ natsTenantId,
411
+ wsConnected,
412
+ sendTyping,
413
+ sendTypingStop,
414
+ sendRead,
415
+ typingByConversation
416
+ }),
417
+ [
418
+ api,
419
+ config,
420
+ mergedTheme,
421
+ queryClient,
422
+ userId,
423
+ tenantId,
424
+ natsTenantId,
425
+ wsConnected,
426
+ sendTyping,
427
+ sendTypingStop,
428
+ sendRead,
429
+ typingByConversation
430
+ ]
431
+ );
432
+ const layoutStyle = {
433
+ flex: 1,
434
+ minHeight: 0,
435
+ display: "flex",
436
+ flexDirection: "column",
437
+ boxSizing: "border-box"
438
+ };
439
+ return /* @__PURE__ */ React.createElement(QueryClientProvider, { client: queryClient }, /* @__PURE__ */ React.createElement(ChatContext.Provider, { value }, /* @__PURE__ */ React.createElement("div", { style: layoutStyle }, natsUrl ? /* @__PURE__ */ React.createElement(ChatNatsBridge, { natsWsUrl: natsUrl, natsToken, onConnectedChange: setNatsConnected }) : null, /* @__PURE__ */ React.createElement("style", { dangerouslySetInnerHTML: { __html: styleTag } }), /* @__PURE__ */ React.createElement("div", { "data-chat-root": true, style: { ...layoutStyle, flex: 1 } }, children))));
440
+ }
441
+ function useChatActions() {
442
+ const { api } = useChat();
443
+ const queryClient = useQueryClient();
444
+ const openOrCreateDirect = useCallback(
445
+ async (peerUserId) => {
446
+ const c = await api.findOrCreateDirect(peerUserId);
447
+ await queryClient.invalidateQueries({ queryKey: chatKeys.conversations() });
448
+ await queryClient.invalidateQueries({ queryKey: chatKeys.messages(c.id) });
449
+ return c;
450
+ },
451
+ [api, queryClient]
452
+ );
453
+ const createGroup = useCallback(
454
+ async (title, members) => {
455
+ const c = await api.createGroup(title, members);
456
+ await queryClient.invalidateQueries({ queryKey: chatKeys.conversations() });
457
+ await queryClient.invalidateQueries({ queryKey: chatKeys.messages(c.id) });
458
+ return c;
459
+ },
460
+ [api, queryClient]
461
+ );
462
+ const findOrCreateEntity = useCallback(
463
+ async (input) => {
464
+ const c = await api.findOrCreateEntity(input);
465
+ await queryClient.invalidateQueries({ queryKey: chatKeys.conversations() });
466
+ await queryClient.invalidateQueries({ queryKey: chatKeys.messages(c.id) });
467
+ return c;
468
+ },
469
+ [api, queryClient]
470
+ );
471
+ const invalidateConversations = useCallback(() => {
472
+ return queryClient.invalidateQueries({ queryKey: chatKeys.conversations() });
473
+ }, [queryClient]);
474
+ const invalidateMessages = useCallback(
475
+ (conversationId) => {
476
+ return queryClient.invalidateQueries({ queryKey: chatKeys.messages(conversationId) });
477
+ },
478
+ [queryClient]
479
+ );
480
+ const invalidateMembers = useCallback(
481
+ (conversationId) => {
482
+ return queryClient.invalidateQueries({ queryKey: chatKeys.members(conversationId) });
483
+ },
484
+ [queryClient]
485
+ );
486
+ const invalidateAllChat = useCallback(() => {
487
+ return queryClient.invalidateQueries({ queryKey: chatKeys.all });
488
+ }, [queryClient]);
489
+ return {
490
+ api,
491
+ openOrCreateDirect,
492
+ createGroup,
493
+ findOrCreateEntity,
494
+ invalidateConversations,
495
+ invalidateMessages,
496
+ invalidateMembers,
497
+ invalidateAllChat
498
+ };
499
+ }
500
+ function useConversationMembers(conversationId) {
501
+ const { api } = useChat();
502
+ return useQuery({
503
+ queryKey: conversationId ? chatKeys.members(conversationId) : ["chat", "members", "none"],
504
+ queryFn: () => api.listConversationMembers(conversationId),
505
+ enabled: !!conversationId,
506
+ staleTime: 6e4
507
+ });
508
+ }
509
+
510
+ // src/chat/lib/reconcile.ts
511
+ function mergeMessagePages(server, prev) {
512
+ if (!prev?.items.length) return server;
513
+ const serverIds = new Set(server.items.map((m) => m.id));
514
+ const extras = prev.items.filter((m) => !serverIds.has(m.id));
515
+ const merged = [...server.items, ...extras];
516
+ merged.sort(
517
+ (a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
518
+ );
519
+ const seen = /* @__PURE__ */ new Set();
520
+ const items = [];
521
+ for (const m of merged) {
522
+ if (seen.has(m.id)) continue;
523
+ seen.add(m.id);
524
+ items.push(m);
525
+ }
526
+ return { items, next_cursor: server.next_cursor };
527
+ }
528
+ function appendOlderMessages(current, older) {
529
+ const currentIds = new Set(current.items.map((m) => m.id));
530
+ const dedupedOlder = older.items.filter((m) => !currentIds.has(m.id));
531
+ return {
532
+ items: [...current.items, ...dedupedOlder],
533
+ next_cursor: older.next_cursor
534
+ };
535
+ }
536
+
537
+ // src/chat/hooks/useMessages.ts
538
+ var INITIAL_MESSAGE_PAGE_SIZE = 30;
539
+ function useMessages(conversationId) {
540
+ const { api, wsConnected, config, queryClient } = useChat();
541
+ const interval = config.pollIntervalMs ?? 3e4;
542
+ const [isFetchingOlder, setIsFetchingOlder] = useState(false);
543
+ const fetchingRef = useRef(false);
544
+ const query = useQuery({
545
+ queryKey: chatKeys.messages(conversationId),
546
+ queryFn: async () => {
547
+ const res = await api.listMessages(conversationId, void 0, INITIAL_MESSAGE_PAGE_SIZE);
548
+ const prev = queryClient.getQueryData(chatKeys.messages(conversationId));
549
+ return mergeMessagePages(res, prev);
550
+ },
551
+ enabled: !!conversationId,
552
+ refetchInterval: wsConnected ? false : interval
553
+ });
554
+ const fetchOlderMessages = useCallback(async () => {
555
+ if (!conversationId || fetchingRef.current) return;
556
+ const current = queryClient.getQueryData(chatKeys.messages(conversationId));
557
+ if (!current?.next_cursor) return;
558
+ fetchingRef.current = true;
559
+ setIsFetchingOlder(true);
560
+ try {
561
+ const older = await api.listMessages(conversationId, current.next_cursor, INITIAL_MESSAGE_PAGE_SIZE);
562
+ queryClient.setQueryData(chatKeys.messages(conversationId), (prev) => {
563
+ if (!prev) return older;
564
+ return appendOlderMessages(prev, older);
565
+ });
566
+ } finally {
567
+ fetchingRef.current = false;
568
+ setIsFetchingOlder(false);
569
+ }
570
+ }, [conversationId, queryClient, api]);
571
+ const cached = queryClient.getQueryData(chatKeys.messages(conversationId));
572
+ const hasMoreMessages = !!cached?.next_cursor;
573
+ return {
574
+ ...query,
575
+ fetchOlderMessages,
576
+ isFetchingOlder,
577
+ hasMoreMessages
578
+ };
579
+ }
580
+
581
+ // src/chat/hooks/useChatPanelController.ts
582
+ function useChatPanelController({
583
+ conversationId: initialId,
584
+ peerUserId,
585
+ entityContext,
586
+ showNewChat = true,
587
+ onConversationChange
588
+ }) {
589
+ const { openOrCreateDirect, findOrCreateEntity } = useChatActions();
590
+ const { typingByConversation } = useChat();
591
+ const queryClient = useQueryClient();
592
+ const [selectedId, setSelectedId] = useState(initialId ?? null);
593
+ const [newChatOpen, setNewChatOpen] = useState(false);
594
+ const setSelected = useCallback(
595
+ (id) => {
596
+ setSelectedId(id);
597
+ onConversationChange?.(id);
598
+ },
599
+ [onConversationChange]
600
+ );
601
+ const refreshFromServer = useCallback(() => {
602
+ void queryClient.invalidateQueries({ queryKey: chatKeys.conversations() });
603
+ if (selectedId) void queryClient.invalidateQueries({ queryKey: chatKeys.messages(selectedId) });
604
+ }, [queryClient, selectedId]);
605
+ useEffect(() => {
606
+ if (!peerUserId) return;
607
+ void openOrCreateDirect(peerUserId).then((c) => setSelected(c.id)).catch(console.error);
608
+ }, [peerUserId, openOrCreateDirect]);
609
+ useEffect(() => {
610
+ if (!entityContext) return;
611
+ void findOrCreateEntity({
612
+ entity_ref: entityContext.entity_ref,
613
+ entity_uuid: entityContext.entity_uuid,
614
+ title: entityContext.title ?? entityContext.entity_ref,
615
+ members: entityContext.members
616
+ }).then((c) => setSelected(c.id)).catch(console.error);
617
+ }, [entityContext?.entity_ref, entityContext?.entity_uuid]);
618
+ useEffect(() => {
619
+ if (initialId !== void 0) setSelectedId(initialId);
620
+ }, [initialId]);
621
+ const { data: convData } = useConversations();
622
+ const {
623
+ data: msgData,
624
+ isLoading: loadMsg,
625
+ fetchOlderMessages,
626
+ isFetchingOlder,
627
+ hasMoreMessages
628
+ } = useMessages(selectedId);
629
+ const { data: membersForSelected = [] } = useConversationMembers(selectedId);
630
+ const membersById = useMemo(
631
+ () => Object.fromEntries(membersForSelected.map((m) => [m.id, m])),
632
+ [membersForSelected]
633
+ );
634
+ const typingNames = useMemo(() => {
635
+ if (!selectedId) return [];
636
+ const ids = typingByConversation[selectedId] ?? [];
637
+ return ids.map((id) => membersById[id]?.display_name ?? "Someone");
638
+ }, [selectedId, typingByConversation, membersById]);
639
+ return {
640
+ selectedId,
641
+ setSelected,
642
+ newChatOpen,
643
+ setNewChatOpen,
644
+ refreshFromServer,
645
+ convItems: convData?.items ?? [],
646
+ messages: msgData?.items ?? [],
647
+ loadMsg,
648
+ fetchOlderMessages,
649
+ isFetchingOlder,
650
+ hasMoreMessages,
651
+ typingNames,
652
+ showNewChat,
653
+ membersForSelected
654
+ };
655
+ }
656
+
657
+ // scss-module:./chat.module.css#scss-module
658
+ var chat_module_default = { "list": "chat__list", "row": "chat__row", "rowActive": "chat__rowActive", "thread": "chat__thread", "threadFlex": "chat__threadFlex", "bubbleRow": "chat__bubbleRow", "bubbleRowOwn": "chat__bubbleRowOwn", "bubbleRowOther": "chat__bubbleRowOther", "bubble": "chat__bubble", "bubbleOwn": "chat__bubbleOwn", "bubbleOther": "chat__bubbleOther", "meta": "chat__meta", "bubbleRowCompact": "chat__bubbleRowCompact", "olderSentinel": "chat__olderSentinel", "olderHint": "chat__olderHint", "olderSpinner": "chat__olderSpinner", "olderDot": "chat__olderDot", "bubbleRowAnimateIn": "chat__bubbleRowAnimateIn", "bubbleFailed": "chat__bubbleFailed", "statusSending": "chat__statusSending", "statusSpinner": "chat__statusSpinner", "statusFailed": "chat__statusFailed", "composer": "chat__composer", "toolBtn": "chat__toolBtn", "toolBtnRecording": "chat__toolBtnRecording", "composerDivider": "chat__composerDivider", "textareaGrow": "chat__textareaGrow", "textareaMirror": "chat__textareaMirror", "textarea": "chat__textarea", "sendBtn": "chat__sendBtn", "sendBtnIcon": "chat__sendBtnIcon", "iconBtn": "chat__iconBtn", "mediaThumb": "chat__mediaThumb", "imageWrap": "chat__imageWrap", "imageBtn": "chat__imageBtn", "imageSkeletonFill": "chat__imageSkeletonFill", "imageSkeletonOverlay": "chat__imageSkeletonOverlay", "videoWrap": "chat__videoWrap", "videoPoster": "chat__videoPoster", "playOverlay": "chat__playOverlay", "voiceWrap": "chat__voiceWrap", "voiceBar": "chat__voiceBar", "fileChip": "chat__fileChip", "widgetFab": "chat__widgetFab", "fabBr": "chat__fabBr", "fabBl": "chat__fabBl", "widgetPanel": "chat__widgetPanel", "panelBr": "chat__panelBr", "panelBl": "chat__panelBl", "widgetHeader": "chat__widgetHeader", "widgetBody": "chat__widgetBody", "chatPanelInner": "chat__chatPanelInner", "panelToolbar": "chat__panelToolbar", "refreshBtn": "chat__refreshBtn", "chatPanelThread": "chat__chatPanelThread", "typing": "chat__typing", "typingIndicator": "chat__typingIndicator", "typingDots": "chat__typingDots", "bubbleDeleted": "chat__bubbleDeleted", "editedLabel": "chat__editedLabel", "modalBackdrop": "chat__modalBackdrop", "modalCard": "chat__modalCard", "modalHeader": "chat__modalHeader", "modalTitle": "chat__modalTitle", "modalClose": "chat__modalClose", "modalBody": "chat__modalBody", "modalPanel": "chat__modalPanel", "segmentWrap": "chat__segmentWrap", "segment": "chat__segment", "segmentActive": "chat__segmentActive", "modalLabel": "chat__modalLabel", "modalSearch": "chat__modalSearch", "modalList": "chat__modalList", "modalRow": "chat__modalRow", "modalRowHoriz": "chat__modalRowHoriz", "modalRowHorizSelected": "chat__modalRowHorizSelected", "modalRowInner": "chat__modalRowInner", "modalAvatar": "chat__modalAvatar", "modalCheckbox": "chat__modalCheckbox", "modalCheckboxOn": "chat__modalCheckboxOn", "modalName": "chat__modalName", "modalMeta": "chat__modalMeta", "modalEmpty": "chat__modalEmpty", "modalError": "chat__modalError", "modalPrimary": "chat__modalPrimary", "chipRow": "chat__chipRow", "chip": "chat__chip", "chipRemove": "chat__chipRemove", "inputWrap": "chat__inputWrap", "mentionPopover": "chat__mentionPopover", "mentionRow": "chat__mentionRow", "mentionRowActive": "chat__mentionRowActive", "panelToolbarInner": "chat__panelToolbarInner" };
659
+
660
+ // src/chat/components/ConversationList.tsx
661
+ function ConversationList({ items, selectedId, onSelect }) {
662
+ return /* @__PURE__ */ React.createElement("div", { className: chat_module_default.list }, items.map((c) => /* @__PURE__ */ React.createElement(
663
+ "button",
664
+ {
665
+ key: c.id,
666
+ type: "button",
667
+ className: `${chat_module_default.row} ${selectedId === c.id ? chat_module_default.rowActive : ""}`,
668
+ onClick: () => onSelect(c.id)
669
+ },
670
+ c.title || (c.kind === "direct" ? "Direct" : "Chat")
671
+ )), items.length === 0 ? /* @__PURE__ */ React.createElement("div", { className: chat_module_default.typing }, "No conversations yet.") : null);
672
+ }
673
+ function useUpload() {
674
+ const { api } = useChat();
675
+ const uploadFile = useCallback(
676
+ async (file, meta) => {
677
+ const ct = file.type || "application/octet-stream";
678
+ const presign = await api.presignPut(file.name || "file", ct);
679
+ const res = await fetch(presign.upload_url, {
680
+ method: "PUT",
681
+ body: file,
682
+ headers: { "Content-Type": ct }
683
+ });
684
+ if (!res.ok) throw new Error(`upload failed: ${res.status}`);
685
+ return {
686
+ storage_key: presign.key,
687
+ mime_type: ct,
688
+ byte_size: file.size,
689
+ original_filename: file.name,
690
+ ...meta
691
+ };
692
+ },
693
+ [api]
694
+ );
695
+ return { uploadFile };
696
+ }
697
+ function useVoiceRecorder() {
698
+ const [recording, setRecording] = useState(false);
699
+ const mediaRef = useRef(null);
700
+ const chunksRef = useRef([]);
701
+ const start = useCallback(async () => {
702
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
703
+ const mime = MediaRecorder.isTypeSupported("audio/webm;codecs=opus") ? "audio/webm;codecs=opus" : "audio/webm";
704
+ const rec = new MediaRecorder(stream, { mimeType: mime });
705
+ chunksRef.current = [];
706
+ rec.ondataavailable = (e) => {
707
+ if (e.data.size) chunksRef.current.push(e.data);
708
+ };
709
+ rec.start(100);
710
+ mediaRef.current = rec;
711
+ setRecording(true);
712
+ }, []);
713
+ const stop = useCallback(async () => {
714
+ const rec = mediaRef.current;
715
+ if (!rec) return null;
716
+ return new Promise((resolve) => {
717
+ rec.onstop = () => {
718
+ const blob = new Blob(chunksRef.current, { type: rec.mimeType });
719
+ rec.stream.getTracks().forEach((t) => t.stop());
720
+ mediaRef.current = null;
721
+ setRecording(false);
722
+ resolve({ blob, mimeType: rec.mimeType });
723
+ };
724
+ rec.stop();
725
+ });
726
+ }, []);
727
+ return { recording, start, stop };
728
+ }
729
+ var urlCache = /* @__PURE__ */ new Map();
730
+ function primePresignedUrl(storageKey, url) {
731
+ if (!urlCache.has(storageKey)) {
732
+ urlCache.set(storageKey, url);
733
+ }
734
+ }
735
+ function usePresignedUrl(storageKey) {
736
+ const { api } = useChat();
737
+ const [url, setUrl] = useState(() => {
738
+ return storageKey ? urlCache.get(storageKey) ?? null : null;
739
+ });
740
+ const [error, setError] = useState(null);
741
+ useEffect(() => {
742
+ if (!storageKey) {
743
+ setUrl(null);
744
+ return;
745
+ }
746
+ const cached = urlCache.get(storageKey);
747
+ if (cached) {
748
+ setUrl(cached);
749
+ return;
750
+ }
751
+ let cancelled = false;
752
+ setError(null);
753
+ api.presignGet(storageKey).then((r) => {
754
+ if (!cancelled) {
755
+ urlCache.set(storageKey, r.url);
756
+ setUrl(r.url);
757
+ }
758
+ }).catch((e) => {
759
+ if (!cancelled) setError(e instanceof Error ? e : new Error(String(e)));
760
+ });
761
+ return () => {
762
+ cancelled = true;
763
+ };
764
+ }, [storageKey, api]);
765
+ return { url, error };
766
+ }
767
+
768
+ // src/chat/components/MessageInput.tsx
769
+ function escapeReg(s) {
770
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
771
+ }
772
+ function MessageInput({ conversationId }) {
773
+ const { api, sendTyping, sendTypingStop, userId } = useChat();
774
+ const qc = useQueryClient();
775
+ const { uploadFile } = useUpload();
776
+ const { recording, start, stop } = useVoiceRecorder();
777
+ const [text, setText] = useState("");
778
+ const fileRef = useRef(null);
779
+ const taRef = useRef(null);
780
+ const { data: members = [] } = useConversationMembers(conversationId);
781
+ const mentionPickIdsRef = useRef(/* @__PURE__ */ new Set());
782
+ useEffect(() => {
783
+ setText("");
784
+ mentionPickIdsRef.current.clear();
785
+ setMentionOpen(false);
786
+ }, [conversationId]);
787
+ const lastTypingSentRef = useRef(0);
788
+ const [mentionOpen, setMentionOpen] = useState(false);
789
+ const [mentionFilter, setMentionFilter] = useState("");
790
+ const [mentionIndex, setMentionIndex] = useState(0);
791
+ const [atCaret, setAtCaret] = useState(null);
792
+ const filteredMembers = useMemo(() => {
793
+ const t = mentionFilter.toLowerCase();
794
+ if (!t) return members;
795
+ return members.filter(
796
+ (m) => m.display_name.toLowerCase().includes(t) || m.id.toLowerCase().includes(t)
797
+ );
798
+ }, [members, mentionFilter]);
799
+ const syncMentionUi = useCallback(
800
+ (value, caret) => {
801
+ const before = value.slice(0, caret);
802
+ const at = before.lastIndexOf("@");
803
+ if (at < 0) {
804
+ setMentionOpen(false);
805
+ setAtCaret(null);
806
+ return;
807
+ }
808
+ const afterAt = before.slice(at + 1);
809
+ if (/\s/.test(afterAt)) {
810
+ setMentionOpen(false);
811
+ setAtCaret(null);
812
+ return;
813
+ }
814
+ setMentionOpen(true);
815
+ setAtCaret(at);
816
+ setMentionFilter(afterAt);
817
+ setMentionIndex(0);
818
+ },
819
+ []
820
+ );
821
+ const onTextChange = useCallback(
822
+ (e) => {
823
+ const v = e.target.value;
824
+ setText(v);
825
+ syncMentionUi(v, e.target.selectionStart ?? v.length);
826
+ if (v.trim()) {
827
+ const now = Date.now();
828
+ if (now - lastTypingSentRef.current > 2e3) {
829
+ sendTyping(conversationId);
830
+ lastTypingSentRef.current = now;
831
+ }
832
+ }
833
+ },
834
+ [syncMentionUi, sendTyping, conversationId]
835
+ );
836
+ const mirrorRef = useRef(null);
837
+ const onSelectMention = useCallback(
838
+ (u) => {
839
+ setMentionOpen(false);
840
+ const ta = taRef.current;
841
+ if (atCaret == null || !ta) return;
842
+ const caret = ta.selectionStart ?? text.length;
843
+ const before = text.slice(0, atCaret);
844
+ const after = text.slice(caret);
845
+ const insert = `@${u.display_name} `;
846
+ const next = before + insert + after;
847
+ mentionPickIdsRef.current.add(u.id);
848
+ setText(next);
849
+ setAtCaret(null);
850
+ requestAnimationFrame(() => {
851
+ const pos = before.length + insert.length;
852
+ ta.focus();
853
+ ta.setSelectionRange(pos, pos);
854
+ });
855
+ },
856
+ [atCaret, text]
857
+ );
858
+ const buildMentionIds = useCallback(
859
+ (body) => {
860
+ const ids = [...mentionPickIdsRef.current];
861
+ const out = [];
862
+ for (const id of ids) {
863
+ const m = members.find((x) => x.id === id);
864
+ if (!m) continue;
865
+ const pat = new RegExp(`@${escapeReg(m.display_name)}(\\s|$)`);
866
+ if (pat.test(body)) out.push(id);
867
+ }
868
+ return [...new Set(out)];
869
+ },
870
+ [members]
871
+ );
872
+ const mutation = useMutation({
873
+ mutationFn: async (payload) => {
874
+ return api.sendMessage(conversationId, payload);
875
+ },
876
+ onMutate: async (payload) => {
877
+ void qc.cancelQueries({ queryKey: chatKeys.messages(conversationId) });
878
+ setText("");
879
+ mentionPickIdsRef.current.clear();
880
+ setMentionOpen(false);
881
+ const optimisticId = `local-${Date.now()}-${Math.random().toString(36).slice(2)}`;
882
+ qc.setQueryData(chatKeys.messages(conversationId), (old) => {
883
+ const optimistic = {
884
+ id: optimisticId,
885
+ conversation_id: conversationId,
886
+ sender_id: userId,
887
+ body: payload.body ?? null,
888
+ created_at: (/* @__PURE__ */ new Date()).toISOString(),
889
+ attachments: payload.attachments?.map((a, i) => ({ ...a, id: `opt-att-${i}` })) ?? [],
890
+ _status: "sending"
891
+ };
892
+ if (!old) return { items: [optimistic], next_cursor: "" };
893
+ return { ...old, items: [optimistic, ...old.items] };
894
+ });
895
+ return { optimisticId };
896
+ },
897
+ onError: (_err, payload, context) => {
898
+ if (typeof payload.body === "string" && payload.body) {
899
+ setText((prev) => prev ? prev : payload.body);
900
+ }
901
+ if (!context?.optimisticId) return;
902
+ qc.setQueryData(chatKeys.messages(conversationId), (old) => {
903
+ if (!old) return old;
904
+ return {
905
+ ...old,
906
+ items: old.items.map(
907
+ (m) => m.id === context.optimisticId ? { ...m, _status: "failed" } : m
908
+ )
909
+ };
910
+ });
911
+ },
912
+ onSuccess: (confirmedMessage, _payload, context) => {
913
+ qc.setQueryData(chatKeys.messages(conversationId), (old) => {
914
+ if (!old) return old;
915
+ const optimisticIdx = old.items.findIndex((m) => m.id === context?.optimisticId);
916
+ if (optimisticIdx !== -1) {
917
+ const next = [...old.items];
918
+ next[optimisticIdx] = confirmedMessage;
919
+ return { ...old, items: next };
920
+ }
921
+ const hasReal = old.items.some((m) => m.id === confirmedMessage.id);
922
+ const cleaned = old.items.filter((m) => m.id !== context?.optimisticId);
923
+ if (hasReal) return { ...old, items: cleaned };
924
+ return { ...old, items: [confirmedMessage, ...cleaned] };
925
+ });
926
+ void qc.invalidateQueries({ queryKey: chatKeys.conversations() });
927
+ }
928
+ });
929
+ const onSend = useCallback(() => {
930
+ const t = text.trim();
931
+ if (!t) return;
932
+ const mids = buildMentionIds(text);
933
+ mutation.mutate({
934
+ body: t || null,
935
+ ...mids.length > 0 ? { mentioned_user_ids: mids } : {}
936
+ });
937
+ sendTypingStop(conversationId);
938
+ lastTypingSentRef.current = 0;
939
+ }, [text, mutation, buildMentionIds, sendTypingStop, conversationId]);
940
+ const onBlur = useCallback(() => {
941
+ if (text.trim()) {
942
+ sendTypingStop(conversationId);
943
+ lastTypingSentRef.current = 0;
944
+ }
945
+ }, [text, sendTypingStop, conversationId]);
946
+ const onFiles = useCallback(
947
+ async (e) => {
948
+ const files = e.target.files;
949
+ if (!files?.length) return;
950
+ const attachments = [];
951
+ for (const f of Array.from(files)) {
952
+ const objectUrl = URL.createObjectURL(f);
953
+ const meta = await uploadFile(f);
954
+ primePresignedUrl(meta.storage_key, objectUrl);
955
+ let duration_seconds;
956
+ if (f.type.startsWith("audio/")) {
957
+ const dur = await readAudioDuration(f);
958
+ if (dur != null) duration_seconds = dur;
959
+ }
960
+ attachments.push({
961
+ storage_key: meta.storage_key,
962
+ mime_type: meta.mime_type,
963
+ byte_size: meta.byte_size,
964
+ original_filename: meta.original_filename,
965
+ duration_seconds
966
+ });
967
+ }
968
+ mutation.mutate({ body: null, attachments });
969
+ e.target.value = "";
970
+ },
971
+ [mutation, uploadFile]
972
+ );
973
+ const onVoiceStop = useCallback(async () => {
974
+ const res = await stop();
975
+ if (!res) return;
976
+ const file = new File([res.blob], "voice.webm", { type: res.mimeType });
977
+ const objectUrl = URL.createObjectURL(file);
978
+ const dur = await readAudioDuration(file);
979
+ const meta = await uploadFile(file, { duration_seconds: dur ?? void 0 });
980
+ primePresignedUrl(meta.storage_key, objectUrl);
981
+ mutation.mutate({
982
+ body: null,
983
+ attachments: [
984
+ {
985
+ storage_key: meta.storage_key,
986
+ mime_type: meta.mime_type,
987
+ byte_size: meta.byte_size,
988
+ duration_seconds: meta.duration_seconds ?? dur ?? void 0
989
+ }
990
+ ]
991
+ });
992
+ }, [stop, uploadFile, mutation]);
993
+ const onKeyDown = useCallback(
994
+ (e) => {
995
+ if (mentionOpen && filteredMembers.length > 0) {
996
+ if (e.key === "ArrowDown") {
997
+ e.preventDefault();
998
+ setMentionIndex((i) => (i + 1) % filteredMembers.length);
999
+ return;
1000
+ }
1001
+ if (e.key === "ArrowUp") {
1002
+ e.preventDefault();
1003
+ setMentionIndex((i) => (i - 1 + filteredMembers.length) % filteredMembers.length);
1004
+ return;
1005
+ }
1006
+ if (e.key === "Escape") {
1007
+ e.preventDefault();
1008
+ setMentionOpen(false);
1009
+ return;
1010
+ }
1011
+ }
1012
+ if (e.key === "Enter" && !e.shiftKey) {
1013
+ if (mentionOpen && filteredMembers.length > 0) {
1014
+ e.preventDefault();
1015
+ onSelectMention(filteredMembers[mentionIndex]);
1016
+ return;
1017
+ }
1018
+ e.preventDefault();
1019
+ onSend();
1020
+ }
1021
+ },
1022
+ [mentionOpen, filteredMembers, mentionIndex, onSelectMention, onSend]
1023
+ );
1024
+ return /* @__PURE__ */ React.createElement("div", { className: chat_module_default.composer }, /* @__PURE__ */ React.createElement("input", { ref: fileRef, type: "file", multiple: true, hidden: true, accept: "image/*,video/*,audio/*", onChange: onFiles }), /* @__PURE__ */ React.createElement("button", { type: "button", className: chat_module_default.toolBtn, onClick: () => fileRef.current?.click(), "aria-label": "Attach file" }, /* @__PURE__ */ React.createElement(IconPlus, { "aria-hidden": true })), /* @__PURE__ */ React.createElement(
1025
+ "button",
1026
+ {
1027
+ type: "button",
1028
+ className: `${chat_module_default.toolBtn} ${recording ? chat_module_default.toolBtnRecording : ""}`,
1029
+ onClick: () => recording ? onVoiceStop() : start(),
1030
+ "aria-label": recording ? "Stop and send recording" : "Record voice message"
1031
+ },
1032
+ recording ? /* @__PURE__ */ React.createElement(IconStop, { "aria-hidden": true }) : /* @__PURE__ */ React.createElement(IconMic, { "aria-hidden": true })
1033
+ ), /* @__PURE__ */ React.createElement("div", { className: chat_module_default.composerDivider, "aria-hidden": true }), /* @__PURE__ */ React.createElement("div", { className: chat_module_default.inputWrap }, mentionOpen && filteredMembers.length > 0 ? /* @__PURE__ */ React.createElement("div", { className: chat_module_default.mentionPopover }, filteredMembers.map((u, i) => /* @__PURE__ */ React.createElement(
1034
+ "button",
1035
+ {
1036
+ key: u.id,
1037
+ type: "button",
1038
+ className: `${chat_module_default.mentionRow} ${i === mentionIndex ? chat_module_default.mentionRowActive : ""}`,
1039
+ onMouseDown: (ev) => {
1040
+ ev.preventDefault();
1041
+ onSelectMention(u);
1042
+ }
1043
+ },
1044
+ u.display_name
1045
+ ))) : null, /* @__PURE__ */ React.createElement("div", { className: chat_module_default.textareaGrow }, /* @__PURE__ */ React.createElement("span", { ref: mirrorRef, className: chat_module_default.textareaMirror, "aria-hidden": true }, text || "Type a message\u2026", "\u200B"), /* @__PURE__ */ React.createElement(
1046
+ "textarea",
1047
+ {
1048
+ ref: taRef,
1049
+ className: chat_module_default.textarea,
1050
+ rows: 1,
1051
+ value: text,
1052
+ onChange: onTextChange,
1053
+ onBlur,
1054
+ onSelect: (e) => syncMentionUi(e.currentTarget.value, e.currentTarget.selectionStart ?? 0),
1055
+ onKeyDown,
1056
+ placeholder: "Type a message\u2026"
1057
+ }
1058
+ ))), /* @__PURE__ */ React.createElement(
1059
+ "button",
1060
+ {
1061
+ type: "button",
1062
+ className: chat_module_default.sendBtn,
1063
+ onClick: onSend,
1064
+ disabled: !text.trim(),
1065
+ "aria-label": "Send message",
1066
+ title: "Send"
1067
+ },
1068
+ /* @__PURE__ */ React.createElement("span", { className: chat_module_default.sendBtnIcon, "aria-hidden": true }, /* @__PURE__ */ React.createElement(IconSend, null))
1069
+ ));
1070
+ }
1071
+ function IconPlus({ "aria-hidden": ah }) {
1072
+ return /* @__PURE__ */ React.createElement("svg", { width: 18, height: 18, viewBox: "0 0 24 24", "aria-hidden": ah, fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round" }, /* @__PURE__ */ React.createElement("path", { d: "M12 5v14M5 12h14" }));
1073
+ }
1074
+ function IconMic({ "aria-hidden": ah }) {
1075
+ return /* @__PURE__ */ React.createElement("svg", { width: 18, height: 18, viewBox: "0 0 24 24", "aria-hidden": ah, fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", strokeLinejoin: "round" }, /* @__PURE__ */ React.createElement("path", { d: "M12 14a3 3 0 0 0 3-3V5a3 3 0 0 0-6 0v6a3 3 0 0 0 3 3z" }), /* @__PURE__ */ React.createElement("path", { d: "M19 10v1a7 7 0 0 1-14 0v-1M12 18v4M8 22h8" }));
1076
+ }
1077
+ function IconStop({ "aria-hidden": ah }) {
1078
+ return /* @__PURE__ */ React.createElement("svg", { width: 16, height: 16, viewBox: "0 0 24 24", "aria-hidden": ah, fill: "currentColor" }, /* @__PURE__ */ React.createElement("rect", { x: 7, y: 7, width: 10, height: 10, rx: 1.5 }));
1079
+ }
1080
+ function IconSend() {
1081
+ return /* @__PURE__ */ React.createElement("svg", { width: 16, height: 16, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2.2, strokeLinecap: "round", strokeLinejoin: "round" }, /* @__PURE__ */ React.createElement("path", { d: "M22 2L11 13M22 2l-7 20-4-9-9-4 20-7z" }));
1082
+ }
1083
+ function readAudioDuration(file) {
1084
+ return new Promise((resolve) => {
1085
+ const url = URL.createObjectURL(file);
1086
+ const a = document.createElement("audio");
1087
+ a.preload = "metadata";
1088
+ a.src = url;
1089
+ a.onloadedmetadata = () => {
1090
+ const d = a.duration;
1091
+ URL.revokeObjectURL(url);
1092
+ resolve(Number.isFinite(d) ? d : void 0);
1093
+ };
1094
+ a.onerror = () => {
1095
+ URL.revokeObjectURL(url);
1096
+ resolve(void 0);
1097
+ };
1098
+ });
1099
+ }
1100
+
1101
+ // src/chat/lib/media.ts
1102
+ function isImageMime(m) {
1103
+ return m.startsWith("image/");
1104
+ }
1105
+ function isVideoMime(m) {
1106
+ return m.startsWith("video/");
1107
+ }
1108
+ function isAudioMime(m) {
1109
+ return m.startsWith("audio/");
1110
+ }
1111
+ function formatDurationSeconds(sec) {
1112
+ const s = Math.floor(sec % 60);
1113
+ const m = Math.floor(sec / 60);
1114
+ return `${m}:${s.toString().padStart(2, "0")}`;
1115
+ }
1116
+ function ImageMessage({ storageKey }) {
1117
+ const { url } = usePresignedUrl(storageKey);
1118
+ const [open, setOpen] = useState(false);
1119
+ const [loaded, setLoaded] = useState(false);
1120
+ return /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement("div", { className: chat_module_default.imageWrap }, !url ? /* @__PURE__ */ React.createElement("div", { className: chat_module_default.imageSkeletonFill, "aria-hidden": true }) : /* @__PURE__ */ React.createElement(React.Fragment, null, !loaded && /* @__PURE__ */ React.createElement("div", { className: chat_module_default.imageSkeletonOverlay, "aria-hidden": true }), /* @__PURE__ */ React.createElement(
1121
+ "button",
1122
+ {
1123
+ type: "button",
1124
+ onClick: () => setOpen(true),
1125
+ className: chat_module_default.imageBtn,
1126
+ "aria-label": "View image",
1127
+ title: "Tap to view"
1128
+ },
1129
+ /* @__PURE__ */ React.createElement(
1130
+ "img",
1131
+ {
1132
+ src: url,
1133
+ alt: "",
1134
+ className: chat_module_default.mediaThumb,
1135
+ loading: "lazy",
1136
+ decoding: "async",
1137
+ onLoad: () => setLoaded(true)
1138
+ }
1139
+ )
1140
+ ))), url ? /* @__PURE__ */ React.createElement(Lightbox, { open, close: () => setOpen(false), slides: [{ src: url }] }) : null);
1141
+ }
1142
+ function VideoMessage({ storageKey, thumbnailKey }) {
1143
+ const { url: videoUrl } = usePresignedUrl(storageKey);
1144
+ const { url: posterUrl } = usePresignedUrl(thumbnailKey || void 0);
1145
+ const [play, setPlay] = useState(false);
1146
+ if (!videoUrl) {
1147
+ return /* @__PURE__ */ React.createElement("div", { className: chat_module_default.videoWrap, "aria-hidden": true });
1148
+ }
1149
+ return /* @__PURE__ */ React.createElement("div", { className: chat_module_default.videoWrap }, play ? /* @__PURE__ */ React.createElement("video", { src: videoUrl, controls: true, playsInline: true }) : /* @__PURE__ */ React.createElement(React.Fragment, null, posterUrl ? /* @__PURE__ */ React.createElement("img", { src: posterUrl, alt: "", className: chat_module_default.videoPoster }) : /* @__PURE__ */ React.createElement("video", { src: videoUrl, preload: "metadata", muted: true, className: chat_module_default.videoPoster }), /* @__PURE__ */ React.createElement("button", { type: "button", className: chat_module_default.playOverlay, onClick: () => setPlay(true), "aria-label": "Play video" }, "\u25B6")));
1150
+ }
1151
+ function VoiceNotePlayer({ storageKey, durationSeconds, isOwn }) {
1152
+ const { url } = usePresignedUrl(storageKey);
1153
+ const containerRef = useRef(null);
1154
+ const waveRef = useRef(null);
1155
+ useEffect(() => {
1156
+ if (!url || !containerRef.current) return;
1157
+ const w = WaveSurfer.create({
1158
+ container: containerRef.current,
1159
+ height: 36,
1160
+ waveColor: isOwn ? "rgba(255,255,255,0.45)" : "rgba(255,255,255,0.35)",
1161
+ progressColor: isOwn ? "#fff" : "var(--chat-primary)",
1162
+ cursorWidth: 0,
1163
+ barWidth: 2,
1164
+ barGap: 1
1165
+ });
1166
+ w.load(url);
1167
+ waveRef.current = w;
1168
+ return () => {
1169
+ w.destroy();
1170
+ waveRef.current = null;
1171
+ };
1172
+ }, [url, isOwn]);
1173
+ return /* @__PURE__ */ React.createElement("div", { className: chat_module_default.voiceWrap }, /* @__PURE__ */ React.createElement(
1174
+ "button",
1175
+ {
1176
+ type: "button",
1177
+ className: chat_module_default.iconBtn,
1178
+ style: { marginBottom: 4 },
1179
+ onClick: () => waveRef.current?.playPause(),
1180
+ "aria-label": "Play voice"
1181
+ },
1182
+ "\u25B6"
1183
+ ), /* @__PURE__ */ React.createElement("div", { ref: containerRef, className: chat_module_default.voiceBar }), durationSeconds != null ? /* @__PURE__ */ React.createElement("span", { className: chat_module_default.meta }, formatDurationSeconds(Math.floor(durationSeconds))) : null);
1184
+ }
1185
+
1186
+ // src/chat/components/MessageBubble.tsx
1187
+ function MessageBubble({ message, compact, animateIn }) {
1188
+ const { userId } = useChat();
1189
+ const own = message.sender_id === userId;
1190
+ const isDeleted = !!message.deleted_at;
1191
+ const timeStr = (() => {
1192
+ try {
1193
+ return format(parseISO(message.created_at), "h:mm a");
1194
+ } catch {
1195
+ return format(/* @__PURE__ */ new Date(), "h:mm a");
1196
+ }
1197
+ })();
1198
+ if (isDeleted) {
1199
+ return /* @__PURE__ */ React.createElement("div", { className: `${chat_module_default.bubbleRow} ${own ? chat_module_default.bubbleRowOwn : chat_module_default.bubbleRowOther}` }, /* @__PURE__ */ React.createElement("div", { className: `${chat_module_default.bubble} ${chat_module_default.bubbleDeleted}` }, /* @__PURE__ */ React.createElement("p", { style: { margin: 0, fontStyle: "italic" } }, "This message was deleted.")));
1200
+ }
1201
+ return /* @__PURE__ */ React.createElement(
1202
+ "div",
1203
+ {
1204
+ className: `${chat_module_default.bubbleRow} ${own ? chat_module_default.bubbleRowOwn : chat_module_default.bubbleRowOther} ${compact ? chat_module_default.bubbleRowCompact : ""} ${animateIn ? chat_module_default.bubbleRowAnimateIn : ""}`
1205
+ },
1206
+ /* @__PURE__ */ React.createElement(
1207
+ "div",
1208
+ {
1209
+ className: `${chat_module_default.bubble} ${own ? chat_module_default.bubbleOwn : chat_module_default.bubbleOther} ${message._status === "failed" ? chat_module_default.bubbleFailed : ""}`
1210
+ },
1211
+ message.body ? /* @__PURE__ */ React.createElement("p", { style: { margin: 0 } }, message.body) : null,
1212
+ message.attachments?.map((a) => /* @__PURE__ */ React.createElement("div", { key: a.storage_key || a.id, style: { marginTop: message.body ? 6 : 0 } }, /* @__PURE__ */ React.createElement(AttachmentBlock, { attachment: a, isOwn: own }))),
1213
+ /* @__PURE__ */ React.createElement("div", { className: chat_module_default.meta }, timeStr, message.edited_at ? /* @__PURE__ */ React.createElement("span", { className: chat_module_default.editedLabel }, " \xB7 edited") : null, own && message._status === "sending" ? /* @__PURE__ */ React.createElement("span", { className: chat_module_default.statusSending, "aria-label": "Sending" }, /* @__PURE__ */ React.createElement(IconClock, null)) : null, own && message._status === "failed" ? /* @__PURE__ */ React.createElement("span", { className: chat_module_default.statusFailed, title: "Failed to send" }, "!") : null)
1214
+ )
1215
+ );
1216
+ }
1217
+ function IconClock() {
1218
+ return /* @__PURE__ */ React.createElement(
1219
+ "svg",
1220
+ {
1221
+ width: 11,
1222
+ height: 11,
1223
+ viewBox: "0 0 24 24",
1224
+ fill: "none",
1225
+ stroke: "currentColor",
1226
+ strokeWidth: 2.5,
1227
+ strokeLinecap: "round",
1228
+ className: chat_module_default.statusSpinner
1229
+ },
1230
+ /* @__PURE__ */ React.createElement("circle", { cx: 12, cy: 12, r: 10 }),
1231
+ /* @__PURE__ */ React.createElement("polyline", { points: "12 6 12 12 16 14" })
1232
+ );
1233
+ }
1234
+ function AttachmentBlock({ attachment: a, isOwn }) {
1235
+ if (isImageMime(a.mime_type)) {
1236
+ return /* @__PURE__ */ React.createElement(ImageMessage, { storageKey: a.storage_key });
1237
+ }
1238
+ if (isVideoMime(a.mime_type)) {
1239
+ return /* @__PURE__ */ React.createElement(VideoMessage, { storageKey: a.storage_key, thumbnailKey: a.thumbnail_storage_key });
1240
+ }
1241
+ if (isAudioMime(a.mime_type)) {
1242
+ return /* @__PURE__ */ React.createElement(VoiceNotePlayer, { storageKey: a.storage_key, durationSeconds: a.duration_seconds, isOwn });
1243
+ }
1244
+ return /* @__PURE__ */ React.createElement("span", { className: chat_module_default.fileChip }, a.mime_type, " \xB7 ", a.byte_size, " bytes");
1245
+ }
1246
+
1247
+ // src/chat/components/MessageThread.tsx
1248
+ function MessageThread({
1249
+ messages,
1250
+ threadClassName,
1251
+ fetchOlderMessages,
1252
+ isFetchingOlder,
1253
+ hasMoreMessages
1254
+ }) {
1255
+ const scrollRef = useRef(null);
1256
+ const bottomRef = useRef(null);
1257
+ const sentinelRef = useRef(null);
1258
+ const ordered = [...messages].reverse();
1259
+ const seenIdsRef = useRef(/* @__PURE__ */ new Set());
1260
+ const initializedRef = useRef(false);
1261
+ const prevCountRef = useRef(0);
1262
+ const prevScrollHeightRef = useRef(0);
1263
+ useEffect(() => {
1264
+ if (isFetchingOlder && scrollRef.current) {
1265
+ prevScrollHeightRef.current = scrollRef.current.scrollHeight;
1266
+ }
1267
+ }, [isFetchingOlder]);
1268
+ useLayoutEffect(() => {
1269
+ const el = scrollRef.current;
1270
+ const prev = prevScrollHeightRef.current;
1271
+ if (!el || !prev || isFetchingOlder) return;
1272
+ const diff = el.scrollHeight - prev;
1273
+ if (diff > 0) {
1274
+ el.scrollTop += diff;
1275
+ prevScrollHeightRef.current = 0;
1276
+ }
1277
+ }, [messages.length, isFetchingOlder]);
1278
+ useLayoutEffect(() => {
1279
+ const el = scrollRef.current;
1280
+ if (!el || messages.length === 0) return;
1281
+ const hasOptimistic = messages.some((m) => m._status === "sending");
1282
+ const isNewMessage = messages.length > prevCountRef.current;
1283
+ prevCountRef.current = messages.length;
1284
+ if (!initializedRef.current) {
1285
+ el.scrollTop = el.scrollHeight;
1286
+ initializedRef.current = true;
1287
+ return;
1288
+ }
1289
+ if (hasOptimistic) {
1290
+ el.scrollTop = el.scrollHeight;
1291
+ return;
1292
+ }
1293
+ if (isNewMessage) {
1294
+ const distFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
1295
+ if (distFromBottom < 160) {
1296
+ bottomRef.current?.scrollIntoView({ behavior: "smooth" });
1297
+ }
1298
+ }
1299
+ }, [messages.length, messages]);
1300
+ useEffect(() => {
1301
+ const sentinel = sentinelRef.current;
1302
+ if (!sentinel || !fetchOlderMessages) return;
1303
+ const observer = new IntersectionObserver(
1304
+ (entries) => {
1305
+ if (entries[0]?.isIntersecting && hasMoreMessages && !isFetchingOlder) {
1306
+ fetchOlderMessages();
1307
+ }
1308
+ },
1309
+ { root: scrollRef.current, rootMargin: "120px 0px 0px 0px", threshold: 0 }
1310
+ );
1311
+ observer.observe(sentinel);
1312
+ return () => observer.disconnect();
1313
+ }, [fetchOlderMessages, hasMoreMessages, isFetchingOlder]);
1314
+ useEffect(() => {
1315
+ return () => {
1316
+ initializedRef.current = false;
1317
+ prevCountRef.current = 0;
1318
+ seenIdsRef.current = /* @__PURE__ */ new Set();
1319
+ };
1320
+ }, []);
1321
+ useEffect(() => {
1322
+ for (const m of ordered) seenIdsRef.current.add(m.id);
1323
+ });
1324
+ return /* @__PURE__ */ React.createElement("div", { ref: scrollRef, className: threadClassName ?? chat_module_default.thread }, /* @__PURE__ */ React.createElement("div", { ref: sentinelRef, className: chat_module_default.olderSentinel }, isFetchingOlder ? /* @__PURE__ */ React.createElement("div", { className: chat_module_default.olderSpinner }, /* @__PURE__ */ React.createElement("span", { className: chat_module_default.olderDot }), /* @__PURE__ */ React.createElement("span", { className: chat_module_default.olderDot }), /* @__PURE__ */ React.createElement("span", { className: chat_module_default.olderDot })) : hasMoreMessages ? /* @__PURE__ */ React.createElement("div", { className: chat_module_default.olderHint }) : null), ordered.map((m, i) => {
1325
+ const prev = ordered[i - 1];
1326
+ const compact = !!prev && prev.sender_id === m.sender_id && new Date(m.created_at).getTime() - new Date(prev.created_at).getTime() < 6e4;
1327
+ const isNew = !seenIdsRef.current.has(m.id) && m._status !== "sending";
1328
+ return /* @__PURE__ */ React.createElement(
1329
+ MessageBubble,
1330
+ {
1331
+ key: m.id,
1332
+ message: m,
1333
+ compact,
1334
+ animateIn: isNew
1335
+ }
1336
+ );
1337
+ }), /* @__PURE__ */ React.createElement("div", { ref: bottomRef }));
1338
+ }
1339
+ function initials(displayName) {
1340
+ const parts = displayName.trim().split(/\s+/).filter(Boolean);
1341
+ if (parts.length >= 2) {
1342
+ return (parts[0][0] + parts[1][0]).toUpperCase();
1343
+ }
1344
+ const s = displayName.trim();
1345
+ return s.slice(0, 2).toUpperCase() || "?";
1346
+ }
1347
+ function IconClose() {
1348
+ return /* @__PURE__ */ React.createElement("svg", { width: "18", height: "18", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", "aria-hidden": true }, /* @__PURE__ */ React.createElement("path", { d: "M18 6L6 18M6 6l12 12" }));
1349
+ }
1350
+ function IconCheck() {
1351
+ return /* @__PURE__ */ React.createElement("svg", { width: "12", height: "12", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "3", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, /* @__PURE__ */ React.createElement("path", { d: "M20 6L9 17l-5-5" }));
1352
+ }
1353
+ function NewChatModal({ open, onClose, onConversationReady }) {
1354
+ const { api, openOrCreateDirect, createGroup } = useChatActions();
1355
+ const [mode, setMode] = useState("direct");
1356
+ const [q, setQ] = useState("");
1357
+ const [debounced, setDebounced] = useState("");
1358
+ const [contacts, setContacts] = useState([]);
1359
+ const [loading, setLoading] = useState(false);
1360
+ const [err, setErr] = useState(null);
1361
+ const [groupTitle, setGroupTitle] = useState("");
1362
+ const [selected, setSelected] = useState([]);
1363
+ useEffect(() => {
1364
+ const t = setTimeout(() => setDebounced(q.trim()), 300);
1365
+ return () => clearTimeout(t);
1366
+ }, [q]);
1367
+ useEffect(() => {
1368
+ if (!open) return;
1369
+ let cancelled = false;
1370
+ setLoading(true);
1371
+ setErr(null);
1372
+ void api.listContacts(debounced || void 0, 50).then((rows) => {
1373
+ if (!cancelled) setContacts(rows);
1374
+ }).catch((e) => {
1375
+ if (!cancelled) setErr(e.message ?? "Failed to load contacts");
1376
+ }).finally(() => {
1377
+ if (!cancelled) setLoading(false);
1378
+ });
1379
+ return () => {
1380
+ cancelled = true;
1381
+ };
1382
+ }, [open, debounced, api]);
1383
+ useEffect(() => {
1384
+ if (!open) {
1385
+ setQ("");
1386
+ setDebounced("");
1387
+ setMode("direct");
1388
+ setGroupTitle("");
1389
+ setSelected([]);
1390
+ setErr(null);
1391
+ }
1392
+ }, [open]);
1393
+ const toggleMember = useCallback((u) => {
1394
+ setSelected((prev) => {
1395
+ const has = prev.some((x) => x.id === u.id);
1396
+ if (has) return prev.filter((x) => x.id !== u.id);
1397
+ return [...prev, u];
1398
+ });
1399
+ }, []);
1400
+ const onPickDirect = useCallback(
1401
+ async (u) => {
1402
+ setErr(null);
1403
+ setLoading(true);
1404
+ try {
1405
+ const c = await openOrCreateDirect(u.id);
1406
+ onConversationReady(c.id);
1407
+ onClose();
1408
+ } catch (e) {
1409
+ setErr(e instanceof Error ? e.message : "Could not open chat");
1410
+ } finally {
1411
+ setLoading(false);
1412
+ }
1413
+ },
1414
+ [openOrCreateDirect, onConversationReady, onClose]
1415
+ );
1416
+ const onCreateGroup = useCallback(async () => {
1417
+ const title = groupTitle.trim();
1418
+ if (!title || selected.length === 0) {
1419
+ setErr("Add a title and at least one member.");
1420
+ return;
1421
+ }
1422
+ setErr(null);
1423
+ setLoading(true);
1424
+ try {
1425
+ const c = await createGroup(
1426
+ title,
1427
+ selected.map((u) => u.id)
1428
+ );
1429
+ onConversationReady(c.id);
1430
+ onClose();
1431
+ } catch (e) {
1432
+ setErr(e instanceof Error ? e.message : "Could not create group");
1433
+ } finally {
1434
+ setLoading(false);
1435
+ }
1436
+ }, [groupTitle, selected, createGroup, onConversationReady, onClose]);
1437
+ const directList = useMemo(() => contacts, [contacts]);
1438
+ if (!open) return null;
1439
+ return /* @__PURE__ */ React.createElement("div", { className: chat_module_default.modalBackdrop, role: "presentation", onMouseDown: onClose }, /* @__PURE__ */ React.createElement(
1440
+ "div",
1441
+ {
1442
+ className: chat_module_default.modalCard,
1443
+ role: "dialog",
1444
+ "aria-modal": "true",
1445
+ "aria-labelledby": "new-chat-title",
1446
+ onMouseDown: (e) => e.stopPropagation()
1447
+ },
1448
+ /* @__PURE__ */ React.createElement("div", { className: chat_module_default.modalHeader }, /* @__PURE__ */ React.createElement("h2", { id: "new-chat-title", className: chat_module_default.modalTitle }, "New chat"), /* @__PURE__ */ React.createElement("button", { type: "button", className: chat_module_default.modalClose, onClick: onClose, "aria-label": "Close dialog" }, /* @__PURE__ */ React.createElement(IconClose, null))),
1449
+ /* @__PURE__ */ React.createElement("div", { className: chat_module_default.modalBody }, /* @__PURE__ */ React.createElement("div", { className: chat_module_default.segmentWrap, role: "tablist", "aria-label": "Chat type" }, /* @__PURE__ */ React.createElement(
1450
+ "button",
1451
+ {
1452
+ type: "button",
1453
+ role: "tab",
1454
+ id: "tab-direct",
1455
+ "aria-selected": mode === "direct",
1456
+ "aria-controls": "new-chat-panel",
1457
+ className: mode === "direct" ? chat_module_default.segmentActive : chat_module_default.segment,
1458
+ onClick: () => setMode("direct")
1459
+ },
1460
+ "Direct"
1461
+ ), /* @__PURE__ */ React.createElement(
1462
+ "button",
1463
+ {
1464
+ type: "button",
1465
+ role: "tab",
1466
+ id: "tab-group",
1467
+ "aria-selected": mode === "group",
1468
+ "aria-controls": "new-chat-panel",
1469
+ className: mode === "group" ? chat_module_default.segmentActive : chat_module_default.segment,
1470
+ onClick: () => setMode("group")
1471
+ },
1472
+ "Group"
1473
+ )), err ? /* @__PURE__ */ React.createElement("div", { className: chat_module_default.modalError }, err) : null, /* @__PURE__ */ React.createElement(
1474
+ "div",
1475
+ {
1476
+ id: "new-chat-panel",
1477
+ className: chat_module_default.modalPanel,
1478
+ role: "tabpanel",
1479
+ "aria-labelledby": mode === "direct" ? "tab-direct" : "tab-group"
1480
+ },
1481
+ mode === "direct" ? /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement("label", { className: chat_module_default.modalLabel, htmlFor: "direct-search" }, "Find someone"), /* @__PURE__ */ React.createElement(
1482
+ "input",
1483
+ {
1484
+ id: "direct-search",
1485
+ type: "search",
1486
+ className: chat_module_default.modalSearch,
1487
+ placeholder: "Search by name or email",
1488
+ value: q,
1489
+ onChange: (e) => setQ(e.target.value),
1490
+ autoFocus: true
1491
+ }
1492
+ ), /* @__PURE__ */ React.createElement("div", { className: chat_module_default.modalList }, loading ? /* @__PURE__ */ React.createElement("div", { className: chat_module_default.modalEmpty }, "Loading contacts\u2026") : directList.length === 0 ? /* @__PURE__ */ React.createElement("div", { className: chat_module_default.modalEmpty }, "No contacts match your search.") : directList.map((u) => /* @__PURE__ */ React.createElement(
1493
+ "button",
1494
+ {
1495
+ key: u.id,
1496
+ type: "button",
1497
+ className: `${chat_module_default.modalRow} ${chat_module_default.modalRowHoriz}`,
1498
+ onClick: () => void onPickDirect(u),
1499
+ disabled: loading
1500
+ },
1501
+ /* @__PURE__ */ React.createElement("span", { className: chat_module_default.modalAvatar, "aria-hidden": true }, initials(u.display_name)),
1502
+ /* @__PURE__ */ React.createElement("span", { className: chat_module_default.modalRowInner }, /* @__PURE__ */ React.createElement("span", { className: chat_module_default.modalName }, u.display_name), u.email ? /* @__PURE__ */ React.createElement("span", { className: chat_module_default.modalMeta }, u.email) : null)
1503
+ )))) : /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement("label", { className: chat_module_default.modalLabel, htmlFor: "group-title" }, "Group name"), /* @__PURE__ */ React.createElement(
1504
+ "input",
1505
+ {
1506
+ id: "group-title",
1507
+ type: "text",
1508
+ className: chat_module_default.modalSearch,
1509
+ placeholder: "e.g. Project Alpha",
1510
+ value: groupTitle,
1511
+ onChange: (e) => setGroupTitle(e.target.value),
1512
+ autoFocus: true
1513
+ }
1514
+ ), /* @__PURE__ */ React.createElement("label", { className: chat_module_default.modalLabel, htmlFor: "group-search" }, "Add people"), /* @__PURE__ */ React.createElement(
1515
+ "input",
1516
+ {
1517
+ id: "group-search",
1518
+ type: "search",
1519
+ className: chat_module_default.modalSearch,
1520
+ placeholder: "Search contacts",
1521
+ value: q,
1522
+ onChange: (e) => setQ(e.target.value)
1523
+ }
1524
+ ), selected.length > 0 ? /* @__PURE__ */ React.createElement("div", { className: chat_module_default.chipRow }, selected.map((u) => /* @__PURE__ */ React.createElement(
1525
+ "button",
1526
+ {
1527
+ key: u.id,
1528
+ type: "button",
1529
+ className: chat_module_default.chip,
1530
+ onClick: () => toggleMember(u),
1531
+ "aria-label": `Remove ${u.display_name}`
1532
+ },
1533
+ u.display_name,
1534
+ /* @__PURE__ */ React.createElement("span", { className: chat_module_default.chipRemove, "aria-hidden": true }, "\xD7")
1535
+ ))) : null, /* @__PURE__ */ React.createElement("div", { className: chat_module_default.modalList }, loading ? /* @__PURE__ */ React.createElement("div", { className: chat_module_default.modalEmpty }, "Loading contacts\u2026") : contacts.length === 0 ? /* @__PURE__ */ React.createElement("div", { className: chat_module_default.modalEmpty }, "No contacts to show. Try a different search.") : contacts.map((u) => {
1536
+ const on = selected.some((x) => x.id === u.id);
1537
+ return /* @__PURE__ */ React.createElement(
1538
+ "button",
1539
+ {
1540
+ key: u.id,
1541
+ type: "button",
1542
+ className: `${chat_module_default.modalRow} ${chat_module_default.modalRowHoriz} ${on ? chat_module_default.modalRowHorizSelected : ""}`,
1543
+ onClick: () => toggleMember(u),
1544
+ "aria-pressed": on
1545
+ },
1546
+ /* @__PURE__ */ React.createElement(
1547
+ "span",
1548
+ {
1549
+ className: `${chat_module_default.modalCheckbox} ${on ? chat_module_default.modalCheckboxOn : ""}`,
1550
+ "aria-hidden": true
1551
+ },
1552
+ on ? /* @__PURE__ */ React.createElement(IconCheck, null) : null
1553
+ ),
1554
+ /* @__PURE__ */ React.createElement("span", { className: chat_module_default.modalAvatar, "aria-hidden": true }, initials(u.display_name)),
1555
+ /* @__PURE__ */ React.createElement("span", { className: chat_module_default.modalRowInner }, /* @__PURE__ */ React.createElement("span", { className: chat_module_default.modalName }, u.display_name), u.email ? /* @__PURE__ */ React.createElement("span", { className: chat_module_default.modalMeta }, u.email) : null)
1556
+ );
1557
+ })), /* @__PURE__ */ React.createElement(
1558
+ "button",
1559
+ {
1560
+ type: "button",
1561
+ className: chat_module_default.modalPrimary,
1562
+ disabled: loading || !groupTitle.trim() || selected.length === 0,
1563
+ onClick: () => void onCreateGroup()
1564
+ },
1565
+ "Create group"
1566
+ ))
1567
+ ))
1568
+ ));
1569
+ }
1570
+ function TypingIndicator({ names }) {
1571
+ if (names.length === 0) return null;
1572
+ let label;
1573
+ if (names.length === 1) {
1574
+ label = `${names[0]} is typing\u2026`;
1575
+ } else if (names.length === 2) {
1576
+ label = `${names[0]} and ${names[1]} are typing\u2026`;
1577
+ } else {
1578
+ label = `${names.length} people are typing\u2026`;
1579
+ }
1580
+ return /* @__PURE__ */ React.createElement(AnimatePresence, null, /* @__PURE__ */ React.createElement(
1581
+ motion.div,
1582
+ {
1583
+ key: "typing",
1584
+ initial: { opacity: 0, y: 4 },
1585
+ animate: { opacity: 1, y: 0 },
1586
+ exit: { opacity: 0, y: 4 },
1587
+ transition: { duration: 0.15 },
1588
+ className: chat_module_default.typingIndicator
1589
+ },
1590
+ /* @__PURE__ */ React.createElement("span", { className: chat_module_default.typingDots }, /* @__PURE__ */ React.createElement("span", null), /* @__PURE__ */ React.createElement("span", null), /* @__PURE__ */ React.createElement("span", null)),
1591
+ label
1592
+ ));
1593
+ }
1594
+
1595
+ // src/chat/components/ChatPanel.tsx
1596
+ function ChatPanel(props) {
1597
+ const {
1598
+ selectedId,
1599
+ setSelected,
1600
+ newChatOpen,
1601
+ setNewChatOpen,
1602
+ refreshFromServer,
1603
+ convItems,
1604
+ messages,
1605
+ loadMsg,
1606
+ typingNames,
1607
+ showNewChat
1608
+ } = useChatPanelController(props);
1609
+ return /* @__PURE__ */ React.createElement("div", { className: chat_module_default.chatPanelInner }, /* @__PURE__ */ React.createElement("div", { className: chat_module_default.panelToolbar }, /* @__PURE__ */ React.createElement("div", { className: chat_module_default.panelToolbarInner }, showNewChat ? /* @__PURE__ */ React.createElement("button", { type: "button", className: chat_module_default.refreshBtn, onClick: () => setNewChatOpen(true) }, "New chat") : /* @__PURE__ */ React.createElement("span", null), /* @__PURE__ */ React.createElement("button", { type: "button", className: chat_module_default.refreshBtn, onClick: refreshFromServer }, "Refresh"))), /* @__PURE__ */ React.createElement(ConversationList, { items: convItems, selectedId, onSelect: (id) => setSelected(id) }), selectedId ? /* @__PURE__ */ React.createElement("div", { className: chat_module_default.chatPanelThread }, loadMsg ? /* @__PURE__ */ React.createElement("div", { className: chat_module_default.typing }, "Loading\u2026") : /* @__PURE__ */ React.createElement(MessageThread, { messages }), /* @__PURE__ */ React.createElement(TypingIndicator, { names: typingNames }), /* @__PURE__ */ React.createElement(MessageInput, { conversationId: selectedId })) : /* @__PURE__ */ React.createElement("div", { className: chat_module_default.typing }, "Select a conversation or start a new chat."), /* @__PURE__ */ React.createElement(
1610
+ NewChatModal,
1611
+ {
1612
+ open: newChatOpen,
1613
+ onClose: () => setNewChatOpen(false),
1614
+ onConversationReady: (id) => setSelected(id)
1615
+ }
1616
+ ));
1617
+ }
1618
+
1619
+ // src/chat/components/ChatWidget.tsx
1620
+ function ChatWidget({
1621
+ position = "bottom-right",
1622
+ defaultOpen = false,
1623
+ conversationId,
1624
+ peerUserId,
1625
+ showNewChat,
1626
+ onConversationChange
1627
+ }) {
1628
+ const [open, setOpen] = useState(defaultOpen);
1629
+ const fabCorner = position === "bottom-left" ? chat_module_default.fabBl : chat_module_default.fabBr;
1630
+ const panelCorner = position === "bottom-left" ? chat_module_default.panelBl : chat_module_default.panelBr;
1631
+ return /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement(
1632
+ "button",
1633
+ {
1634
+ type: "button",
1635
+ className: `${chat_module_default.widgetFab} ${fabCorner}`,
1636
+ onClick: () => setOpen((o) => !o),
1637
+ "aria-label": open ? "Close chat" : "Open chat"
1638
+ },
1639
+ open ? "\u2715" : "\u{1F4AC}"
1640
+ ), /* @__PURE__ */ React.createElement(AnimatePresence, null, open ? /* @__PURE__ */ React.createElement(
1641
+ motion.div,
1642
+ {
1643
+ key: "panel",
1644
+ initial: { opacity: 0, y: 16, scale: 0.98 },
1645
+ animate: { opacity: 1, y: 0, scale: 1 },
1646
+ exit: { opacity: 0, y: 16, scale: 0.98 },
1647
+ transition: { duration: 0.2 },
1648
+ className: `${chat_module_default.widgetPanel} ${panelCorner}`
1649
+ },
1650
+ /* @__PURE__ */ React.createElement("div", { className: chat_module_default.widgetHeader }, "Messages"),
1651
+ /* @__PURE__ */ React.createElement("div", { className: chat_module_default.widgetBody }, /* @__PURE__ */ React.createElement(
1652
+ ChatPanel,
1653
+ {
1654
+ conversationId,
1655
+ peerUserId,
1656
+ showNewChat,
1657
+ onConversationChange
1658
+ }
1659
+ ))
1660
+ ) : null));
1661
+ }
1662
+ function convDisplayName(c) {
1663
+ if (c.kind === "direct" && c.peer_display_name) return c.peer_display_name;
1664
+ if (c.title) return c.title;
1665
+ return c.kind === "direct" ? "Direct message" : "Group chat";
1666
+ }
1667
+ function convLabel(c) {
1668
+ return c.title || (c.kind === "direct" ? "Direct" : "Chat");
1669
+ }
1670
+ function formatConvTime(iso) {
1671
+ try {
1672
+ const d = new Date(iso);
1673
+ const now = /* @__PURE__ */ new Date();
1674
+ const isToday = d.toDateString() === now.toDateString();
1675
+ if (isToday) return format(d, "h:mm a");
1676
+ const diffDays = Math.floor((now.getTime() - d.getTime()) / 864e5);
1677
+ if (diffDays < 7) return format(d, "EEE");
1678
+ return format(d, "MMM d");
1679
+ } catch {
1680
+ return "";
1681
+ }
1682
+ }
1683
+ function initials2(name) {
1684
+ const parts = name.trim().split(/\s+/).filter(Boolean);
1685
+ if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase();
1686
+ return name.slice(0, 2).toUpperCase() || "?";
1687
+ }
1688
+
1689
+ // scss-module:./standalone-chat.module.css#scss-module
1690
+ var standalone_chat_module_default = { "root": "standalone-chat__root", "shell": "standalone-chat__shell", "sidebar": "standalone-chat__sidebar", "sidebarHeader": "standalone-chat__sidebarHeader", "sidebarTitle": "standalone-chat__sidebarTitle", "iconSquare": "standalone-chat__iconSquare", "searchWrap": "standalone-chat__searchWrap", "searchInput": "standalone-chat__searchInput", "searchIcon": "standalone-chat__searchIcon", "tabs": "standalone-chat__tabs", "tab": "standalone-chat__tab", "tabActive": "standalone-chat__tabActive", "listScroll": "standalone-chat__listScroll", "sectionLabel": "standalone-chat__sectionLabel", "convRow": "standalone-chat__convRow", "convRowActive": "standalone-chat__convRowActive", "avatar": "standalone-chat__avatar", "avatarInitials": "standalone-chat__avatarInitials", "avatarDot": "standalone-chat__avatarDot", "convBody": "standalone-chat__convBody", "convTopLine": "standalone-chat__convTopLine", "convName": "standalone-chat__convName", "convPreview": "standalone-chat__convPreview", "convTime": "standalone-chat__convTime", "fab": "standalone-chat__fab", "sidebarFooter": "standalone-chat__sidebarFooter", "main": "standalone-chat__main", "mainHeader": "standalone-chat__mainHeader", "mainHeaderLeft": "standalone-chat__mainHeaderLeft", "mainAvatar": "standalone-chat__mainAvatar", "mainTitle": "standalone-chat__mainTitle", "mainStatus": "standalone-chat__mainStatus", "mainActions": "standalone-chat__mainActions", "actionBtn": "standalone-chat__actionBtn", "threadArea": "standalone-chat__threadArea", "emptyMain": "standalone-chat__emptyMain", "inputArea": "standalone-chat__inputArea", "callsEmpty": "standalone-chat__callsEmpty" };
1691
+
1692
+ // src/chat/components/ChatInboxSidebar.tsx
1693
+ function ChatInboxSidebar({
1694
+ q,
1695
+ setQ,
1696
+ tab,
1697
+ setTab,
1698
+ tabDefs,
1699
+ filtered,
1700
+ activeRows,
1701
+ restRows,
1702
+ selectedId,
1703
+ onSelectConversation,
1704
+ onRefresh,
1705
+ showNewChat,
1706
+ onNewChat
1707
+ }) {
1708
+ const renderRow = (c) => {
1709
+ const name = convDisplayName(c);
1710
+ const timeStr = formatConvTime(c.last_message_at ?? c.updated_at);
1711
+ const preview = c.last_message_preview ?? (c.kind === "group" ? "Group chat" : "");
1712
+ const isActive = selectedId === c.id;
1713
+ return /* @__PURE__ */ React.createElement(
1714
+ "button",
1715
+ {
1716
+ key: c.id,
1717
+ type: "button",
1718
+ className: `${standalone_chat_module_default.convRow} ${isActive ? standalone_chat_module_default.convRowActive : ""}`,
1719
+ onClick: () => onSelectConversation(c.id)
1720
+ },
1721
+ /* @__PURE__ */ React.createElement("div", { className: standalone_chat_module_default.avatar, "aria-hidden": true }, /* @__PURE__ */ React.createElement("span", { className: standalone_chat_module_default.avatarInitials }, initials2(name)), /* @__PURE__ */ React.createElement("span", { className: standalone_chat_module_default.avatarDot })),
1722
+ /* @__PURE__ */ React.createElement("div", { className: standalone_chat_module_default.convBody }, /* @__PURE__ */ React.createElement("div", { className: standalone_chat_module_default.convTopLine }, /* @__PURE__ */ React.createElement("span", { className: standalone_chat_module_default.convName }, name), /* @__PURE__ */ React.createElement("span", { className: standalone_chat_module_default.convTime }, timeStr)), preview ? /* @__PURE__ */ React.createElement("div", { className: standalone_chat_module_default.convPreview }, preview) : null)
1723
+ );
1724
+ };
1725
+ return /* @__PURE__ */ React.createElement("aside", { className: standalone_chat_module_default.sidebar }, /* @__PURE__ */ React.createElement("div", { className: standalone_chat_module_default.sidebarHeader }, /* @__PURE__ */ React.createElement("h1", { className: standalone_chat_module_default.sidebarTitle }, "Messages"), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", gap: 8 } }, /* @__PURE__ */ React.createElement(
1726
+ "button",
1727
+ {
1728
+ type: "button",
1729
+ className: standalone_chat_module_default.iconSquare,
1730
+ "aria-label": "Refresh conversations",
1731
+ title: "Refresh",
1732
+ onClick: () => onRefresh()
1733
+ },
1734
+ "\u21BB"
1735
+ ), /* @__PURE__ */ React.createElement("button", { type: "button", className: standalone_chat_module_default.iconSquare, "aria-label": "Settings", title: "Settings" }, "\u2699"))), /* @__PURE__ */ React.createElement("div", { className: standalone_chat_module_default.searchWrap }, /* @__PURE__ */ React.createElement(
1736
+ "input",
1737
+ {
1738
+ type: "search",
1739
+ className: standalone_chat_module_default.searchInput,
1740
+ placeholder: "Search Chat",
1741
+ value: q,
1742
+ onChange: (e) => setQ(e.target.value),
1743
+ "aria-label": "Search conversations"
1744
+ }
1745
+ ), /* @__PURE__ */ React.createElement("span", { className: standalone_chat_module_default.searchIcon }, "\u2315")), /* @__PURE__ */ React.createElement("div", { className: standalone_chat_module_default.tabs, role: "tablist" }, tabDefs.map(([id, label]) => /* @__PURE__ */ React.createElement(
1746
+ "button",
1747
+ {
1748
+ key: id,
1749
+ type: "button",
1750
+ role: "tab",
1751
+ "aria-selected": tab === id,
1752
+ className: `${standalone_chat_module_default.tab} ${tab === id ? standalone_chat_module_default.tabActive : ""}`,
1753
+ onClick: () => setTab(id)
1754
+ },
1755
+ label
1756
+ ))), /* @__PURE__ */ React.createElement("div", { className: standalone_chat_module_default.listScroll }, tab === "calls" ? /* @__PURE__ */ React.createElement("p", { className: standalone_chat_module_default.callsEmpty }, "No recent calls yet.") : filtered.length === 0 ? /* @__PURE__ */ React.createElement("p", { className: standalone_chat_module_default.callsEmpty }, "No conversations match.") : /* @__PURE__ */ React.createElement(React.Fragment, null, activeRows.length > 0 ? /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement("div", { className: standalone_chat_module_default.sectionLabel }, "ACTIVE CHATS"), activeRows.map(renderRow)) : null, restRows.length > 0 ? /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement("div", { className: standalone_chat_module_default.sectionLabel }, "ALL CHATS"), restRows.map(renderRow)) : null)), showNewChat ? /* @__PURE__ */ React.createElement("div", { className: standalone_chat_module_default.sidebarFooter }, /* @__PURE__ */ React.createElement("button", { type: "button", className: standalone_chat_module_default.fab, "aria-label": "New chat", onClick: onNewChat }, "+")) : null);
1757
+ }
1758
+
1759
+ // src/chat/components/ChatMainColumn.tsx
1760
+ function ChatMainColumn({
1761
+ selectedId,
1762
+ headerTitle,
1763
+ headerStatus,
1764
+ messages,
1765
+ loadMsg,
1766
+ fetchOlderMessages,
1767
+ isFetchingOlder,
1768
+ hasMoreMessages,
1769
+ typingNames,
1770
+ emptyMessage = "Select a conversation or start a new chat.",
1771
+ threadClassName
1772
+ }) {
1773
+ const { config } = useChat();
1774
+ const callEnabled = config.callEnabled === true;
1775
+ if (!selectedId) {
1776
+ return /* @__PURE__ */ React.createElement("main", { className: standalone_chat_module_default.main }, /* @__PURE__ */ React.createElement("div", { className: standalone_chat_module_default.emptyMain }, emptyMessage));
1777
+ }
1778
+ return /* @__PURE__ */ React.createElement("main", { className: standalone_chat_module_default.main }, /* @__PURE__ */ React.createElement("header", { className: standalone_chat_module_default.mainHeader }, /* @__PURE__ */ React.createElement("div", { className: standalone_chat_module_default.mainHeaderLeft }, /* @__PURE__ */ React.createElement("div", { className: standalone_chat_module_default.mainAvatar, "aria-hidden": true }), /* @__PURE__ */ React.createElement("div", { style: { minWidth: 0 } }, /* @__PURE__ */ React.createElement("div", { className: standalone_chat_module_default.mainTitle }, headerTitle), headerStatus ? /* @__PURE__ */ React.createElement("div", { className: standalone_chat_module_default.mainStatus }, headerStatus) : null)), /* @__PURE__ */ React.createElement("div", { className: standalone_chat_module_default.mainActions }, callEnabled ? /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement("button", { type: "button", className: standalone_chat_module_default.actionBtn, "aria-label": "Voice call", title: "Voice call" }, "\u{1F4DE}"), /* @__PURE__ */ React.createElement("button", { type: "button", className: standalone_chat_module_default.actionBtn, "aria-label": "Video call", title: "Video call" }, "\u{1F4F9}")) : null, /* @__PURE__ */ React.createElement("button", { type: "button", className: standalone_chat_module_default.actionBtn, "aria-label": "More", title: "More" }, "\u22EE"))), /* @__PURE__ */ React.createElement("div", { className: standalone_chat_module_default.threadArea }, loadMsg ? /* @__PURE__ */ React.createElement("div", { className: standalone_chat_module_default.emptyMain }, "Loading\u2026") : /* @__PURE__ */ React.createElement(
1779
+ MessageThread,
1780
+ {
1781
+ messages,
1782
+ threadClassName: threadClassName ?? chat_module_default.threadFlex,
1783
+ fetchOlderMessages,
1784
+ isFetchingOlder,
1785
+ hasMoreMessages
1786
+ }
1787
+ ), /* @__PURE__ */ React.createElement(TypingIndicator, { names: typingNames })), /* @__PURE__ */ React.createElement("div", { className: standalone_chat_module_default.inputArea }, /* @__PURE__ */ React.createElement(MessageInput, { conversationId: selectedId })));
1788
+ }
1789
+
1790
+ // src/chat/components/StandaloneChatPage.tsx
1791
+ function StandaloneChatPage(props) {
1792
+ const { config } = useChat();
1793
+ const {
1794
+ selectedId,
1795
+ setSelected,
1796
+ newChatOpen,
1797
+ setNewChatOpen,
1798
+ refreshFromServer,
1799
+ convItems,
1800
+ messages,
1801
+ loadMsg,
1802
+ fetchOlderMessages,
1803
+ isFetchingOlder,
1804
+ hasMoreMessages,
1805
+ typingNames,
1806
+ showNewChat,
1807
+ membersForSelected
1808
+ } = useChatPanelController(props);
1809
+ const [tab, setTab] = useState("recent");
1810
+ const [q, setQ] = useState("");
1811
+ const callEnabled = config.callEnabled === true;
1812
+ useEffect(() => {
1813
+ if (!callEnabled && tab === "calls") setTab("recent");
1814
+ }, [callEnabled, tab]);
1815
+ const filtered = useMemo(() => {
1816
+ let list = [...convItems];
1817
+ const needle = q.trim().toLowerCase();
1818
+ if (needle) list = list.filter((c) => convLabel(c).toLowerCase().includes(needle));
1819
+ if (tab === "groups") list = list.filter((c) => c.kind === "group");
1820
+ if (tab === "calls") list = [];
1821
+ list.sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime());
1822
+ return list;
1823
+ }, [convItems, q, tab]);
1824
+ const { activeRows, restRows } = useMemo(() => {
1825
+ const active = filtered.slice(0, 4);
1826
+ const rest = filtered.slice(4);
1827
+ return { activeRows: active, restRows: rest };
1828
+ }, [filtered]);
1829
+ const tabDefs = useMemo(() => {
1830
+ const rows = [
1831
+ ["recent", "Recent"],
1832
+ ["groups", "Groups"]
1833
+ ];
1834
+ if (callEnabled) rows.push(["calls", "Calls"]);
1835
+ return rows;
1836
+ }, [callEnabled]);
1837
+ const selectedConv = selectedId ? convItems.find((c) => c.id === selectedId) : void 0;
1838
+ const otherMembers = membersForSelected.filter((m) => m.id !== config.userId);
1839
+ const headerTitle = selectedConv ? selectedConv.kind === "direct" ? selectedConv.peer_display_name ?? otherMembers[0]?.display_name ?? convLabel(selectedConv) : convLabel(selectedConv) : "";
1840
+ const headerStatus = typingNames.length > 0 ? `${typingNames.join(", ")} typing\u2026` : selectedId ? "online" : "";
1841
+ return /* @__PURE__ */ React.createElement("div", { className: standalone_chat_module_default.root }, /* @__PURE__ */ React.createElement("div", { className: standalone_chat_module_default.shell }, /* @__PURE__ */ React.createElement(
1842
+ ChatInboxSidebar,
1843
+ {
1844
+ q,
1845
+ setQ,
1846
+ tab,
1847
+ setTab,
1848
+ tabDefs,
1849
+ filtered,
1850
+ activeRows,
1851
+ restRows,
1852
+ selectedId,
1853
+ onSelectConversation: (id) => setSelected(id),
1854
+ onRefresh: refreshFromServer,
1855
+ showNewChat,
1856
+ onNewChat: () => setNewChatOpen(true)
1857
+ }
1858
+ ), /* @__PURE__ */ React.createElement(
1859
+ ChatMainColumn,
1860
+ {
1861
+ selectedId: selectedId && selectedConv ? selectedId : null,
1862
+ headerTitle,
1863
+ headerStatus,
1864
+ messages,
1865
+ loadMsg,
1866
+ fetchOlderMessages,
1867
+ isFetchingOlder,
1868
+ hasMoreMessages,
1869
+ typingNames
1870
+ }
1871
+ )), /* @__PURE__ */ React.createElement(
1872
+ NewChatModal,
1873
+ {
1874
+ open: newChatOpen,
1875
+ onClose: () => setNewChatOpen(false),
1876
+ onConversationReady: (id) => setSelected(id)
1877
+ }
1878
+ ));
1879
+ }
1880
+ function ChatConversationView({
1881
+ conversationId,
1882
+ header,
1883
+ className,
1884
+ threadAreaClassName,
1885
+ threadClassName,
1886
+ inputAreaClassName,
1887
+ showTyping = true,
1888
+ loadingFallback
1889
+ }) {
1890
+ const { typingByConversation } = useChat();
1891
+ const {
1892
+ data: msgData,
1893
+ isLoading: loadMsg,
1894
+ fetchOlderMessages,
1895
+ isFetchingOlder,
1896
+ hasMoreMessages
1897
+ } = useMessages(conversationId);
1898
+ const { data: membersForSelected = [] } = useConversationMembers(conversationId);
1899
+ const membersById = useMemo(
1900
+ () => Object.fromEntries(membersForSelected.map((m) => [m.id, m])),
1901
+ [membersForSelected]
1902
+ );
1903
+ const typingNames = useMemo(() => {
1904
+ const ids = typingByConversation[conversationId] ?? [];
1905
+ return ids.map((id) => membersById[id]?.display_name ?? "Someone");
1906
+ }, [conversationId, typingByConversation, membersById]);
1907
+ const messages = msgData?.items ?? [];
1908
+ return /* @__PURE__ */ React.createElement("div", { className }, header, /* @__PURE__ */ React.createElement("div", { className: threadAreaClassName }, loadMsg ? loadingFallback ?? /* @__PURE__ */ React.createElement("div", { className: chat_module_default.typing }, "Loading\u2026") : /* @__PURE__ */ React.createElement(
1909
+ MessageThread,
1910
+ {
1911
+ messages,
1912
+ threadClassName: threadClassName ?? chat_module_default.threadFlex,
1913
+ fetchOlderMessages,
1914
+ isFetchingOlder,
1915
+ hasMoreMessages
1916
+ }
1917
+ ), showTyping ? /* @__PURE__ */ React.createElement(TypingIndicator, { names: typingNames }) : null), /* @__PURE__ */ React.createElement("div", { className: inputAreaClassName }, /* @__PURE__ */ React.createElement(MessageInput, { conversationId })));
1918
+ }
1919
+
1920
+ export { ChatAPI, MessageInput as ChatComposer, ChatConversationView, ChatInboxSidebar, ChatMainColumn, ChatPanel, ChatProvider, ChatWidget, ConversationList, INITIAL_MESSAGE_PAGE_SIZE, MessageBubble, MessageInput, MessageThread, NewChatModal, StandaloneChatPage, TypingIndicator, chatKeys, convDisplayName, convLabel, formatConvTime, initials2 as initials, mergeMessagePages, useChat, useChatActions, useChatPanelController, useConversationMembers, useConversations, useMessages, usePresignedUrl, useUpload, useVoiceRecorder };
1921
+ //# sourceMappingURL=index.mjs.map
1922
+ //# sourceMappingURL=index.mjs.map