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