@natoe/colab 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,4893 @@
1
+ import { createContext, forwardRef, useRef, useImperativeHandle, useEffect, useCallback, useState, useContext, useMemo } from 'react';
2
+ import { Socket, Presence } from 'phoenix';
3
+ import { jsxs, jsx, Fragment } from 'react/jsx-runtime';
4
+
5
+ // src/components/CollabPanel.tsx
6
+
7
+ // src/core/constants.ts
8
+ var EVENTS = {
9
+ MESSAGE_NEW: "message:new",
10
+ MESSAGE_READ: "message:read",
11
+ MESSAGE_PINNED: "message:pinned",
12
+ MESSAGE_UNPINNED: "message:unpinned",
13
+ USER_TYPING: "user:typing",
14
+ USER_JOINED: "user:joined",
15
+ USER_LEFT: "user:left",
16
+ CHANNEL_UPDATED: "channel:updated",
17
+ CHANNEL_DELETED: "channel:deleted",
18
+ PRESENCE_STATE: "presence_state",
19
+ PRESENCE_DIFF: "presence_diff"
20
+ };
21
+ var MAX_PINNED_MESSAGES = 3;
22
+ var MESSAGE_TYPES = {
23
+ TEXT: "text",
24
+ AUDIO: "audio",
25
+ IMAGE: "image",
26
+ FILE: "file",
27
+ SYSTEM: "system",
28
+ DEEP_LINK: "deep_link"
29
+ };
30
+ var DEEP_LINK_PREFIX = "natoe://";
31
+ var TYPING_DEBOUNCE_MS = 2e3;
32
+ var MESSAGES_PAGE_SIZE = 30;
33
+ var AUDIO_MIME_TYPE = "audio/webm;codecs=opus";
34
+ var SUPPORTED_IMAGE_TYPES = ["image/jpeg", "image/png", "image/gif", "image/webp"];
35
+ var MAX_FILE_SIZE = 20 * 1024 * 1024;
36
+
37
+ // src/core/caseConvert.ts
38
+ function snakeToCamel(input) {
39
+ if (Array.isArray(input)) {
40
+ return input.map((item) => snakeToCamel(item));
41
+ }
42
+ if (input !== null && typeof input === "object") {
43
+ const source = input;
44
+ const out = {};
45
+ for (const key of Object.keys(source)) {
46
+ const camelKey = toCamelKey(key);
47
+ out[camelKey] = snakeToCamel(source[key]);
48
+ }
49
+ return out;
50
+ }
51
+ return input;
52
+ }
53
+ function toCamelKey(key) {
54
+ if (!key.includes("_")) return key;
55
+ return key.replace(/_([a-zA-Z0-9])/g, (_, c) => c.toUpperCase());
56
+ }
57
+
58
+ // src/core/socket.ts
59
+ var CollabSocket = class {
60
+ constructor() {
61
+ this.socket = null;
62
+ this.channels = /* @__PURE__ */ new Map();
63
+ this.presences = /* @__PURE__ */ new Map();
64
+ this.userChannel = null;
65
+ this.config = null;
66
+ this.onUnreadUpdate = null;
67
+ }
68
+ /** Connect to Phoenix WebSocket server. Idempotent — safe to call when
69
+ * the token becomes available later (e.g. after async login) without
70
+ * stacking up multiple open sockets. */
71
+ connect(config) {
72
+ this.config = config;
73
+ if (!config.getToken()) {
74
+ return;
75
+ }
76
+ if (this.socket) {
77
+ if (this.socket.isConnected()) return;
78
+ try {
79
+ this.socket.disconnect();
80
+ } catch {
81
+ }
82
+ this.channels.clear();
83
+ this.presences.clear();
84
+ this.userChannel = null;
85
+ }
86
+ this.socket = new Socket(config.socketUrl, {
87
+ params: () => ({ token: config.getToken() })
88
+ });
89
+ this.socket.onError((err) => {
90
+ const closeEvent = err;
91
+ config.onError?.({
92
+ code: "SOCKET_ERROR",
93
+ message: "WebSocket connection error",
94
+ details: closeEvent ? {
95
+ type: closeEvent.type,
96
+ code: closeEvent.code,
97
+ reason: closeEvent.reason,
98
+ wasClean: closeEvent.wasClean
99
+ } : err
100
+ });
101
+ });
102
+ this.socket.onClose((event) => {
103
+ config.onError?.({
104
+ code: "SOCKET_CLOSED",
105
+ message: `Socket closed (code ${event?.code ?? "unknown"})`,
106
+ details: event ? { code: event.code, reason: event.reason, wasClean: event.wasClean } : void 0
107
+ });
108
+ });
109
+ this.socket.connect();
110
+ this.joinUserChannel();
111
+ }
112
+ /** Subscribe to user-level events (unread counts, notifications) */
113
+ joinUserChannel() {
114
+ if (!this.socket || !this.config) return;
115
+ this.userChannel = this.socket.channel("user_notifications", {});
116
+ this.userChannel.on("unread_update", (payload) => {
117
+ this.onUnreadUpdate?.(payload);
118
+ });
119
+ this.userChannel.join().receive("ok", () => {
120
+ }).receive("error", (reason) => {
121
+ this.config?.onError?.({
122
+ code: "USER_CHANNEL_ERROR",
123
+ message: "Failed to join user channel",
124
+ details: reason
125
+ });
126
+ });
127
+ }
128
+ /** Register callback for unread count changes */
129
+ onUnreadCountUpdate(callback) {
130
+ this.onUnreadUpdate = callback;
131
+ }
132
+ /** Join a conversation channel and subscribe to events */
133
+ joinConversation(conversationId, callbacks) {
134
+ if (!this.socket) return null;
135
+ if (this.channels.has(conversationId)) {
136
+ return this.channels.get(conversationId);
137
+ }
138
+ const channel = this.socket.channel(`conversation:${conversationId}`, {});
139
+ if (callbacks.onMessage) {
140
+ channel.on(EVENTS.MESSAGE_NEW, (payload) => {
141
+ callbacks.onMessage(snakeToCamel(payload));
142
+ });
143
+ }
144
+ if (callbacks.onTyping) {
145
+ channel.on(EVENTS.USER_TYPING, (payload) => {
146
+ callbacks.onTyping(snakeToCamel(payload));
147
+ });
148
+ }
149
+ if (callbacks.onUserJoined) {
150
+ channel.on(EVENTS.USER_JOINED, (payload) => {
151
+ callbacks.onUserJoined(snakeToCamel(payload));
152
+ });
153
+ }
154
+ if (callbacks.onUserLeft) {
155
+ channel.on(EVENTS.USER_LEFT, (payload) => {
156
+ callbacks.onUserLeft(snakeToCamel(payload));
157
+ });
158
+ }
159
+ if (callbacks.onChannelUpdated) {
160
+ channel.on(EVENTS.CHANNEL_UPDATED, (payload) => {
161
+ callbacks.onChannelUpdated(snakeToCamel(payload));
162
+ });
163
+ }
164
+ if (callbacks.onChannelDeleted) {
165
+ channel.on(EVENTS.CHANNEL_DELETED, () => {
166
+ callbacks.onChannelDeleted();
167
+ });
168
+ }
169
+ if (callbacks.onMessageRead) {
170
+ channel.on(EVENTS.MESSAGE_READ, (payload) => {
171
+ callbacks.onMessageRead(snakeToCamel(payload));
172
+ });
173
+ }
174
+ if (callbacks.onMessagePinned) {
175
+ channel.on(EVENTS.MESSAGE_PINNED, (payload) => {
176
+ callbacks.onMessagePinned(snakeToCamel(payload));
177
+ });
178
+ }
179
+ if (callbacks.onMessageUnpinned) {
180
+ channel.on(EVENTS.MESSAGE_UNPINNED, (payload) => {
181
+ callbacks.onMessageUnpinned(snakeToCamel(payload));
182
+ });
183
+ }
184
+ if (callbacks.onPresence) {
185
+ const presence = new Presence(channel);
186
+ presence.onSync(() => {
187
+ const online = {};
188
+ presence.list((id) => {
189
+ online[id] = { userId: id, isOnline: true };
190
+ });
191
+ callbacks.onPresence(online);
192
+ });
193
+ this.presences.set(conversationId, presence);
194
+ }
195
+ channel.join().receive("ok", () => {
196
+ }).receive("error", (reason) => {
197
+ this.config?.onError?.({
198
+ code: "CHANNEL_JOIN_ERROR",
199
+ message: `Failed to join conversation ${conversationId}`,
200
+ details: reason
201
+ });
202
+ });
203
+ this.channels.set(conversationId, channel);
204
+ return channel;
205
+ }
206
+ /** Leave a conversation channel */
207
+ leaveConversation(conversationId) {
208
+ const channel = this.channels.get(conversationId);
209
+ if (channel) {
210
+ channel.leave();
211
+ this.channels.delete(conversationId);
212
+ this.presences.delete(conversationId);
213
+ }
214
+ }
215
+ /** Send a message to a conversation.
216
+ *
217
+ * Phoenix buffers pushes fired while a channel is still joining and
218
+ * replays them on successful join. The default per-push timeout is 10s,
219
+ * which is tight on slow dev backends — we bump to 30s so a late join
220
+ * doesn't surface as a "timed out" error to a message that ultimately
221
+ * persists. The caller still gets 'ok' once the server acks.
222
+ */
223
+ sendMessage(conversationId, payload) {
224
+ return new Promise((resolve, reject) => {
225
+ const channel = this.channels.get(conversationId);
226
+ if (!channel) {
227
+ reject({ code: "NOT_JOINED", message: "Not joined to this conversation" });
228
+ return;
229
+ }
230
+ if (!this.socket?.isConnected()) {
231
+ reject({
232
+ code: "SOCKET_DISCONNECTED",
233
+ message: "Cannot send \u2014 chat server not connected"
234
+ });
235
+ return;
236
+ }
237
+ channel.push(EVENTS.MESSAGE_NEW, payload, 3e4).receive("ok", (response) => resolve(response)).receive(
238
+ "error",
239
+ (reason) => reject({ code: "SEND_ERROR", message: "Failed to send message", details: reason })
240
+ ).receive(
241
+ "timeout",
242
+ () => reject({ code: "TIMEOUT", message: "Message send timed out" })
243
+ );
244
+ });
245
+ }
246
+ /** Broadcast typing indicator */
247
+ sendTyping(conversationId, isTyping) {
248
+ const channel = this.channels.get(conversationId);
249
+ channel?.push(EVENTS.USER_TYPING, {
250
+ userId: this.config?.userId,
251
+ userName: this.config?.userName,
252
+ isTyping
253
+ });
254
+ }
255
+ /** Mark messages as read */
256
+ markAsRead(conversationId, messageId) {
257
+ const channel = this.channels.get(conversationId);
258
+ channel?.push(EVENTS.MESSAGE_READ, {
259
+ messageId,
260
+ userId: this.config?.userId
261
+ });
262
+ }
263
+ // ── Channel Management ──
264
+ /** Add a user to the conversation */
265
+ inviteUser(conversationId, payload) {
266
+ return this.channelPush(conversationId, "invite_user", payload);
267
+ }
268
+ /** Remove a user from the conversation */
269
+ removeUser(conversationId, userId) {
270
+ return this.channelPush(conversationId, "remove_user", { userId });
271
+ }
272
+ /** Update channel name/picture */
273
+ updateChannel(conversationId, payload) {
274
+ return this.channelPush(conversationId, "update_channel", payload);
275
+ }
276
+ /** Delete channel (admin only) */
277
+ deleteChannel(conversationId) {
278
+ return this.channelPush(conversationId, "delete_channel", {});
279
+ }
280
+ /** Pin a message in the conversation (max MAX_PINNED_MESSAGES) */
281
+ pinMessage(conversationId, messageId) {
282
+ return this.channelPush(conversationId, "pin_message", { messageId });
283
+ }
284
+ /** Unpin a message in the conversation */
285
+ unpinMessage(conversationId, messageId) {
286
+ return this.channelPush(conversationId, "unpin_message", { messageId });
287
+ }
288
+ /** Generic push with promise wrapper */
289
+ channelPush(conversationId, event, payload) {
290
+ return new Promise((resolve, reject) => {
291
+ const channel = this.channels.get(conversationId);
292
+ if (!channel) {
293
+ reject({ code: "NOT_JOINED", message: "Not joined to this conversation" });
294
+ return;
295
+ }
296
+ channel.push(event, payload).receive("ok", (response) => resolve(response)).receive(
297
+ "error",
298
+ (reason) => reject({ code: "CHANNEL_ERROR", message: `Failed: ${event}`, details: reason })
299
+ );
300
+ });
301
+ }
302
+ /** Check if connected */
303
+ isConnected() {
304
+ return this.socket?.isConnected() ?? false;
305
+ }
306
+ /** Disconnect socket and leave all channels */
307
+ disconnect() {
308
+ this.channels.forEach((channel) => channel.leave());
309
+ this.channels.clear();
310
+ this.presences.clear();
311
+ this.userChannel?.leave();
312
+ this.socket?.disconnect();
313
+ this.socket = null;
314
+ this.config = null;
315
+ }
316
+ };
317
+ var CollabContext = createContext(null);
318
+ function useCollab() {
319
+ const context = useContext(CollabContext);
320
+ if (!context) {
321
+ throw new Error("useCollab must be used within a <CollabProvider>");
322
+ }
323
+ return context;
324
+ }
325
+ function CollabProvider({ config, apiBaseUrl, children }) {
326
+ const [socket, setSocket] = useState(() => {
327
+ if (typeof window === "undefined") return null;
328
+ return new CollabSocket();
329
+ });
330
+ const [unreadCounts, setUnreadCounts] = useState({});
331
+ const pendingOrderIds = useRef(/* @__PURE__ */ new Set());
332
+ const pendingResolvers = useRef(/* @__PURE__ */ new Map());
333
+ const previewCache = useRef(/* @__PURE__ */ new Map());
334
+ const batchScheduled = useRef(false);
335
+ useEffect(() => {
336
+ if (typeof window === "undefined") return;
337
+ let s = socket;
338
+ if (!s) {
339
+ s = new CollabSocket();
340
+ setSocket(s);
341
+ }
342
+ s.onUnreadCountUpdate((counts) => {
343
+ setUnreadCounts(counts);
344
+ previewCache.current.clear();
345
+ });
346
+ if (!s.isConnected()) {
347
+ s.connect(config);
348
+ }
349
+ }, [config.socketUrl, config.userId]);
350
+ const authHeaders = useCallback(
351
+ () => ({
352
+ Authorization: `Bearer ${config.getToken()}`,
353
+ "Content-Type": "application/json"
354
+ }),
355
+ [config]
356
+ );
357
+ const flushPreviewBatch = useCallback(async () => {
358
+ const orderIds = Array.from(pendingOrderIds.current);
359
+ const resolvers = new Map(pendingResolvers.current);
360
+ pendingOrderIds.current.clear();
361
+ pendingResolvers.current.clear();
362
+ batchScheduled.current = false;
363
+ if (orderIds.length === 0) return;
364
+ try {
365
+ const query = orderIds.map((id) => `order_ids[]=${encodeURIComponent(id)}`).join("&");
366
+ const response = await fetch(`${apiBaseUrl}/api/natoe-colab/conversations/previews?${query}`, {
367
+ headers: authHeaders()
368
+ });
369
+ if (!response.ok) throw new Error(`Preview batch failed: ${response.status}`);
370
+ const data = snakeToCamel(await response.json());
371
+ orderIds.forEach((orderId) => {
372
+ const preview = data.previews[orderId] ?? null;
373
+ previewCache.current.set(orderId, preview);
374
+ resolvers.get(orderId)?.forEach((resolve) => resolve(preview));
375
+ });
376
+ } catch (error) {
377
+ config.onError?.({
378
+ code: "PREVIEW_BATCH_ERROR",
379
+ message: "Failed to fetch conversation previews",
380
+ details: error
381
+ });
382
+ orderIds.forEach((orderId) => {
383
+ resolvers.get(orderId)?.forEach((resolve) => resolve(null));
384
+ });
385
+ }
386
+ }, [apiBaseUrl, authHeaders, config]);
387
+ const requestPreview = useCallback(
388
+ (orderId) => {
389
+ if (previewCache.current.has(orderId)) {
390
+ return Promise.resolve(previewCache.current.get(orderId) ?? null);
391
+ }
392
+ return new Promise((resolve) => {
393
+ pendingOrderIds.current.add(orderId);
394
+ const existing = pendingResolvers.current.get(orderId) || [];
395
+ existing.push(resolve);
396
+ pendingResolvers.current.set(orderId, existing);
397
+ if (!batchScheduled.current) {
398
+ batchScheduled.current = true;
399
+ queueMicrotask(flushPreviewBatch);
400
+ }
401
+ });
402
+ },
403
+ [flushPreviewBatch]
404
+ );
405
+ const invalidatePreview = useCallback((orderId) => {
406
+ previewCache.current.delete(orderId);
407
+ }, []);
408
+ const fetchMessages = useCallback(
409
+ async (conversationId, options = {}) => {
410
+ const limit = options.limit ?? MESSAGES_PAGE_SIZE;
411
+ const params = new URLSearchParams({ limit: String(limit) });
412
+ if (options.before) params.set("before", options.before);
413
+ const response = await fetch(
414
+ `${apiBaseUrl}/api/natoe-colab/conversations/${conversationId}/messages?${params.toString()}`,
415
+ { headers: authHeaders() }
416
+ );
417
+ if (!response.ok) {
418
+ throw { code: "FETCH_ERROR", message: "Failed to fetch messages", status: response.status };
419
+ }
420
+ return snakeToCamel(await response.json());
421
+ },
422
+ [apiBaseUrl, authHeaders]
423
+ );
424
+ const createConversationWithMessage = useCallback(
425
+ async (orderId, message, options) => {
426
+ if (!orderId || !orderId.trim()) {
427
+ throw {
428
+ code: "INVALID_ORDER_ID",
429
+ message: "Cannot create conversation: orderId is empty. Host app passed an order with no orderId/mainOrderId set."
430
+ };
431
+ }
432
+ const response = await fetch(`${apiBaseUrl}/api/natoe-colab/orders/${orderId}/messages`, {
433
+ method: "POST",
434
+ headers: authHeaders(),
435
+ body: JSON.stringify({
436
+ message,
437
+ conversation: {
438
+ name: options.name,
439
+ participant_ids: options.participantIds ?? []
440
+ }
441
+ })
442
+ });
443
+ if (!response.ok) {
444
+ const error = await response.json().catch(() => ({}));
445
+ throw { code: "CREATE_ERROR", message: "Failed to create conversation", details: error };
446
+ }
447
+ const data = snakeToCamel(await response.json());
448
+ previewCache.current.delete(orderId);
449
+ return data;
450
+ },
451
+ [apiBaseUrl, authHeaders]
452
+ );
453
+ const addParticipant = useCallback(
454
+ async (conversationId, userId) => {
455
+ const response = await fetch(
456
+ `${apiBaseUrl}/api/natoe-colab/conversations/${conversationId}/participants`,
457
+ {
458
+ method: "POST",
459
+ headers: authHeaders(),
460
+ body: JSON.stringify({ user_id: userId })
461
+ }
462
+ );
463
+ if (!response.ok) {
464
+ throw { code: "ADD_PARTICIPANT_ERROR", message: "Failed to add participant" };
465
+ }
466
+ },
467
+ [apiBaseUrl, authHeaders]
468
+ );
469
+ const removeParticipant = useCallback(
470
+ async (conversationId, userId) => {
471
+ const response = await fetch(
472
+ `${apiBaseUrl}/api/natoe-colab/conversations/${conversationId}/participants/${userId}`,
473
+ {
474
+ method: "DELETE",
475
+ headers: authHeaders()
476
+ }
477
+ );
478
+ if (!response.ok) {
479
+ throw { code: "REMOVE_PARTICIPANT_ERROR", message: "Failed to remove participant" };
480
+ }
481
+ },
482
+ [apiBaseUrl, authHeaders]
483
+ );
484
+ const fetchConversationList = useCallback(async () => {
485
+ const response = await fetch(`${apiBaseUrl}/api/natoe-colab/conversations`, {
486
+ headers: authHeaders()
487
+ });
488
+ if (!response.ok) {
489
+ throw { code: "LIST_ERROR", message: "Failed to fetch conversation list" };
490
+ }
491
+ return snakeToCamel(await response.json());
492
+ }, [apiBaseUrl, authHeaders]);
493
+ const pinMessage = useCallback(
494
+ async (conversationId, messageId) => {
495
+ const response = await fetch(
496
+ `${apiBaseUrl}/api/natoe-colab/conversations/${conversationId}/messages/${messageId}/pin`,
497
+ {
498
+ method: "POST",
499
+ headers: authHeaders()
500
+ }
501
+ );
502
+ if (!response.ok) {
503
+ const error = await response.json().catch(() => ({}));
504
+ throw { code: "PIN_ERROR", message: "Failed to pin message", details: error };
505
+ }
506
+ },
507
+ [apiBaseUrl, authHeaders]
508
+ );
509
+ const unpinMessage = useCallback(
510
+ async (conversationId, messageId) => {
511
+ const response = await fetch(
512
+ `${apiBaseUrl}/api/natoe-colab/conversations/${conversationId}/messages/${messageId}/pin`,
513
+ {
514
+ method: "DELETE",
515
+ headers: authHeaders()
516
+ }
517
+ );
518
+ if (!response.ok) {
519
+ throw { code: "UNPIN_ERROR", message: "Failed to unpin message" };
520
+ }
521
+ },
522
+ [apiBaseUrl, authHeaders]
523
+ );
524
+ const fetchPinnedMessages = useCallback(
525
+ async (conversationId) => {
526
+ const response = await fetch(
527
+ `${apiBaseUrl}/api/natoe-colab/conversations/${conversationId}/pinned`,
528
+ { headers: authHeaders() }
529
+ );
530
+ if (!response.ok) {
531
+ throw { code: "FETCH_PINNED_ERROR", message: "Failed to fetch pinned messages" };
532
+ }
533
+ return snakeToCamel(await response.json());
534
+ },
535
+ [apiBaseUrl, authHeaders]
536
+ );
537
+ const totalUnread = Object.values(unreadCounts).reduce((sum, count) => sum + count, 0);
538
+ if (!socket) return null;
539
+ return /* @__PURE__ */ jsx(
540
+ CollabContext.Provider,
541
+ {
542
+ value: {
543
+ socket,
544
+ config,
545
+ apiBaseUrl,
546
+ totalUnread,
547
+ requestPreview,
548
+ invalidatePreview,
549
+ fetchMessages,
550
+ createConversationWithMessage,
551
+ addParticipant,
552
+ removeParticipant,
553
+ fetchConversationList,
554
+ pinMessage,
555
+ unpinMessage,
556
+ fetchPinnedMessages
557
+ },
558
+ children
559
+ }
560
+ );
561
+ }
562
+
563
+ // src/core/channelName.ts
564
+ function buildChannelName(patientData) {
565
+ const patientPart = patientData.patientName?.trim() || "Unknown";
566
+ const labPart = patientData.labName?.trim()?.slice(0, 24) || "";
567
+ const last4 = (patientData.displayOrderId ?? "").trim().slice(-4) || "0000";
568
+ return `${patientPart} - ${labPart} - ${last4}`;
569
+ }
570
+
571
+ // src/hooks/useConversation.ts
572
+ function useConversation({
573
+ orderId,
574
+ patientData,
575
+ participantIds = [],
576
+ loadHistory = true
577
+ }) {
578
+ const {
579
+ socket,
580
+ config,
581
+ requestPreview,
582
+ invalidatePreview,
583
+ fetchMessages,
584
+ createConversationWithMessage,
585
+ pinMessage: pinMessageApi,
586
+ unpinMessage: unpinMessageApi
587
+ } = useCollab();
588
+ const [conversation, setConversation] = useState(null);
589
+ const [messages, setMessages] = useState([]);
590
+ const [pinnedMessages, setPinnedMessages] = useState([]);
591
+ const [participants, setParticipants] = useState([]);
592
+ const [typingUsers, setTypingUsers] = useState([]);
593
+ const [isLoading, setIsLoading] = useState(true);
594
+ const [hasMore, setHasMore] = useState(true);
595
+ const [error, setError] = useState(null);
596
+ const [isConnected, setIsConnected] = useState(false);
597
+ const [replyTo, setReplyTo] = useState(null);
598
+ const joinedConversationId = useRef(null);
599
+ const typingTimers = useRef(/* @__PURE__ */ new Map());
600
+ const ensureConversationInFlight = useRef(null);
601
+ const markedReadIdsRef = useRef(/* @__PURE__ */ new Set());
602
+ const buildName = useCallback(
603
+ () => buildChannelName(patientData),
604
+ [patientData]
605
+ );
606
+ const joinChannel = useCallback(
607
+ (conv) => {
608
+ if (joinedConversationId.current === conv.id) return;
609
+ socket.joinConversation(conv.id, {
610
+ // Dedupe by id so an optimistic message (added client-side on send)
611
+ // doesn't double up when the server's broadcast arrives.
612
+ onMessage: (msg) => setMessages((prev) => {
613
+ if (prev.some((m) => m.id === msg.id)) return prev;
614
+ const optimisticIdx = prev.findIndex(
615
+ (m) => m.id.startsWith("pending-") && m.senderId === msg.senderId && m.body === msg.body
616
+ );
617
+ if (optimisticIdx >= 0) {
618
+ const next = prev.slice();
619
+ next[optimisticIdx] = msg;
620
+ return next;
621
+ }
622
+ return [...prev, msg];
623
+ }),
624
+ onTyping: (event) => {
625
+ if (event.userId === config.userId) return;
626
+ if (event.isTyping) {
627
+ setTypingUsers((prev) => {
628
+ const exists = prev.some((t) => t.userId === event.userId);
629
+ return exists ? prev : [...prev, event];
630
+ });
631
+ const existingTimer = typingTimers.current.get(event.userId);
632
+ if (existingTimer) clearTimeout(existingTimer);
633
+ const timer = setTimeout(() => {
634
+ setTypingUsers((prev) => prev.filter((t) => t.userId !== event.userId));
635
+ typingTimers.current.delete(event.userId);
636
+ }, 3e3);
637
+ typingTimers.current.set(event.userId, timer);
638
+ } else {
639
+ setTypingUsers((prev) => prev.filter((t) => t.userId !== event.userId));
640
+ const timer = typingTimers.current.get(event.userId);
641
+ if (timer) clearTimeout(timer);
642
+ typingTimers.current.delete(event.userId);
643
+ }
644
+ },
645
+ onUserJoined: (participant) => {
646
+ setParticipants(
647
+ (prev) => prev.some((p) => p.userId === participant.userId) ? prev : [...prev, participant]
648
+ );
649
+ },
650
+ onUserLeft: (participant) => {
651
+ setParticipants((prev) => prev.filter((p) => p.userId !== participant.userId));
652
+ },
653
+ onChannelUpdated: (updates) => {
654
+ setConversation((prev) => prev ? { ...prev, ...updates } : prev);
655
+ },
656
+ onChannelDeleted: () => {
657
+ setConversation(null);
658
+ setMessages([]);
659
+ joinedConversationId.current = null;
660
+ setIsConnected(false);
661
+ },
662
+ onMessageRead: ({ messageId, userId }) => {
663
+ setMessages(
664
+ (prev) => prev.map(
665
+ (msg) => msg.id === messageId && !(msg.readBy ?? []).includes(userId) ? { ...msg, readBy: [...msg.readBy ?? [], userId] } : msg
666
+ )
667
+ );
668
+ },
669
+ onMessagePinned: (message) => {
670
+ setPinnedMessages(
671
+ (prev) => prev.some((m) => m.id === message.id) ? prev : [...prev, message]
672
+ );
673
+ setMessages(
674
+ (prev) => prev.map((m) => m.id === message.id ? { ...m, isPinned: true } : m)
675
+ );
676
+ },
677
+ onMessageUnpinned: ({ messageId }) => {
678
+ setPinnedMessages((prev) => prev.filter((m) => m.id !== messageId));
679
+ setMessages(
680
+ (prev) => prev.map((m) => m.id === messageId ? { ...m, isPinned: false } : m)
681
+ );
682
+ }
683
+ });
684
+ joinedConversationId.current = conv.id;
685
+ setIsConnected(true);
686
+ },
687
+ [socket, config.userId]
688
+ );
689
+ useEffect(() => {
690
+ let cancelled = false;
691
+ const init = async () => {
692
+ setIsLoading(true);
693
+ setError(null);
694
+ try {
695
+ const preview = await requestPreview(orderId);
696
+ if (cancelled) return;
697
+ if (!preview) {
698
+ setConversation(null);
699
+ setMessages([]);
700
+ setParticipants([]);
701
+ setHasMore(false);
702
+ setIsLoading(false);
703
+ return;
704
+ }
705
+ const existingConv = {
706
+ id: preview.conversationId,
707
+ orderId: preview.orderId,
708
+ name: preview.name,
709
+ participants: preview.participants,
710
+ unreadCount: preview.unreadCount,
711
+ createdAt: "",
712
+ updatedAt: ""
713
+ };
714
+ setConversation(existingConv);
715
+ setParticipants(preview.participants);
716
+ joinChannel(existingConv);
717
+ setMessages(preview.lastMessages);
718
+ setHasMore(preview.messageCount > preview.lastMessages.length);
719
+ setPinnedMessages(preview.lastMessages.filter((m) => m.isPinned));
720
+ setIsLoading(false);
721
+ if (loadHistory) {
722
+ fetchMessages(preview.conversationId).then((history) => {
723
+ if (cancelled) return;
724
+ setMessages((prev) => {
725
+ const seen = new Set(prev.map((m) => m.id));
726
+ const older = history.filter((m) => !seen.has(m.id));
727
+ return [...older, ...prev];
728
+ });
729
+ setHasMore(history.length > 0);
730
+ setPinnedMessages((prev) => {
731
+ const fromHistory = history.filter((m) => m.isPinned);
732
+ const seen = new Set(prev.map((m) => m.id));
733
+ return [...fromHistory.filter((m) => !seen.has(m.id)), ...prev];
734
+ });
735
+ }).catch(() => {
736
+ });
737
+ }
738
+ } catch (err) {
739
+ if (cancelled) return;
740
+ const msg = err instanceof Error ? err.message : "Failed to load conversation";
741
+ setError(msg);
742
+ setIsLoading(false);
743
+ }
744
+ };
745
+ init();
746
+ return () => {
747
+ cancelled = true;
748
+ if (joinedConversationId.current) {
749
+ socket.leaveConversation(joinedConversationId.current);
750
+ joinedConversationId.current = null;
751
+ }
752
+ typingTimers.current.forEach((timer) => clearTimeout(timer));
753
+ typingTimers.current.clear();
754
+ markedReadIdsRef.current.clear();
755
+ setIsConnected(false);
756
+ };
757
+ }, [orderId]);
758
+ const ensureConversation = useCallback(
759
+ async (payload) => {
760
+ if (conversation) return { conv: conversation, persistedByCreate: false };
761
+ if (ensureConversationInFlight.current) {
762
+ const conv = await ensureConversationInFlight.current;
763
+ return { conv, persistedByCreate: false };
764
+ }
765
+ const inflight = (async () => {
766
+ const result = await createConversationWithMessage(orderId, payload, {
767
+ name: buildName(),
768
+ participantIds
769
+ });
770
+ setConversation(result.conversation);
771
+ setParticipants(result.conversation.participants);
772
+ setMessages([result.message]);
773
+ joinChannel(result.conversation);
774
+ invalidatePreview(orderId);
775
+ if (loadHistory) {
776
+ fetchMessages(result.conversation.id).then((history) => {
777
+ setMessages((prev) => {
778
+ const seen = new Set(prev.map((m) => m.id));
779
+ const older = history.filter((m) => !seen.has(m.id));
780
+ return [...older, ...prev];
781
+ });
782
+ setPinnedMessages((prev) => {
783
+ const fromHistory = history.filter((m) => m.isPinned);
784
+ const seen = new Set(prev.map((m) => m.id));
785
+ return [...fromHistory.filter((m) => !seen.has(m.id)), ...prev];
786
+ });
787
+ }).catch(() => {
788
+ });
789
+ }
790
+ return result.conversation;
791
+ })();
792
+ ensureConversationInFlight.current = inflight;
793
+ try {
794
+ const conv = await inflight;
795
+ return { conv, persistedByCreate: true };
796
+ } finally {
797
+ ensureConversationInFlight.current = null;
798
+ }
799
+ },
800
+ [
801
+ conversation,
802
+ createConversationWithMessage,
803
+ orderId,
804
+ buildName,
805
+ participantIds,
806
+ joinChannel,
807
+ invalidatePreview,
808
+ loadHistory,
809
+ fetchMessages
810
+ ]
811
+ );
812
+ const sendMessage = useCallback(
813
+ async (body) => {
814
+ const payload = {
815
+ body,
816
+ type: "text",
817
+ ...replyTo ? { replyToId: replyTo.id } : {}
818
+ };
819
+ const { conv, persistedByCreate } = await ensureConversation(payload);
820
+ setReplyTo(null);
821
+ if (persistedByCreate) return;
822
+ const optimistic = {
823
+ id: `pending-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
824
+ conversationId: conv.id,
825
+ senderId: config.userId,
826
+ senderName: config.userName,
827
+ senderRole: config.userRole,
828
+ body,
829
+ type: "text",
830
+ insertedAt: (/* @__PURE__ */ new Date()).toISOString(),
831
+ ...replyTo ? {
832
+ replyToId: replyTo.id,
833
+ replyToSnapshot: {
834
+ messageId: replyTo.id,
835
+ senderId: replyTo.senderId,
836
+ senderName: replyTo.senderName,
837
+ body: replyTo.body,
838
+ type: replyTo.type
839
+ }
840
+ } : {}
841
+ };
842
+ setMessages((prev) => [...prev, optimistic]);
843
+ try {
844
+ await socket.sendMessage(conv.id, payload);
845
+ } catch (err) {
846
+ setMessages((prev) => prev.filter((m) => m.id !== optimistic.id));
847
+ config.onError?.({
848
+ code: "SEND_FAILED",
849
+ message: "Failed to send message",
850
+ details: err
851
+ });
852
+ }
853
+ },
854
+ [ensureConversation, socket, replyTo, config]
855
+ );
856
+ const sendAudioMessage = useCallback(
857
+ async (audioBlob, duration) => {
858
+ if (!config.onUploadFile) return;
859
+ const url = await config.onUploadFile(audioBlob, "voice-message.webm");
860
+ const payload = {
861
+ body: "Voice message",
862
+ type: "audio",
863
+ mediaUrl: url,
864
+ mediaDuration: duration
865
+ };
866
+ const { conv, persistedByCreate } = await ensureConversation(payload);
867
+ if (persistedByCreate) return;
868
+ await socket.sendMessage(conv.id, payload);
869
+ },
870
+ [config, ensureConversation, socket]
871
+ );
872
+ const sendFileMessage = useCallback(
873
+ async (file) => {
874
+ if (!config.onUploadFile) return;
875
+ const url = await config.onUploadFile(file, file.name);
876
+ const isImage = file.type.startsWith("image/");
877
+ const payload = {
878
+ body: file.name,
879
+ type: isImage ? "image" : "file",
880
+ mediaUrl: url,
881
+ fileName: file.name
882
+ };
883
+ const { conv, persistedByCreate } = await ensureConversation(payload);
884
+ if (persistedByCreate) return;
885
+ await socket.sendMessage(conv.id, payload);
886
+ },
887
+ [config, ensureConversation, socket]
888
+ );
889
+ const sendDeepLink = useCallback(
890
+ async (path, label) => {
891
+ const payload = {
892
+ body: label,
893
+ type: "deep_link",
894
+ metadata: { path }
895
+ };
896
+ const { conv, persistedByCreate } = await ensureConversation(payload);
897
+ if (persistedByCreate) return;
898
+ await socket.sendMessage(conv.id, payload);
899
+ },
900
+ [ensureConversation, socket]
901
+ );
902
+ const sendTypingIndicator = useCallback(
903
+ (isTyping) => {
904
+ if (!conversation || !isConnected) return;
905
+ socket.sendTyping(conversation.id, isTyping);
906
+ },
907
+ [conversation, isConnected, socket]
908
+ );
909
+ const markAsRead = useCallback(
910
+ (messageId) => {
911
+ if (!conversation || !isConnected) return;
912
+ if (markedReadIdsRef.current.has(messageId)) return;
913
+ markedReadIdsRef.current.add(messageId);
914
+ socket.markAsRead(conversation.id, messageId);
915
+ },
916
+ [conversation, isConnected, socket]
917
+ );
918
+ const loadMoreMessages = useCallback(async () => {
919
+ if (!conversation || !hasMore || isLoading) return;
920
+ const oldestMessage = messages[0];
921
+ const before = oldestMessage?.id;
922
+ setIsLoading(true);
923
+ try {
924
+ const older = await fetchMessages(conversation.id, { before });
925
+ setMessages((prev) => [...older, ...prev]);
926
+ setHasMore(older.length > 0);
927
+ } catch (err) {
928
+ config.onError?.({
929
+ code: "LOAD_ERROR",
930
+ message: "Failed to load older messages",
931
+ details: err
932
+ });
933
+ } finally {
934
+ setIsLoading(false);
935
+ }
936
+ }, [conversation, hasMore, isLoading, messages, fetchMessages, config]);
937
+ const pinMessage = useCallback(
938
+ async (messageId) => {
939
+ if (!conversation) return;
940
+ try {
941
+ await pinMessageApi(conversation.id, messageId);
942
+ } catch (err) {
943
+ config.onError?.({
944
+ code: "PIN_ERROR",
945
+ message: "Failed to pin message",
946
+ details: err
947
+ });
948
+ }
949
+ },
950
+ [conversation, pinMessageApi, config]
951
+ );
952
+ const unpinMessage = useCallback(
953
+ async (messageId) => {
954
+ if (!conversation) return;
955
+ try {
956
+ await unpinMessageApi(conversation.id, messageId);
957
+ } catch (err) {
958
+ config.onError?.({
959
+ code: "UNPIN_ERROR",
960
+ message: "Failed to unpin message",
961
+ details: err
962
+ });
963
+ }
964
+ },
965
+ [conversation, unpinMessageApi, config]
966
+ );
967
+ return {
968
+ conversation,
969
+ messages,
970
+ pinnedMessages,
971
+ participants,
972
+ typingUsers,
973
+ isLoading,
974
+ hasMore,
975
+ error,
976
+ isConnected,
977
+ replyTo,
978
+ setReplyTo,
979
+ sendMessage,
980
+ sendAudioMessage,
981
+ sendFileMessage,
982
+ sendDeepLink,
983
+ sendTyping: sendTypingIndicator,
984
+ markAsRead,
985
+ loadMoreMessages,
986
+ pinMessage,
987
+ unpinMessage
988
+ };
989
+ }
990
+ function useChannelSettings({ conversationId }) {
991
+ const { socket, config } = useCollab();
992
+ const [isUpdating, setIsUpdating] = useState(false);
993
+ const [error, setError] = useState(null);
994
+ const isAdmin = config.userRole === "admin";
995
+ const withLoading = useCallback(
996
+ async (operation) => {
997
+ if (!conversationId) return;
998
+ setIsUpdating(true);
999
+ setError(null);
1000
+ try {
1001
+ const result = await operation();
1002
+ setIsUpdating(false);
1003
+ return result;
1004
+ } catch (err) {
1005
+ const collabError = typeof err === "object" && err !== null && "code" in err ? err : { code: "UNKNOWN", message: "Operation failed" };
1006
+ setError(collabError);
1007
+ setIsUpdating(false);
1008
+ config.onError?.(collabError);
1009
+ return void 0;
1010
+ }
1011
+ },
1012
+ [conversationId, config]
1013
+ );
1014
+ const inviteUser = useCallback(
1015
+ async (payload) => {
1016
+ await withLoading(() => socket.inviteUser(conversationId, payload));
1017
+ },
1018
+ [conversationId, socket, withLoading]
1019
+ );
1020
+ const removeUser = useCallback(
1021
+ async (userId) => {
1022
+ await withLoading(() => socket.removeUser(conversationId, userId));
1023
+ },
1024
+ [conversationId, socket, withLoading]
1025
+ );
1026
+ const leaveChannel = useCallback(async () => {
1027
+ await withLoading(async () => {
1028
+ await socket.removeUser(conversationId, config.userId);
1029
+ socket.leaveConversation(conversationId);
1030
+ });
1031
+ }, [conversationId, socket, config.userId, withLoading]);
1032
+ const deleteChannel = useCallback(async () => {
1033
+ if (!isAdmin) {
1034
+ const err = {
1035
+ code: "FORBIDDEN",
1036
+ message: "Only admins can delete channels"
1037
+ };
1038
+ setError(err);
1039
+ config.onError?.(err);
1040
+ return;
1041
+ }
1042
+ await withLoading(() => socket.deleteChannel(conversationId));
1043
+ }, [conversationId, socket, isAdmin, config, withLoading]);
1044
+ const updateChannel = useCallback(
1045
+ async (payload) => {
1046
+ await withLoading(() => socket.updateChannel(conversationId, payload));
1047
+ },
1048
+ [conversationId, socket, withLoading]
1049
+ );
1050
+ return {
1051
+ inviteUser,
1052
+ removeUser,
1053
+ leaveChannel,
1054
+ deleteChannel,
1055
+ updateChannel,
1056
+ isUpdating,
1057
+ error,
1058
+ isAdmin
1059
+ };
1060
+ }
1061
+ function useDeepLinks() {
1062
+ const { config } = useCollab();
1063
+ const parseDeepLink = useCallback((link) => {
1064
+ if (!link.startsWith(DEEP_LINK_PREFIX)) return null;
1065
+ const path = link.slice(DEEP_LINK_PREFIX.length);
1066
+ const parts = path.split("/").filter(Boolean);
1067
+ if (parts.length < 2) return null;
1068
+ return {
1069
+ raw: link,
1070
+ path,
1071
+ resource: parts[0],
1072
+ id: parts[1]
1073
+ };
1074
+ }, []);
1075
+ const handleDeepLink = useCallback(
1076
+ (link) => {
1077
+ const parsed = parseDeepLink(link);
1078
+ if (!parsed) return;
1079
+ if (parsed.resource === "dicom" && config.onOpenDicom) {
1080
+ config.onOpenDicom(parsed.id, "");
1081
+ return;
1082
+ }
1083
+ if (config.onDeepLink) {
1084
+ config.onDeepLink(parsed.path);
1085
+ }
1086
+ },
1087
+ [parseDeepLink, config]
1088
+ );
1089
+ const containsDeepLink = useCallback((text) => {
1090
+ return text.includes(DEEP_LINK_PREFIX);
1091
+ }, []);
1092
+ const extractDeepLinks = useCallback(
1093
+ (text) => {
1094
+ const regex = new RegExp(`${escapeRegex(DEEP_LINK_PREFIX)}[\\w/.-]+`, "g");
1095
+ const matches = text.match(regex) || [];
1096
+ return matches.map(parseDeepLink).filter((link) => link !== null);
1097
+ },
1098
+ [parseDeepLink]
1099
+ );
1100
+ return {
1101
+ parseDeepLink,
1102
+ handleDeepLink,
1103
+ containsDeepLink,
1104
+ extractDeepLinks
1105
+ };
1106
+ }
1107
+ function escapeRegex(string) {
1108
+ return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1109
+ }
1110
+ function PatientHeader({
1111
+ patientData,
1112
+ onOpenDicom,
1113
+ onOpenSettings,
1114
+ className
1115
+ }) {
1116
+ const hasDicom = !!(patientData.studyId && patientData.storageId);
1117
+ return /* @__PURE__ */ jsxs("div", { className, style: styles.container, children: [
1118
+ /* @__PURE__ */ jsx("div", { style: styles.infoRow, children: /* @__PURE__ */ jsx("div", { style: styles.patientInfo, children: /* @__PURE__ */ jsx("span", { style: styles.patientName, children: buildChannelName(patientData) }) }) }),
1119
+ /* @__PURE__ */ jsxs("div", { style: styles.secondaryRow, children: [
1120
+ patientData.patientAge && /* @__PURE__ */ jsxs("span", { style: styles.secondary, children: [
1121
+ patientData.patientAge,
1122
+ patientData.patientSex ? `/${patientData.patientSex}` : ""
1123
+ ] }),
1124
+ patientData.studyType && /* @__PURE__ */ jsx("span", { style: styles.secondary, children: patientData.studyType }),
1125
+ patientData.bodyParts && patientData.bodyParts.length > 0 && /* @__PURE__ */ jsx("span", { style: styles.secondary, children: patientData.bodyParts.join(", ") }),
1126
+ patientData.referringPhysician && /* @__PURE__ */ jsxs("span", { style: styles.secondary, children: [
1127
+ "Ref: ",
1128
+ patientData.referringPhysician
1129
+ ] })
1130
+ ] }),
1131
+ /* @__PURE__ */ jsxs("div", { style: styles.actions, children: [
1132
+ hasDicom && onOpenDicom && /* @__PURE__ */ jsx("button", { onClick: onOpenDicom, style: styles.dicomButton, type: "button", children: "View DICOM" }),
1133
+ onOpenSettings && /* @__PURE__ */ jsx("button", { onClick: onOpenSettings, style: styles.settingsButton, type: "button", children: "Settings" })
1134
+ ] })
1135
+ ] });
1136
+ }
1137
+ var styles = {
1138
+ container: {
1139
+ padding: "12px 16px",
1140
+ borderBottom: "1px solid #e5e7eb",
1141
+ backgroundColor: "#f9fafb"
1142
+ },
1143
+ infoRow: {
1144
+ display: "flex",
1145
+ justifyContent: "space-between",
1146
+ alignItems: "center"
1147
+ },
1148
+ patientInfo: {
1149
+ display: "flex",
1150
+ alignItems: "center",
1151
+ gap: "8px",
1152
+ flexWrap: "wrap"
1153
+ },
1154
+ patientName: {
1155
+ fontWeight: 600,
1156
+ fontSize: "15px",
1157
+ color: "#111827"
1158
+ },
1159
+ secondaryRow: {
1160
+ display: "flex",
1161
+ gap: "12px",
1162
+ marginTop: "4px",
1163
+ alignItems: "center"
1164
+ },
1165
+ secondary: {
1166
+ fontSize: "12px",
1167
+ color: "#6b7280"
1168
+ },
1169
+ actions: {
1170
+ display: "flex",
1171
+ gap: "8px",
1172
+ marginTop: "8px"
1173
+ },
1174
+ dicomButton: {
1175
+ padding: "6px 12px",
1176
+ fontSize: "13px",
1177
+ fontWeight: 500,
1178
+ color: "#ffffff",
1179
+ backgroundColor: "#2563eb",
1180
+ border: "none",
1181
+ borderRadius: "6px",
1182
+ cursor: "pointer"
1183
+ },
1184
+ settingsButton: {
1185
+ padding: "6px 12px",
1186
+ fontSize: "13px",
1187
+ fontWeight: 500,
1188
+ color: "#374151",
1189
+ backgroundColor: "#ffffff",
1190
+ border: "1px solid #d1d5db",
1191
+ borderRadius: "6px",
1192
+ cursor: "pointer"
1193
+ }
1194
+ };
1195
+ function ReplyQuoteBlock({
1196
+ snapshot,
1197
+ inOwnBubble = false,
1198
+ onClick,
1199
+ className
1200
+ }) {
1201
+ const baseBg = inOwnBubble ? "rgba(255,255,255,0.18)" : "#f3f4f6";
1202
+ const barColor = inOwnBubble ? "#ffffff" : "#2563eb";
1203
+ const nameColor = inOwnBubble ? "#ffffff" : "#2563eb";
1204
+ const bodyColor = inOwnBubble ? "rgba(255,255,255,0.85)" : "#4b5563";
1205
+ const preview = renderSnapshotPreview(snapshot);
1206
+ return /* @__PURE__ */ jsxs(
1207
+ "div",
1208
+ {
1209
+ className,
1210
+ style: { ...styles2.container, backgroundColor: baseBg },
1211
+ onClick: () => onClick?.(snapshot.messageId),
1212
+ role: onClick ? "button" : void 0,
1213
+ tabIndex: onClick ? 0 : void 0,
1214
+ children: [
1215
+ /* @__PURE__ */ jsx("div", { style: { ...styles2.bar, backgroundColor: barColor } }),
1216
+ /* @__PURE__ */ jsxs("div", { style: styles2.content, children: [
1217
+ /* @__PURE__ */ jsx("span", { style: { ...styles2.senderName, color: nameColor }, children: snapshot.senderName }),
1218
+ /* @__PURE__ */ jsx("span", { style: { ...styles2.body, color: bodyColor }, children: preview })
1219
+ ] })
1220
+ ]
1221
+ }
1222
+ );
1223
+ }
1224
+ function renderSnapshotPreview(snapshot) {
1225
+ switch (snapshot.type) {
1226
+ case "audio":
1227
+ return "\u{1F3A4} Voice message";
1228
+ case "image":
1229
+ return "\u{1F4F7} Image";
1230
+ case "file":
1231
+ return "\u{1F4CE} File";
1232
+ case "deep_link":
1233
+ return `\u{1F517} ${snapshot.body}`;
1234
+ default: {
1235
+ const body = snapshot.body || "";
1236
+ return body.length > 80 ? `${body.slice(0, 80)}\u2026` : body;
1237
+ }
1238
+ }
1239
+ }
1240
+ var styles2 = {
1241
+ container: {
1242
+ display: "flex",
1243
+ gap: "6px",
1244
+ padding: "4px 8px",
1245
+ marginBottom: "4px",
1246
+ borderRadius: "6px",
1247
+ cursor: "pointer",
1248
+ maxWidth: "100%"
1249
+ },
1250
+ bar: {
1251
+ width: "3px",
1252
+ borderRadius: "2px",
1253
+ flexShrink: 0
1254
+ },
1255
+ content: {
1256
+ display: "flex",
1257
+ flexDirection: "column",
1258
+ gap: "1px",
1259
+ minWidth: 0,
1260
+ overflow: "hidden"
1261
+ },
1262
+ senderName: {
1263
+ fontSize: "11px",
1264
+ fontWeight: 600,
1265
+ lineHeight: "1.2"
1266
+ },
1267
+ body: {
1268
+ fontSize: "11px",
1269
+ lineHeight: "1.3",
1270
+ overflow: "hidden",
1271
+ textOverflow: "ellipsis",
1272
+ whiteSpace: "nowrap"
1273
+ }
1274
+ };
1275
+ function SeenByIndicator({
1276
+ readBy,
1277
+ participants,
1278
+ currentUserId,
1279
+ senderId,
1280
+ className
1281
+ }) {
1282
+ const [expanded, setExpanded] = useState(false);
1283
+ if (senderId !== currentUserId) return null;
1284
+ const seenByOthers = readBy.filter((id) => id !== currentUserId && id !== senderId).map((id) => participants.find((p) => p.userId === id)).filter((p) => !!p);
1285
+ if (seenByOthers.length === 0) return null;
1286
+ const displayCount = expanded ? seenByOthers.length : Math.min(3, seenByOthers.length);
1287
+ const shown = seenByOthers.slice(0, displayCount);
1288
+ const remainder = seenByOthers.length - displayCount;
1289
+ return /* @__PURE__ */ jsxs("div", { className, style: styles3.container, children: [
1290
+ /* @__PURE__ */ jsx("span", { style: styles3.checkmark, children: "\u2713\u2713" }),
1291
+ /* @__PURE__ */ jsxs("span", { style: styles3.label, children: [
1292
+ "Seen by ",
1293
+ shown.map((p) => p.userName ?? "User").join(", ")
1294
+ ] }),
1295
+ remainder > 0 && /* @__PURE__ */ jsxs(
1296
+ "button",
1297
+ {
1298
+ onClick: (e) => {
1299
+ e.stopPropagation();
1300
+ setExpanded(true);
1301
+ },
1302
+ style: styles3.moreButton,
1303
+ type: "button",
1304
+ children: [
1305
+ "+",
1306
+ remainder
1307
+ ]
1308
+ }
1309
+ )
1310
+ ] });
1311
+ }
1312
+ var styles3 = {
1313
+ container: {
1314
+ display: "flex",
1315
+ alignItems: "center",
1316
+ gap: "4px",
1317
+ marginTop: "2px",
1318
+ fontSize: "10px",
1319
+ opacity: 0.75
1320
+ },
1321
+ checkmark: {
1322
+ fontSize: "10px",
1323
+ color: "#60a5fa"
1324
+ },
1325
+ label: {
1326
+ fontSize: "10px",
1327
+ color: "inherit"
1328
+ },
1329
+ moreButton: {
1330
+ fontSize: "10px",
1331
+ fontWeight: 600,
1332
+ color: "inherit",
1333
+ backgroundColor: "transparent",
1334
+ border: "none",
1335
+ padding: 0,
1336
+ cursor: "pointer",
1337
+ textDecoration: "underline"
1338
+ }
1339
+ };
1340
+ function MessageActionsMenu({
1341
+ message,
1342
+ onReply,
1343
+ onPin,
1344
+ onUnpin,
1345
+ onCopy,
1346
+ visible,
1347
+ pinDisabled = false,
1348
+ className
1349
+ }) {
1350
+ if (!visible) return null;
1351
+ const isPinned = !!message.isPinned;
1352
+ const canCopy = message.type === "text" && !!message.body;
1353
+ const handleCopy = () => {
1354
+ if (!onCopy) return;
1355
+ onCopy(message.body);
1356
+ };
1357
+ return /* @__PURE__ */ jsxs("div", { className, style: styles4.container, children: [
1358
+ /* @__PURE__ */ jsx(
1359
+ "button",
1360
+ {
1361
+ onClick: () => onReply(message),
1362
+ style: styles4.button,
1363
+ title: "Reply",
1364
+ type: "button",
1365
+ children: "\u21A9"
1366
+ }
1367
+ ),
1368
+ isPinned ? /* @__PURE__ */ jsx(
1369
+ "button",
1370
+ {
1371
+ onClick: () => onUnpin(message.id),
1372
+ style: styles4.button,
1373
+ title: "Unpin",
1374
+ type: "button",
1375
+ children: "\u{1F4CC}"
1376
+ }
1377
+ ) : /* @__PURE__ */ jsx(
1378
+ "button",
1379
+ {
1380
+ onClick: () => onPin(message.id),
1381
+ style: {
1382
+ ...styles4.button,
1383
+ opacity: pinDisabled ? 0.4 : 1,
1384
+ cursor: pinDisabled ? "not-allowed" : "pointer"
1385
+ },
1386
+ disabled: pinDisabled,
1387
+ title: pinDisabled ? "Pin limit reached (max 3)" : "Pin",
1388
+ type: "button",
1389
+ children: "\u{1F4CC}"
1390
+ }
1391
+ ),
1392
+ canCopy && onCopy && /* @__PURE__ */ jsx(
1393
+ "button",
1394
+ {
1395
+ onClick: handleCopy,
1396
+ style: styles4.button,
1397
+ title: "Copy text",
1398
+ type: "button",
1399
+ children: "\u2398"
1400
+ }
1401
+ )
1402
+ ] });
1403
+ }
1404
+ var styles4 = {
1405
+ container: {
1406
+ display: "flex",
1407
+ gap: "2px",
1408
+ padding: "2px",
1409
+ backgroundColor: "#ffffff",
1410
+ border: "1px solid #e5e7eb",
1411
+ borderRadius: "20px",
1412
+ boxShadow: "0 2px 8px rgba(0, 0, 0, 0.08)"
1413
+ },
1414
+ button: {
1415
+ width: "24px",
1416
+ height: "24px",
1417
+ display: "flex",
1418
+ alignItems: "center",
1419
+ justifyContent: "center",
1420
+ fontSize: "12px",
1421
+ backgroundColor: "transparent",
1422
+ border: "none",
1423
+ borderRadius: "50%",
1424
+ cursor: "pointer"
1425
+ }
1426
+ };
1427
+ function MessageBubble({
1428
+ message,
1429
+ isOwn,
1430
+ participants = [],
1431
+ currentUserId = "",
1432
+ showSeenBy = true,
1433
+ onDeepLinkClick,
1434
+ onReply,
1435
+ onPin,
1436
+ onUnpin,
1437
+ onCopy,
1438
+ onReplyJumpTo,
1439
+ pinDisabled = false,
1440
+ className
1441
+ }) {
1442
+ const [isHovered, setIsHovered] = useState(false);
1443
+ if (message.type === "system") {
1444
+ return /* @__PURE__ */ jsx(SystemBubble, { message });
1445
+ }
1446
+ const hasActions = !!(onReply || onPin || onUnpin || onCopy);
1447
+ return /* @__PURE__ */ jsx(
1448
+ "div",
1449
+ {
1450
+ className,
1451
+ style: {
1452
+ ...styles5.wrapper,
1453
+ justifyContent: isOwn ? "flex-end" : "flex-start"
1454
+ },
1455
+ onMouseEnter: () => setIsHovered(true),
1456
+ onMouseLeave: () => setIsHovered(false),
1457
+ children: /* @__PURE__ */ jsxs("div", { style: styles5.bubbleWrapper, children: [
1458
+ /* @__PURE__ */ jsxs(
1459
+ "div",
1460
+ {
1461
+ style: {
1462
+ ...styles5.bubble,
1463
+ ...isOwn ? styles5.ownBubble : styles5.otherBubble
1464
+ },
1465
+ children: [
1466
+ message.isPinned && /* @__PURE__ */ jsx("div", { style: styles5.pinnedBadge, children: "\u{1F4CC} Pinned" }),
1467
+ message.replyToSnapshot && /* @__PURE__ */ jsx(
1468
+ ReplyQuoteBlock,
1469
+ {
1470
+ snapshot: message.replyToSnapshot,
1471
+ inOwnBubble: isOwn,
1472
+ onClick: onReplyJumpTo
1473
+ }
1474
+ ),
1475
+ !isOwn && /* @__PURE__ */ jsxs("div", { style: styles5.senderRow, children: [
1476
+ /* @__PURE__ */ jsx("span", { style: styles5.senderName, children: message.senderName }),
1477
+ /* @__PURE__ */ jsx(RoleBadge, { role: message.senderRole })
1478
+ ] }),
1479
+ message.type === "text" && /* @__PURE__ */ jsx(TextContent, { body: message.body, onDeepLinkClick }),
1480
+ message.type === "audio" && /* @__PURE__ */ jsx(AudioContent, { mediaUrl: message.mediaUrl, duration: message.mediaDuration }),
1481
+ message.type === "image" && /* @__PURE__ */ jsx(ImageContent, { mediaUrl: message.mediaUrl, fileName: message.fileName }),
1482
+ message.type === "file" && /* @__PURE__ */ jsx(FileContent, { mediaUrl: message.mediaUrl, fileName: message.fileName }),
1483
+ message.type === "deep_link" && /* @__PURE__ */ jsx(DeepLinkContent, { body: message.body, metadata: message.metadata, onDeepLinkClick }),
1484
+ /* @__PURE__ */ jsx("div", { style: styles5.timestamp, children: formatTime(message.insertedAt) }),
1485
+ showSeenBy && isOwn && /* @__PURE__ */ jsx(
1486
+ SeenByIndicator,
1487
+ {
1488
+ readBy: message.readBy ?? [],
1489
+ participants,
1490
+ currentUserId,
1491
+ senderId: message.senderId
1492
+ }
1493
+ )
1494
+ ]
1495
+ }
1496
+ ),
1497
+ hasActions && /* @__PURE__ */ jsx(
1498
+ "div",
1499
+ {
1500
+ style: {
1501
+ ...styles5.actionsWrapper,
1502
+ [isOwn ? "right" : "left"]: "100%",
1503
+ [isOwn ? "marginRight" : "marginLeft"]: "6px"
1504
+ },
1505
+ children: /* @__PURE__ */ jsx(
1506
+ MessageActionsMenu,
1507
+ {
1508
+ message,
1509
+ onReply: onReply ?? (() => {
1510
+ }),
1511
+ onPin: onPin ?? (() => {
1512
+ }),
1513
+ onUnpin: onUnpin ?? (() => {
1514
+ }),
1515
+ onCopy,
1516
+ visible: isHovered,
1517
+ pinDisabled
1518
+ }
1519
+ )
1520
+ }
1521
+ )
1522
+ ] })
1523
+ }
1524
+ );
1525
+ }
1526
+ function TextContent({ body, onDeepLinkClick }) {
1527
+ if (body.includes(DEEP_LINK_PREFIX)) {
1528
+ const parts = body.split(new RegExp(`(${escapeRegex2(DEEP_LINK_PREFIX)}[\\w/.-]+)`, "g"));
1529
+ return /* @__PURE__ */ jsx("p", { style: styles5.textBody, children: parts.map(
1530
+ (part, i) => part.startsWith(DEEP_LINK_PREFIX) ? /* @__PURE__ */ jsx(
1531
+ "a",
1532
+ {
1533
+ onClick: (e) => {
1534
+ e.preventDefault();
1535
+ onDeepLinkClick?.(part);
1536
+ },
1537
+ href: "#",
1538
+ style: styles5.deepLink,
1539
+ children: part
1540
+ },
1541
+ i
1542
+ ) : /* @__PURE__ */ jsx("span", { children: part }, i)
1543
+ ) });
1544
+ }
1545
+ return /* @__PURE__ */ jsx("p", { style: styles5.textBody, children: body });
1546
+ }
1547
+ function AudioContent({ mediaUrl, duration }) {
1548
+ if (!mediaUrl) return null;
1549
+ return /* @__PURE__ */ jsxs("div", { style: styles5.audioContainer, children: [
1550
+ /* @__PURE__ */ jsx("audio", { controls: true, preload: "metadata", style: styles5.audioPlayer, children: /* @__PURE__ */ jsx("source", { src: mediaUrl }) }),
1551
+ duration != null && /* @__PURE__ */ jsx("span", { style: styles5.audioDuration, children: formatDuration(duration) })
1552
+ ] });
1553
+ }
1554
+ function ImageContent({ mediaUrl, fileName }) {
1555
+ if (!mediaUrl) return null;
1556
+ return /* @__PURE__ */ jsx("a", { href: mediaUrl, target: "_blank", rel: "noopener noreferrer", children: /* @__PURE__ */ jsx("img", { src: mediaUrl, alt: fileName || "Image", style: styles5.imagePreview }) });
1557
+ }
1558
+ function FileContent({ mediaUrl, fileName }) {
1559
+ if (!mediaUrl) return null;
1560
+ return /* @__PURE__ */ jsxs("a", { href: mediaUrl, target: "_blank", rel: "noopener noreferrer", style: styles5.fileLink, children: [
1561
+ /* @__PURE__ */ jsx("span", { style: styles5.fileIcon, children: "\u{1F4C4}" }),
1562
+ /* @__PURE__ */ jsx("span", { style: styles5.fileName, children: fileName || "Download file" })
1563
+ ] });
1564
+ }
1565
+ function DeepLinkContent({
1566
+ body,
1567
+ metadata,
1568
+ onDeepLinkClick
1569
+ }) {
1570
+ const path = metadata?.path;
1571
+ return /* @__PURE__ */ jsxs(
1572
+ "div",
1573
+ {
1574
+ style: styles5.deepLinkCard,
1575
+ onClick: () => path && onDeepLinkClick?.(`${DEEP_LINK_PREFIX}${path}`),
1576
+ children: [
1577
+ /* @__PURE__ */ jsx("span", { style: styles5.deepLinkLabel, children: body }),
1578
+ path && /* @__PURE__ */ jsxs("span", { style: styles5.deepLinkPath, children: [
1579
+ DEEP_LINK_PREFIX,
1580
+ path
1581
+ ] })
1582
+ ]
1583
+ }
1584
+ );
1585
+ }
1586
+ function SystemBubble({ message }) {
1587
+ return /* @__PURE__ */ jsxs("div", { style: styles5.systemMessage, children: [
1588
+ /* @__PURE__ */ jsx("span", { children: message.body }),
1589
+ /* @__PURE__ */ jsx("span", { style: styles5.systemTime, children: formatTime(message.insertedAt) })
1590
+ ] });
1591
+ }
1592
+ function RoleBadge({ role }) {
1593
+ const colors = {
1594
+ radiologist: "#7c3aed",
1595
+ lab: "#2563eb",
1596
+ physician: "#059669",
1597
+ admin: "#dc2626"
1598
+ };
1599
+ return /* @__PURE__ */ jsx("span", { style: { ...styles5.roleBadge, backgroundColor: `${colors[role]}15`, color: colors[role] }, children: role });
1600
+ }
1601
+ function formatTime(iso) {
1602
+ try {
1603
+ return new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
1604
+ } catch {
1605
+ return "";
1606
+ }
1607
+ }
1608
+ function formatDuration(seconds) {
1609
+ const m = Math.floor(seconds / 60);
1610
+ const s = seconds % 60;
1611
+ return `${m}:${s.toString().padStart(2, "0")}`;
1612
+ }
1613
+ function escapeRegex2(string) {
1614
+ return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1615
+ }
1616
+ var styles5 = {
1617
+ wrapper: {
1618
+ display: "flex",
1619
+ marginBottom: "8px",
1620
+ paddingLeft: "16px",
1621
+ paddingRight: "16px"
1622
+ },
1623
+ bubbleWrapper: {
1624
+ position: "relative",
1625
+ maxWidth: "75%"
1626
+ },
1627
+ bubble: {
1628
+ padding: "8px 12px",
1629
+ borderRadius: "12px",
1630
+ wordBreak: "break-word"
1631
+ },
1632
+ ownBubble: {
1633
+ backgroundColor: "#2563eb",
1634
+ color: "#ffffff",
1635
+ borderBottomRightRadius: "4px"
1636
+ },
1637
+ otherBubble: {
1638
+ backgroundColor: "#f3f4f6",
1639
+ color: "#111827",
1640
+ borderBottomLeftRadius: "4px"
1641
+ },
1642
+ pinnedBadge: {
1643
+ display: "inline-block",
1644
+ fontSize: "10px",
1645
+ fontWeight: 600,
1646
+ marginBottom: "4px",
1647
+ opacity: 0.7
1648
+ },
1649
+ senderRow: {
1650
+ display: "flex",
1651
+ alignItems: "center",
1652
+ gap: "6px",
1653
+ marginBottom: "4px"
1654
+ },
1655
+ senderName: {
1656
+ fontSize: "12px",
1657
+ fontWeight: 600,
1658
+ color: "#374151"
1659
+ },
1660
+ roleBadge: {
1661
+ fontSize: "10px",
1662
+ fontWeight: 500,
1663
+ padding: "1px 6px",
1664
+ borderRadius: "4px"
1665
+ },
1666
+ textBody: {
1667
+ margin: 0,
1668
+ fontSize: "14px",
1669
+ lineHeight: "1.4"
1670
+ },
1671
+ timestamp: {
1672
+ fontSize: "10px",
1673
+ opacity: 0.7,
1674
+ marginTop: "4px",
1675
+ textAlign: "right"
1676
+ },
1677
+ actionsWrapper: {
1678
+ position: "absolute",
1679
+ top: "50%",
1680
+ transform: "translateY(-50%)",
1681
+ zIndex: 10
1682
+ },
1683
+ audioContainer: {
1684
+ display: "flex",
1685
+ alignItems: "center",
1686
+ gap: "8px"
1687
+ },
1688
+ audioPlayer: {
1689
+ height: "32px",
1690
+ maxWidth: "220px"
1691
+ },
1692
+ audioDuration: {
1693
+ fontSize: "12px",
1694
+ opacity: 0.7,
1695
+ whiteSpace: "nowrap"
1696
+ },
1697
+ imagePreview: {
1698
+ maxWidth: "240px",
1699
+ maxHeight: "180px",
1700
+ borderRadius: "8px",
1701
+ objectFit: "cover",
1702
+ cursor: "pointer"
1703
+ },
1704
+ fileLink: {
1705
+ display: "flex",
1706
+ alignItems: "center",
1707
+ gap: "6px",
1708
+ textDecoration: "none",
1709
+ color: "inherit",
1710
+ padding: "6px 0"
1711
+ },
1712
+ fileIcon: {
1713
+ fontSize: "18px"
1714
+ },
1715
+ fileName: {
1716
+ fontSize: "13px",
1717
+ textDecoration: "underline"
1718
+ },
1719
+ deepLink: {
1720
+ color: "#3b82f6",
1721
+ textDecoration: "underline",
1722
+ cursor: "pointer"
1723
+ },
1724
+ deepLinkCard: {
1725
+ display: "flex",
1726
+ flexDirection: "column",
1727
+ gap: "2px",
1728
+ padding: "8px",
1729
+ backgroundColor: "#eff6ff",
1730
+ borderRadius: "8px",
1731
+ border: "1px solid #bfdbfe",
1732
+ cursor: "pointer"
1733
+ },
1734
+ deepLinkLabel: {
1735
+ fontSize: "13px",
1736
+ fontWeight: 500,
1737
+ color: "#1d4ed8"
1738
+ },
1739
+ deepLinkPath: {
1740
+ fontSize: "11px",
1741
+ color: "#6b7280",
1742
+ fontFamily: "monospace"
1743
+ },
1744
+ systemMessage: {
1745
+ display: "flex",
1746
+ justifyContent: "center",
1747
+ alignItems: "center",
1748
+ gap: "8px",
1749
+ padding: "4px 16px",
1750
+ fontSize: "12px",
1751
+ color: "#9ca3af"
1752
+ },
1753
+ systemTime: {
1754
+ fontSize: "10px",
1755
+ color: "#d1d5db"
1756
+ }
1757
+ };
1758
+ var MessageList = forwardRef(function MessageList2({
1759
+ messages,
1760
+ currentUserId,
1761
+ participants = [],
1762
+ showSeenBy = true,
1763
+ typingUsers,
1764
+ hasMore,
1765
+ isLoading,
1766
+ onLoadMore,
1767
+ onDeepLinkClick,
1768
+ onMessageVisible,
1769
+ onReply,
1770
+ onPin,
1771
+ onUnpin,
1772
+ onCopy,
1773
+ pinDisabled,
1774
+ className
1775
+ }, ref) {
1776
+ const containerRef = useRef(null);
1777
+ const bottomRef = useRef(null);
1778
+ const prevMessageCount = useRef(messages.length);
1779
+ useImperativeHandle(
1780
+ ref,
1781
+ () => ({
1782
+ scrollToMessage: (messageId) => {
1783
+ const element = containerRef.current?.querySelector(`[data-message-id="${messageId}"]`);
1784
+ if (element) {
1785
+ element.scrollIntoView({ behavior: "smooth", block: "center" });
1786
+ element.style.transition = "background-color 200ms";
1787
+ element.style.backgroundColor = "#fef3c7";
1788
+ setTimeout(() => {
1789
+ element.style.backgroundColor = "";
1790
+ }, 800);
1791
+ }
1792
+ }
1793
+ }),
1794
+ []
1795
+ );
1796
+ useEffect(() => {
1797
+ if (messages.length > prevMessageCount.current) {
1798
+ const lastMessage = messages[messages.length - 1];
1799
+ const isOwnMessage = lastMessage?.senderId === currentUserId;
1800
+ if (isOwnMessage || isNearBottom(containerRef.current)) {
1801
+ bottomRef.current?.scrollIntoView({ behavior: "smooth" });
1802
+ }
1803
+ }
1804
+ prevMessageCount.current = messages.length;
1805
+ }, [messages, currentUserId]);
1806
+ useEffect(() => {
1807
+ if (!onMessageVisible || !containerRef.current) return;
1808
+ const observer = new IntersectionObserver(
1809
+ (entries) => {
1810
+ entries.forEach((entry) => {
1811
+ if (entry.isIntersecting) {
1812
+ const messageId = entry.target.dataset.messageId;
1813
+ if (messageId) onMessageVisible(messageId);
1814
+ }
1815
+ });
1816
+ },
1817
+ { root: containerRef.current, threshold: 0.5 }
1818
+ );
1819
+ const messageElements = containerRef.current.querySelectorAll("[data-message-id]");
1820
+ messageElements.forEach((el) => observer.observe(el));
1821
+ return () => observer.disconnect();
1822
+ }, [messages, onMessageVisible]);
1823
+ const handleScroll = useCallback(() => {
1824
+ if (!containerRef.current || !hasMore || isLoading) return;
1825
+ if (containerRef.current.scrollTop < 50) {
1826
+ onLoadMore();
1827
+ }
1828
+ }, [hasMore, isLoading, onLoadMore]);
1829
+ return /* @__PURE__ */ jsxs(
1830
+ "div",
1831
+ {
1832
+ ref: containerRef,
1833
+ className,
1834
+ style: styles6.container,
1835
+ onScroll: handleScroll,
1836
+ children: [
1837
+ isLoading && /* @__PURE__ */ jsx("div", { style: styles6.loadingMore, children: "Loading earlier messages..." }),
1838
+ hasMore && !isLoading && messages.length > 0 && /* @__PURE__ */ jsx("button", { onClick: onLoadMore, style: styles6.loadMoreButton, type: "button", children: "Load earlier messages" }),
1839
+ messages.length === 0 && !isLoading && /* @__PURE__ */ jsxs("div", { style: styles6.empty, children: [
1840
+ /* @__PURE__ */ jsx("p", { style: styles6.emptyTitle, children: "No messages yet" }),
1841
+ /* @__PURE__ */ jsx("p", { style: styles6.emptySubtitle, children: "Start the conversation about this study" })
1842
+ ] }),
1843
+ messages.map((message) => /* @__PURE__ */ jsx("div", { "data-message-id": message.id, children: /* @__PURE__ */ jsx(
1844
+ MessageBubble,
1845
+ {
1846
+ message,
1847
+ isOwn: message.senderId === currentUserId,
1848
+ participants,
1849
+ currentUserId,
1850
+ showSeenBy,
1851
+ onDeepLinkClick,
1852
+ onReply,
1853
+ onPin,
1854
+ onUnpin,
1855
+ onCopy,
1856
+ pinDisabled
1857
+ }
1858
+ ) }, message.id)),
1859
+ typingUsers.length > 0 && /* @__PURE__ */ jsxs("div", { style: styles6.typingIndicator, children: [
1860
+ typingUsers.map((t) => t.userName ?? "Someone").join(", "),
1861
+ typingUsers.length === 1 ? " is " : " are ",
1862
+ "typing..."
1863
+ ] }),
1864
+ /* @__PURE__ */ jsx("div", { ref: bottomRef })
1865
+ ]
1866
+ }
1867
+ );
1868
+ });
1869
+ function isNearBottom(container) {
1870
+ if (!container) return true;
1871
+ const threshold = 150;
1872
+ return container.scrollHeight - container.scrollTop - container.clientHeight < threshold;
1873
+ }
1874
+ var styles6 = {
1875
+ container: {
1876
+ flex: 1,
1877
+ overflowY: "auto",
1878
+ paddingTop: "12px",
1879
+ paddingBottom: "8px"
1880
+ },
1881
+ loadingMore: {
1882
+ textAlign: "center",
1883
+ padding: "12px",
1884
+ fontSize: "13px",
1885
+ color: "#9ca3af"
1886
+ },
1887
+ loadMoreButton: {
1888
+ display: "block",
1889
+ margin: "0 auto 12px",
1890
+ padding: "6px 16px",
1891
+ fontSize: "12px",
1892
+ color: "#6b7280",
1893
+ backgroundColor: "transparent",
1894
+ border: "1px solid #e5e7eb",
1895
+ borderRadius: "16px",
1896
+ cursor: "pointer"
1897
+ },
1898
+ empty: {
1899
+ display: "flex",
1900
+ flexDirection: "column",
1901
+ alignItems: "center",
1902
+ justifyContent: "center",
1903
+ padding: "48px 16px",
1904
+ color: "#9ca3af"
1905
+ },
1906
+ emptyTitle: {
1907
+ fontSize: "15px",
1908
+ fontWeight: 500,
1909
+ margin: "0 0 4px"
1910
+ },
1911
+ emptySubtitle: {
1912
+ fontSize: "13px",
1913
+ margin: 0
1914
+ },
1915
+ typingIndicator: {
1916
+ padding: "4px 16px 8px",
1917
+ fontSize: "12px",
1918
+ color: "#9ca3af",
1919
+ fontStyle: "italic"
1920
+ }
1921
+ };
1922
+ function useAudioRecorder() {
1923
+ const [isRecording, setIsRecording] = useState(false);
1924
+ const [duration, setDuration] = useState(0);
1925
+ const [error, setError] = useState(null);
1926
+ const mediaRecorderRef = useRef(null);
1927
+ const chunksRef = useRef([]);
1928
+ const streamRef = useRef(null);
1929
+ const timerRef = useRef(null);
1930
+ const startTimeRef = useRef(0);
1931
+ const resolveRef = useRef(null);
1932
+ const isSupported = typeof window !== "undefined" && typeof navigator !== "undefined" && !!navigator.mediaDevices?.getUserMedia && !!window.MediaRecorder;
1933
+ const cleanup = useCallback(() => {
1934
+ if (timerRef.current) {
1935
+ clearInterval(timerRef.current);
1936
+ timerRef.current = null;
1937
+ }
1938
+ if (streamRef.current) {
1939
+ streamRef.current.getTracks().forEach((track) => track.stop());
1940
+ streamRef.current = null;
1941
+ }
1942
+ mediaRecorderRef.current = null;
1943
+ chunksRef.current = [];
1944
+ setIsRecording(false);
1945
+ setDuration(0);
1946
+ }, []);
1947
+ const start = useCallback(async () => {
1948
+ if (!isSupported) {
1949
+ setError("Audio recording is not supported in this browser");
1950
+ return;
1951
+ }
1952
+ try {
1953
+ setError(null);
1954
+ chunksRef.current = [];
1955
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
1956
+ streamRef.current = stream;
1957
+ const mimeType = MediaRecorder.isTypeSupported(AUDIO_MIME_TYPE) ? AUDIO_MIME_TYPE : MediaRecorder.isTypeSupported("audio/webm") ? "audio/webm" : "audio/mp4";
1958
+ const recorder = new MediaRecorder(stream, { mimeType });
1959
+ mediaRecorderRef.current = recorder;
1960
+ recorder.ondataavailable = (event) => {
1961
+ if (event.data.size > 0) {
1962
+ chunksRef.current.push(event.data);
1963
+ }
1964
+ };
1965
+ recorder.onerror = () => {
1966
+ setError("Recording failed");
1967
+ cleanup();
1968
+ resolveRef.current?.(null);
1969
+ resolveRef.current = null;
1970
+ };
1971
+ recorder.onstop = () => {
1972
+ const finalDuration = Math.round((Date.now() - startTimeRef.current) / 1e3);
1973
+ const blob = new Blob(chunksRef.current, { type: mimeType });
1974
+ cleanup();
1975
+ resolveRef.current?.({ blob, duration: finalDuration });
1976
+ resolveRef.current = null;
1977
+ };
1978
+ recorder.start(250);
1979
+ startTimeRef.current = Date.now();
1980
+ setIsRecording(true);
1981
+ timerRef.current = setInterval(() => {
1982
+ const elapsed = Math.round((Date.now() - startTimeRef.current) / 1e3);
1983
+ setDuration(elapsed);
1984
+ }, 1e3);
1985
+ } catch (err) {
1986
+ if (err instanceof DOMException && err.name === "NotAllowedError") {
1987
+ setError("Microphone access denied. Please allow microphone permissions.");
1988
+ } else {
1989
+ setError("Failed to start recording");
1990
+ }
1991
+ cleanup();
1992
+ }
1993
+ }, [isSupported, cleanup]);
1994
+ const stop = useCallback(async () => {
1995
+ return new Promise((resolve) => {
1996
+ if (!mediaRecorderRef.current || mediaRecorderRef.current.state === "inactive") {
1997
+ resolve(null);
1998
+ return;
1999
+ }
2000
+ resolveRef.current = resolve;
2001
+ mediaRecorderRef.current.stop();
2002
+ });
2003
+ }, []);
2004
+ const cancel = useCallback(() => {
2005
+ if (mediaRecorderRef.current && mediaRecorderRef.current.state !== "inactive") {
2006
+ mediaRecorderRef.current.onstop = null;
2007
+ mediaRecorderRef.current.stop();
2008
+ }
2009
+ cleanup();
2010
+ resolveRef.current?.(null);
2011
+ resolveRef.current = null;
2012
+ }, [cleanup]);
2013
+ return {
2014
+ isRecording,
2015
+ duration,
2016
+ start,
2017
+ stop,
2018
+ cancel,
2019
+ isSupported,
2020
+ error
2021
+ };
2022
+ }
2023
+ function ReplyPreview({ message, onCancel, className }) {
2024
+ const preview = renderQuotedPreview(message);
2025
+ return /* @__PURE__ */ jsxs("div", { className, style: styles7.container, children: [
2026
+ /* @__PURE__ */ jsx("div", { style: styles7.bar }),
2027
+ /* @__PURE__ */ jsxs("div", { style: styles7.content, children: [
2028
+ /* @__PURE__ */ jsxs("div", { style: styles7.senderLine, children: [
2029
+ /* @__PURE__ */ jsx("span", { style: styles7.senderIcon, children: "\u21A9" }),
2030
+ /* @__PURE__ */ jsxs("span", { style: styles7.senderName, children: [
2031
+ "Replying to ",
2032
+ message.senderName
2033
+ ] })
2034
+ ] }),
2035
+ /* @__PURE__ */ jsx("div", { style: styles7.preview, children: preview })
2036
+ ] }),
2037
+ /* @__PURE__ */ jsx("button", { onClick: onCancel, style: styles7.cancelButton, title: "Cancel reply", type: "button", children: "\u2715" })
2038
+ ] });
2039
+ }
2040
+ function renderQuotedPreview(message) {
2041
+ switch (message.type) {
2042
+ case "audio":
2043
+ return "\u{1F3A4} Voice message";
2044
+ case "image":
2045
+ return `\u{1F4F7} ${message.fileName || "Image"}`;
2046
+ case "file":
2047
+ return `\u{1F4CE} ${message.fileName || "File"}`;
2048
+ case "deep_link":
2049
+ return `\u{1F517} ${message.body}`;
2050
+ default: {
2051
+ const body = message.body || "";
2052
+ return body.length > 120 ? `${body.slice(0, 120)}\u2026` : body;
2053
+ }
2054
+ }
2055
+ }
2056
+ var styles7 = {
2057
+ container: {
2058
+ display: "flex",
2059
+ alignItems: "stretch",
2060
+ gap: "8px",
2061
+ padding: "8px 12px",
2062
+ backgroundColor: "#f9fafb",
2063
+ borderTop: "1px solid #e5e7eb"
2064
+ },
2065
+ bar: {
2066
+ width: "3px",
2067
+ backgroundColor: "#2563eb",
2068
+ borderRadius: "2px",
2069
+ flexShrink: 0
2070
+ },
2071
+ content: {
2072
+ flex: 1,
2073
+ display: "flex",
2074
+ flexDirection: "column",
2075
+ gap: "2px",
2076
+ minWidth: 0
2077
+ },
2078
+ senderLine: {
2079
+ display: "flex",
2080
+ alignItems: "center",
2081
+ gap: "4px"
2082
+ },
2083
+ senderIcon: {
2084
+ fontSize: "11px",
2085
+ color: "#2563eb"
2086
+ },
2087
+ senderName: {
2088
+ fontSize: "12px",
2089
+ fontWeight: 600,
2090
+ color: "#2563eb"
2091
+ },
2092
+ preview: {
2093
+ fontSize: "12px",
2094
+ color: "#6b7280",
2095
+ overflow: "hidden",
2096
+ textOverflow: "ellipsis",
2097
+ whiteSpace: "nowrap"
2098
+ },
2099
+ cancelButton: {
2100
+ alignSelf: "center",
2101
+ width: "24px",
2102
+ height: "24px",
2103
+ display: "flex",
2104
+ alignItems: "center",
2105
+ justifyContent: "center",
2106
+ color: "#6b7280",
2107
+ backgroundColor: "transparent",
2108
+ border: "none",
2109
+ borderRadius: "4px",
2110
+ cursor: "pointer",
2111
+ fontSize: "12px",
2112
+ flexShrink: 0
2113
+ }
2114
+ };
2115
+ function MicIcon({ size = 20, color = "currentColor" }) {
2116
+ return /* @__PURE__ */ jsx(
2117
+ "svg",
2118
+ {
2119
+ xmlns: "http://www.w3.org/2000/svg",
2120
+ width: size,
2121
+ height: size,
2122
+ viewBox: "0 0 24 24",
2123
+ fill: color,
2124
+ "aria-hidden": "true",
2125
+ children: /* @__PURE__ */ jsx("path", { d: "M12 14c1.66 0 2.99-1.34 2.99-3L15 5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm5.3-3c0 3-2.54 5.1-5.3 5.1S6.7 14 6.7 11H5c0 3.41 2.72 6.23 6 6.72V21h2v-3.28c3.28-.48 6-3.3 6-6.72h-1.7z" })
2126
+ }
2127
+ );
2128
+ }
2129
+ function MessageInput({
2130
+ onSendText,
2131
+ onSendAudio,
2132
+ onSendFile,
2133
+ onTyping,
2134
+ replyTo,
2135
+ onCancelReply,
2136
+ disabled = false,
2137
+ placeholder = "Type a message...",
2138
+ className
2139
+ }) {
2140
+ const [text, setText] = useState("");
2141
+ const [isSending, setIsSending] = useState(false);
2142
+ const [fileError, setFileError] = useState(null);
2143
+ const fileInputRef = useRef(null);
2144
+ const typingTimeoutRef = useRef(null);
2145
+ const isTypingRef = useRef(false);
2146
+ const sendingRef = useRef(false);
2147
+ const { isRecording, duration, start: startRecording, stop: stopRecording, cancel: cancelRecording, isSupported: micSupported, error: micError } = useAudioRecorder();
2148
+ const handleTyping = useCallback(() => {
2149
+ if (!isTypingRef.current) {
2150
+ isTypingRef.current = true;
2151
+ onTyping(true);
2152
+ }
2153
+ if (typingTimeoutRef.current) {
2154
+ clearTimeout(typingTimeoutRef.current);
2155
+ }
2156
+ typingTimeoutRef.current = setTimeout(() => {
2157
+ isTypingRef.current = false;
2158
+ onTyping(false);
2159
+ }, 2e3);
2160
+ }, [onTyping]);
2161
+ useEffect(() => {
2162
+ return () => {
2163
+ if (typingTimeoutRef.current) clearTimeout(typingTimeoutRef.current);
2164
+ };
2165
+ }, []);
2166
+ const handleSendText = useCallback(async () => {
2167
+ const trimmed = text.trim();
2168
+ if (!trimmed || sendingRef.current) return;
2169
+ sendingRef.current = true;
2170
+ setIsSending(true);
2171
+ if (isTypingRef.current) {
2172
+ isTypingRef.current = false;
2173
+ onTyping(false);
2174
+ }
2175
+ if (typingTimeoutRef.current) {
2176
+ clearTimeout(typingTimeoutRef.current);
2177
+ typingTimeoutRef.current = null;
2178
+ }
2179
+ try {
2180
+ await onSendText(trimmed);
2181
+ setText("");
2182
+ } finally {
2183
+ sendingRef.current = false;
2184
+ setIsSending(false);
2185
+ }
2186
+ }, [text, onSendText, onTyping]);
2187
+ const handleKeyDown = useCallback(
2188
+ (e) => {
2189
+ if (e.key === "Enter" && !e.shiftKey) {
2190
+ e.preventDefault();
2191
+ handleSendText();
2192
+ }
2193
+ },
2194
+ [handleSendText]
2195
+ );
2196
+ const handleAudioStop = useCallback(async () => {
2197
+ const result = await stopRecording();
2198
+ if (result) {
2199
+ setIsSending(true);
2200
+ try {
2201
+ await onSendAudio(result.blob, result.duration);
2202
+ } finally {
2203
+ setIsSending(false);
2204
+ }
2205
+ }
2206
+ }, [stopRecording, onSendAudio]);
2207
+ const handleFileSelect = useCallback(
2208
+ async (e) => {
2209
+ const file = e.target.files?.[0];
2210
+ if (!file) return;
2211
+ setFileError(null);
2212
+ if (file.size > MAX_FILE_SIZE) {
2213
+ setFileError(`File too large. Maximum size is ${MAX_FILE_SIZE / (1024 * 1024)}MB.`);
2214
+ return;
2215
+ }
2216
+ setIsSending(true);
2217
+ try {
2218
+ await onSendFile(file);
2219
+ } finally {
2220
+ setIsSending(false);
2221
+ if (fileInputRef.current) fileInputRef.current.value = "";
2222
+ }
2223
+ },
2224
+ [onSendFile]
2225
+ );
2226
+ return /* @__PURE__ */ jsxs("div", { className, style: styles8.container, children: [
2227
+ replyTo && onCancelReply && /* @__PURE__ */ jsx(ReplyPreview, { message: replyTo, onCancel: onCancelReply }),
2228
+ (fileError || micError) && /* @__PURE__ */ jsx("div", { style: styles8.error, children: fileError || micError }),
2229
+ isRecording ? /* @__PURE__ */ jsxs("div", { style: styles8.recordingBar, children: [
2230
+ /* @__PURE__ */ jsxs("div", { style: styles8.recordingIndicator, children: [
2231
+ /* @__PURE__ */ jsx("span", { style: styles8.recordingDot }),
2232
+ "Recording ",
2233
+ formatDuration2(duration)
2234
+ ] }),
2235
+ /* @__PURE__ */ jsxs("div", { style: styles8.recordingActions, children: [
2236
+ /* @__PURE__ */ jsx("button", { onClick: cancelRecording, style: styles8.cancelButton, type: "button", children: "Cancel" }),
2237
+ /* @__PURE__ */ jsx("button", { onClick: handleAudioStop, style: styles8.stopButton, type: "button", children: "Send" })
2238
+ ] })
2239
+ ] }) : /* @__PURE__ */ jsxs("div", { style: styles8.inputBar, children: [
2240
+ micSupported && /* @__PURE__ */ jsx(
2241
+ "button",
2242
+ {
2243
+ onClick: startRecording,
2244
+ disabled: disabled || isSending,
2245
+ style: styles8.iconButton,
2246
+ title: "Record voice message",
2247
+ type: "button",
2248
+ children: /* @__PURE__ */ jsx(MicIcon, { size: 20, color: "#374151" })
2249
+ }
2250
+ ),
2251
+ /* @__PURE__ */ jsx(
2252
+ "button",
2253
+ {
2254
+ onClick: () => fileInputRef.current?.click(),
2255
+ disabled: disabled || isSending,
2256
+ style: styles8.iconButton,
2257
+ title: "Attach file",
2258
+ type: "button",
2259
+ children: "\u{1F4CE}"
2260
+ }
2261
+ ),
2262
+ /* @__PURE__ */ jsx(
2263
+ "input",
2264
+ {
2265
+ ref: fileInputRef,
2266
+ type: "file",
2267
+ onChange: handleFileSelect,
2268
+ style: { display: "none" },
2269
+ accept: [...SUPPORTED_IMAGE_TYPES, "application/pdf", ".doc", ".docx"].join(",")
2270
+ }
2271
+ ),
2272
+ /* @__PURE__ */ jsx(
2273
+ "textarea",
2274
+ {
2275
+ value: text,
2276
+ onChange: (e) => {
2277
+ setText(e.target.value);
2278
+ handleTyping();
2279
+ },
2280
+ onKeyDown: handleKeyDown,
2281
+ placeholder,
2282
+ disabled: disabled || isSending,
2283
+ rows: 1,
2284
+ style: styles8.textarea
2285
+ }
2286
+ ),
2287
+ /* @__PURE__ */ jsx(
2288
+ "button",
2289
+ {
2290
+ onClick: handleSendText,
2291
+ disabled: disabled || isSending || !text.trim(),
2292
+ style: {
2293
+ ...styles8.sendButton,
2294
+ opacity: text.trim() ? 1 : 0.4
2295
+ },
2296
+ type: "button",
2297
+ children: "\u27A4"
2298
+ }
2299
+ )
2300
+ ] })
2301
+ ] });
2302
+ }
2303
+ function formatDuration2(seconds) {
2304
+ const m = Math.floor(seconds / 60);
2305
+ const s = seconds % 60;
2306
+ return `${m}:${s.toString().padStart(2, "0")}`;
2307
+ }
2308
+ var styles8 = {
2309
+ container: {
2310
+ borderTop: "1px solid #e5e7eb",
2311
+ backgroundColor: "#ffffff"
2312
+ },
2313
+ error: {
2314
+ padding: "6px 16px",
2315
+ fontSize: "12px",
2316
+ color: "#dc2626",
2317
+ backgroundColor: "#fef2f2"
2318
+ },
2319
+ inputBar: {
2320
+ display: "flex",
2321
+ alignItems: "flex-end",
2322
+ gap: "4px",
2323
+ padding: "8px 12px"
2324
+ },
2325
+ iconButton: {
2326
+ width: "36px",
2327
+ height: "36px",
2328
+ display: "flex",
2329
+ alignItems: "center",
2330
+ justifyContent: "center",
2331
+ fontSize: "18px",
2332
+ backgroundColor: "transparent",
2333
+ border: "none",
2334
+ borderRadius: "50%",
2335
+ cursor: "pointer",
2336
+ flexShrink: 0
2337
+ },
2338
+ textarea: {
2339
+ flex: 1,
2340
+ padding: "8px 12px",
2341
+ fontSize: "14px",
2342
+ lineHeight: "1.4",
2343
+ border: "1px solid #e5e7eb",
2344
+ borderRadius: "20px",
2345
+ resize: "none",
2346
+ outline: "none",
2347
+ fontFamily: "inherit",
2348
+ maxHeight: "120px"
2349
+ },
2350
+ sendButton: {
2351
+ width: "36px",
2352
+ height: "36px",
2353
+ display: "flex",
2354
+ alignItems: "center",
2355
+ justifyContent: "center",
2356
+ fontSize: "18px",
2357
+ backgroundColor: "#2563eb",
2358
+ color: "#ffffff",
2359
+ border: "none",
2360
+ borderRadius: "50%",
2361
+ cursor: "pointer",
2362
+ flexShrink: 0
2363
+ },
2364
+ recordingBar: {
2365
+ display: "flex",
2366
+ justifyContent: "space-between",
2367
+ alignItems: "center",
2368
+ padding: "12px 16px",
2369
+ backgroundColor: "#fef2f2"
2370
+ },
2371
+ recordingIndicator: {
2372
+ display: "flex",
2373
+ alignItems: "center",
2374
+ gap: "8px",
2375
+ fontSize: "14px",
2376
+ color: "#dc2626",
2377
+ fontWeight: 500
2378
+ },
2379
+ recordingDot: {
2380
+ width: "8px",
2381
+ height: "8px",
2382
+ borderRadius: "50%",
2383
+ backgroundColor: "#dc2626",
2384
+ animation: "pulse 1.5s infinite"
2385
+ },
2386
+ recordingActions: {
2387
+ display: "flex",
2388
+ gap: "8px"
2389
+ },
2390
+ cancelButton: {
2391
+ padding: "6px 14px",
2392
+ fontSize: "13px",
2393
+ color: "#6b7280",
2394
+ backgroundColor: "#ffffff",
2395
+ border: "1px solid #d1d5db",
2396
+ borderRadius: "6px",
2397
+ cursor: "pointer"
2398
+ },
2399
+ stopButton: {
2400
+ padding: "6px 14px",
2401
+ fontSize: "13px",
2402
+ fontWeight: 500,
2403
+ color: "#ffffff",
2404
+ backgroundColor: "#dc2626",
2405
+ border: "none",
2406
+ borderRadius: "6px",
2407
+ cursor: "pointer"
2408
+ }
2409
+ };
2410
+ function ParticipantsList({
2411
+ participants,
2412
+ currentUserId,
2413
+ isAdmin,
2414
+ onRemoveUser,
2415
+ className
2416
+ }) {
2417
+ return /* @__PURE__ */ jsx("div", { className, style: styles9.list, children: participants.map((participant) => /* @__PURE__ */ jsxs("div", { style: styles9.item, children: [
2418
+ /* @__PURE__ */ jsxs("div", { style: styles9.avatar, children: [
2419
+ participant.avatar ? /* @__PURE__ */ jsx("img", { src: participant.avatar, alt: "", style: styles9.avatarImg }) : /* @__PURE__ */ jsx("span", { style: styles9.avatarFallback, children: (participant.userName ?? "?").charAt(0).toUpperCase() }),
2420
+ /* @__PURE__ */ jsx(
2421
+ "span",
2422
+ {
2423
+ style: {
2424
+ ...styles9.statusDot,
2425
+ backgroundColor: participant.isOnline ? "#22c55e" : "#d1d5db"
2426
+ }
2427
+ }
2428
+ )
2429
+ ] }),
2430
+ /* @__PURE__ */ jsxs("div", { style: styles9.info, children: [
2431
+ /* @__PURE__ */ jsxs("span", { style: styles9.name, children: [
2432
+ participant.userName ?? fallbackName(participant.userRole),
2433
+ participant.userId === currentUserId && /* @__PURE__ */ jsx("span", { style: styles9.youBadge, children: " (you)" })
2434
+ ] }),
2435
+ /* @__PURE__ */ jsx(RoleBadge2, { role: participant.userRole })
2436
+ ] }),
2437
+ isAdmin && participant.userId !== currentUserId && /* @__PURE__ */ jsx(
2438
+ "button",
2439
+ {
2440
+ onClick: () => onRemoveUser(participant.userId),
2441
+ style: styles9.removeButton,
2442
+ title: `Remove ${participant.userName ?? fallbackName(participant.userRole)}`,
2443
+ type: "button",
2444
+ children: "\u2715"
2445
+ }
2446
+ )
2447
+ ] }, participant.userId)) });
2448
+ }
2449
+ function fallbackName(role) {
2450
+ switch (role) {
2451
+ case "lab":
2452
+ return "Imaging Center";
2453
+ case "radiologist":
2454
+ return "Radiologist";
2455
+ case "physician":
2456
+ return "Physician";
2457
+ case "admin":
2458
+ return "Natoe Admin";
2459
+ default:
2460
+ return "User";
2461
+ }
2462
+ }
2463
+ function RoleBadge2({ role }) {
2464
+ const colors = {
2465
+ radiologist: { bg: "#f5f3ff", text: "#7c3aed" },
2466
+ lab: { bg: "#eff6ff", text: "#2563eb" },
2467
+ physician: { bg: "#ecfdf5", text: "#059669" },
2468
+ admin: { bg: "#fef2f2", text: "#dc2626" }
2469
+ };
2470
+ const color = colors[role];
2471
+ return /* @__PURE__ */ jsx("span", { style: { ...styles9.roleBadge, backgroundColor: color.bg, color: color.text }, children: role });
2472
+ }
2473
+ var styles9 = {
2474
+ list: {
2475
+ display: "flex",
2476
+ flexDirection: "column"
2477
+ },
2478
+ item: {
2479
+ display: "flex",
2480
+ alignItems: "center",
2481
+ gap: "10px",
2482
+ padding: "8px 0",
2483
+ borderBottom: "1px solid #f9fafb"
2484
+ },
2485
+ avatar: {
2486
+ position: "relative",
2487
+ width: "32px",
2488
+ height: "32px",
2489
+ flexShrink: 0
2490
+ },
2491
+ avatarImg: {
2492
+ width: "32px",
2493
+ height: "32px",
2494
+ borderRadius: "50%",
2495
+ objectFit: "cover"
2496
+ },
2497
+ avatarFallback: {
2498
+ width: "32px",
2499
+ height: "32px",
2500
+ borderRadius: "50%",
2501
+ backgroundColor: "#e5e7eb",
2502
+ display: "flex",
2503
+ alignItems: "center",
2504
+ justifyContent: "center",
2505
+ fontSize: "13px",
2506
+ fontWeight: 600,
2507
+ color: "#6b7280"
2508
+ },
2509
+ statusDot: {
2510
+ position: "absolute",
2511
+ bottom: "0",
2512
+ right: "0",
2513
+ width: "8px",
2514
+ height: "8px",
2515
+ borderRadius: "50%",
2516
+ border: "2px solid #ffffff"
2517
+ },
2518
+ info: {
2519
+ display: "flex",
2520
+ alignItems: "center",
2521
+ gap: "6px",
2522
+ flex: 1,
2523
+ minWidth: 0
2524
+ },
2525
+ name: {
2526
+ fontSize: "13px",
2527
+ fontWeight: 500,
2528
+ color: "#111827",
2529
+ overflow: "hidden",
2530
+ textOverflow: "ellipsis",
2531
+ whiteSpace: "nowrap"
2532
+ },
2533
+ youBadge: {
2534
+ fontSize: "11px",
2535
+ fontWeight: 400,
2536
+ color: "#9ca3af"
2537
+ },
2538
+ roleBadge: {
2539
+ fontSize: "10px",
2540
+ fontWeight: 500,
2541
+ padding: "1px 6px",
2542
+ borderRadius: "4px",
2543
+ whiteSpace: "nowrap"
2544
+ },
2545
+ removeButton: {
2546
+ width: "24px",
2547
+ height: "24px",
2548
+ display: "flex",
2549
+ alignItems: "center",
2550
+ justifyContent: "center",
2551
+ backgroundColor: "transparent",
2552
+ border: "none",
2553
+ borderRadius: "4px",
2554
+ cursor: "pointer",
2555
+ fontSize: "11px",
2556
+ color: "#9ca3af",
2557
+ flexShrink: 0
2558
+ }
2559
+ };
2560
+ function ChannelSettings({
2561
+ channelName,
2562
+ participants,
2563
+ currentUserId,
2564
+ isAdmin,
2565
+ isUpdating,
2566
+ error,
2567
+ onUpdateChannel,
2568
+ onInviteUser,
2569
+ onRemoveUser,
2570
+ onLeaveChannel,
2571
+ onDeleteChannel,
2572
+ onClose,
2573
+ className
2574
+ }) {
2575
+ const [editingName, setEditingName] = useState(false);
2576
+ const [nameValue, setNameValue] = useState(channelName);
2577
+ const [showInvite, setShowInvite] = useState(false);
2578
+ const [inviteId, setInviteId] = useState("");
2579
+ const [inviteName, setInviteName] = useState("");
2580
+ const [inviteRole, setInviteRole] = useState("radiologist");
2581
+ const [confirmDelete, setConfirmDelete] = useState(false);
2582
+ const handleSaveName = async () => {
2583
+ if (nameValue.trim() && nameValue !== channelName) {
2584
+ await onUpdateChannel({ name: nameValue.trim() });
2585
+ }
2586
+ setEditingName(false);
2587
+ };
2588
+ const handleInvite = async () => {
2589
+ if (!inviteId.trim() || !inviteName.trim()) return;
2590
+ await onInviteUser({
2591
+ userId: inviteId.trim(),
2592
+ userName: inviteName.trim(),
2593
+ userRole: inviteRole
2594
+ });
2595
+ setInviteId("");
2596
+ setInviteName("");
2597
+ setShowInvite(false);
2598
+ };
2599
+ const handleDelete = async () => {
2600
+ if (!confirmDelete) {
2601
+ setConfirmDelete(true);
2602
+ return;
2603
+ }
2604
+ await onDeleteChannel();
2605
+ };
2606
+ return /* @__PURE__ */ jsxs("div", { className, style: styles10.container, children: [
2607
+ /* @__PURE__ */ jsxs("div", { style: styles10.header, children: [
2608
+ /* @__PURE__ */ jsx("h3", { style: styles10.title, children: "Channel Settings" }),
2609
+ /* @__PURE__ */ jsx("button", { onClick: onClose, style: styles10.closeButton, type: "button", children: "\u2715" })
2610
+ ] }),
2611
+ error && /* @__PURE__ */ jsx("div", { style: styles10.error, children: error.message }),
2612
+ /* @__PURE__ */ jsxs("div", { style: styles10.section, children: [
2613
+ /* @__PURE__ */ jsx("label", { style: styles10.label, children: "Channel Name" }),
2614
+ editingName ? /* @__PURE__ */ jsxs("div", { style: styles10.editRow, children: [
2615
+ /* @__PURE__ */ jsx(
2616
+ "input",
2617
+ {
2618
+ value: nameValue,
2619
+ onChange: (e) => setNameValue(e.target.value),
2620
+ style: styles10.input,
2621
+ autoFocus: true
2622
+ }
2623
+ ),
2624
+ /* @__PURE__ */ jsx("button", { onClick: handleSaveName, disabled: isUpdating, style: styles10.saveButton, type: "button", children: "Save" }),
2625
+ /* @__PURE__ */ jsx("button", { onClick: () => setEditingName(false), style: styles10.cancelButton, type: "button", children: "Cancel" })
2626
+ ] }) : /* @__PURE__ */ jsxs("div", { style: styles10.editRow, children: [
2627
+ /* @__PURE__ */ jsx("span", { style: styles10.channelName, children: channelName }),
2628
+ /* @__PURE__ */ jsx("button", { onClick: () => setEditingName(true), style: styles10.editButton, type: "button", children: "Edit" })
2629
+ ] })
2630
+ ] }),
2631
+ /* @__PURE__ */ jsxs("div", { style: styles10.section, children: [
2632
+ /* @__PURE__ */ jsxs("div", { style: styles10.sectionHeader, children: [
2633
+ /* @__PURE__ */ jsxs("label", { style: styles10.label, children: [
2634
+ "Participants (",
2635
+ participants.length,
2636
+ ")"
2637
+ ] }),
2638
+ /* @__PURE__ */ jsx("button", { onClick: () => setShowInvite(!showInvite), style: styles10.addButton, type: "button", children: "+ Add" })
2639
+ ] }),
2640
+ showInvite && /* @__PURE__ */ jsxs("div", { style: styles10.inviteForm, children: [
2641
+ /* @__PURE__ */ jsx(
2642
+ "input",
2643
+ {
2644
+ value: inviteId,
2645
+ onChange: (e) => setInviteId(e.target.value),
2646
+ placeholder: "User ID",
2647
+ style: styles10.input
2648
+ }
2649
+ ),
2650
+ /* @__PURE__ */ jsx(
2651
+ "input",
2652
+ {
2653
+ value: inviteName,
2654
+ onChange: (e) => setInviteName(e.target.value),
2655
+ placeholder: "User Name",
2656
+ style: styles10.input
2657
+ }
2658
+ ),
2659
+ /* @__PURE__ */ jsxs(
2660
+ "select",
2661
+ {
2662
+ value: inviteRole,
2663
+ onChange: (e) => setInviteRole(e.target.value),
2664
+ style: styles10.input,
2665
+ children: [
2666
+ /* @__PURE__ */ jsx("option", { value: "radiologist", children: "Radiologist" }),
2667
+ /* @__PURE__ */ jsx("option", { value: "lab", children: "Lab" }),
2668
+ /* @__PURE__ */ jsx("option", { value: "physician", children: "Physician" }),
2669
+ /* @__PURE__ */ jsx("option", { value: "admin", children: "Admin" })
2670
+ ]
2671
+ }
2672
+ ),
2673
+ /* @__PURE__ */ jsxs("div", { style: styles10.inviteActions, children: [
2674
+ /* @__PURE__ */ jsx("button", { onClick: handleInvite, disabled: isUpdating, style: styles10.saveButton, type: "button", children: "Invite" }),
2675
+ /* @__PURE__ */ jsx("button", { onClick: () => setShowInvite(false), style: styles10.cancelButton, type: "button", children: "Cancel" })
2676
+ ] })
2677
+ ] }),
2678
+ /* @__PURE__ */ jsx(
2679
+ ParticipantsList,
2680
+ {
2681
+ participants,
2682
+ currentUserId,
2683
+ isAdmin,
2684
+ onRemoveUser
2685
+ }
2686
+ )
2687
+ ] }),
2688
+ /* @__PURE__ */ jsxs("div", { style: styles10.dangerZone, children: [
2689
+ /* @__PURE__ */ jsx("button", { onClick: onLeaveChannel, disabled: isUpdating, style: styles10.leaveButton, type: "button", children: "Leave Channel" }),
2690
+ isAdmin && /* @__PURE__ */ jsx(
2691
+ "button",
2692
+ {
2693
+ onClick: handleDelete,
2694
+ disabled: isUpdating,
2695
+ style: confirmDelete ? styles10.confirmDeleteButton : styles10.deleteButton,
2696
+ type: "button",
2697
+ children: confirmDelete ? "Confirm Delete" : "Delete Channel"
2698
+ }
2699
+ )
2700
+ ] })
2701
+ ] });
2702
+ }
2703
+ var styles10 = {
2704
+ container: {
2705
+ display: "flex",
2706
+ flexDirection: "column",
2707
+ height: "100%",
2708
+ backgroundColor: "#ffffff"
2709
+ },
2710
+ header: {
2711
+ display: "flex",
2712
+ justifyContent: "space-between",
2713
+ alignItems: "center",
2714
+ padding: "12px 16px",
2715
+ borderBottom: "1px solid #e5e7eb"
2716
+ },
2717
+ title: {
2718
+ margin: 0,
2719
+ fontSize: "16px",
2720
+ fontWeight: 600
2721
+ },
2722
+ closeButton: {
2723
+ width: "28px",
2724
+ height: "28px",
2725
+ display: "flex",
2726
+ alignItems: "center",
2727
+ justifyContent: "center",
2728
+ backgroundColor: "transparent",
2729
+ border: "none",
2730
+ borderRadius: "4px",
2731
+ cursor: "pointer",
2732
+ fontSize: "14px",
2733
+ color: "#6b7280"
2734
+ },
2735
+ error: {
2736
+ padding: "8px 16px",
2737
+ fontSize: "13px",
2738
+ color: "#dc2626",
2739
+ backgroundColor: "#fef2f2"
2740
+ },
2741
+ section: {
2742
+ padding: "16px",
2743
+ borderBottom: "1px solid #f3f4f6"
2744
+ },
2745
+ sectionHeader: {
2746
+ display: "flex",
2747
+ justifyContent: "space-between",
2748
+ alignItems: "center",
2749
+ marginBottom: "8px"
2750
+ },
2751
+ label: {
2752
+ fontSize: "12px",
2753
+ fontWeight: 600,
2754
+ color: "#6b7280",
2755
+ textTransform: "uppercase",
2756
+ letterSpacing: "0.05em"
2757
+ },
2758
+ channelName: {
2759
+ fontSize: "14px",
2760
+ color: "#111827"
2761
+ },
2762
+ editRow: {
2763
+ display: "flex",
2764
+ alignItems: "center",
2765
+ gap: "8px",
2766
+ marginTop: "6px"
2767
+ },
2768
+ input: {
2769
+ padding: "6px 10px",
2770
+ fontSize: "13px",
2771
+ border: "1px solid #d1d5db",
2772
+ borderRadius: "6px",
2773
+ outline: "none",
2774
+ width: "100%"
2775
+ },
2776
+ editButton: {
2777
+ padding: "4px 10px",
2778
+ fontSize: "12px",
2779
+ color: "#2563eb",
2780
+ backgroundColor: "transparent",
2781
+ border: "none",
2782
+ cursor: "pointer"
2783
+ },
2784
+ saveButton: {
2785
+ padding: "6px 12px",
2786
+ fontSize: "12px",
2787
+ fontWeight: 500,
2788
+ color: "#ffffff",
2789
+ backgroundColor: "#2563eb",
2790
+ border: "none",
2791
+ borderRadius: "6px",
2792
+ cursor: "pointer",
2793
+ whiteSpace: "nowrap"
2794
+ },
2795
+ cancelButton: {
2796
+ padding: "6px 12px",
2797
+ fontSize: "12px",
2798
+ color: "#6b7280",
2799
+ backgroundColor: "transparent",
2800
+ border: "1px solid #d1d5db",
2801
+ borderRadius: "6px",
2802
+ cursor: "pointer",
2803
+ whiteSpace: "nowrap"
2804
+ },
2805
+ addButton: {
2806
+ padding: "4px 10px",
2807
+ fontSize: "12px",
2808
+ fontWeight: 500,
2809
+ color: "#2563eb",
2810
+ backgroundColor: "#eff6ff",
2811
+ border: "none",
2812
+ borderRadius: "4px",
2813
+ cursor: "pointer"
2814
+ },
2815
+ inviteForm: {
2816
+ display: "flex",
2817
+ flexDirection: "column",
2818
+ gap: "8px",
2819
+ padding: "12px",
2820
+ marginBottom: "12px",
2821
+ backgroundColor: "#f9fafb",
2822
+ borderRadius: "8px"
2823
+ },
2824
+ inviteActions: {
2825
+ display: "flex",
2826
+ gap: "8px"
2827
+ },
2828
+ dangerZone: {
2829
+ padding: "16px",
2830
+ marginTop: "auto",
2831
+ display: "flex",
2832
+ flexDirection: "column",
2833
+ gap: "8px"
2834
+ },
2835
+ leaveButton: {
2836
+ padding: "8px",
2837
+ fontSize: "13px",
2838
+ fontWeight: 500,
2839
+ color: "#b45309",
2840
+ backgroundColor: "#fffbeb",
2841
+ border: "1px solid #fde68a",
2842
+ borderRadius: "6px",
2843
+ cursor: "pointer"
2844
+ },
2845
+ deleteButton: {
2846
+ padding: "8px",
2847
+ fontSize: "13px",
2848
+ fontWeight: 500,
2849
+ color: "#dc2626",
2850
+ backgroundColor: "#fef2f2",
2851
+ border: "1px solid #fecaca",
2852
+ borderRadius: "6px",
2853
+ cursor: "pointer"
2854
+ },
2855
+ confirmDeleteButton: {
2856
+ padding: "8px",
2857
+ fontSize: "13px",
2858
+ fontWeight: 600,
2859
+ color: "#ffffff",
2860
+ backgroundColor: "#dc2626",
2861
+ border: "none",
2862
+ borderRadius: "6px",
2863
+ cursor: "pointer"
2864
+ }
2865
+ };
2866
+ function PinnedMessagesBar({
2867
+ pinnedMessages,
2868
+ onJumpTo,
2869
+ onUnpin,
2870
+ className
2871
+ }) {
2872
+ const [index, setIndex] = useState(0);
2873
+ if (pinnedMessages.length === 0) return null;
2874
+ const safeIndex = Math.min(index, pinnedMessages.length - 1);
2875
+ const current = pinnedMessages[safeIndex];
2876
+ const preview = renderPinPreview(current);
2877
+ const handleClick = () => {
2878
+ onJumpTo(current.id);
2879
+ if (pinnedMessages.length > 1) {
2880
+ setIndex((prev) => (prev + 1) % pinnedMessages.length);
2881
+ }
2882
+ };
2883
+ return /* @__PURE__ */ jsxs("div", { className, style: styles11.container, children: [
2884
+ /* @__PURE__ */ jsxs("div", { style: styles11.indicator, children: [
2885
+ /* @__PURE__ */ jsx("span", { style: styles11.pinIcon, children: "\u{1F4CC}" }),
2886
+ pinnedMessages.length > 1 && /* @__PURE__ */ jsx("div", { style: styles11.segments, children: pinnedMessages.map((_, i) => /* @__PURE__ */ jsx(
2887
+ "span",
2888
+ {
2889
+ style: {
2890
+ ...styles11.segment,
2891
+ backgroundColor: i === safeIndex ? "#2563eb" : "#d1d5db"
2892
+ }
2893
+ },
2894
+ i
2895
+ )) })
2896
+ ] }),
2897
+ /* @__PURE__ */ jsxs(
2898
+ "button",
2899
+ {
2900
+ onClick: handleClick,
2901
+ style: styles11.contentButton,
2902
+ type: "button",
2903
+ title: "Click to jump to message",
2904
+ children: [
2905
+ /* @__PURE__ */ jsxs("div", { style: styles11.label, children: [
2906
+ "Pinned ",
2907
+ pinnedMessages.length > 1 ? `(${safeIndex + 1}/${pinnedMessages.length})` : ""
2908
+ ] }),
2909
+ /* @__PURE__ */ jsxs("div", { style: styles11.preview, children: [
2910
+ /* @__PURE__ */ jsxs("span", { style: styles11.sender, children: [
2911
+ current.senderName,
2912
+ ":"
2913
+ ] }),
2914
+ " ",
2915
+ preview
2916
+ ] })
2917
+ ]
2918
+ }
2919
+ ),
2920
+ /* @__PURE__ */ jsx(
2921
+ "button",
2922
+ {
2923
+ onClick: (e) => {
2924
+ e.stopPropagation();
2925
+ onUnpin(current.id);
2926
+ },
2927
+ style: styles11.unpinButton,
2928
+ title: "Unpin this message",
2929
+ type: "button",
2930
+ children: "\u2715"
2931
+ }
2932
+ )
2933
+ ] });
2934
+ }
2935
+ function renderPinPreview(message) {
2936
+ switch (message.type) {
2937
+ case "audio":
2938
+ return "\u{1F3A4} Voice message";
2939
+ case "image":
2940
+ return `\u{1F4F7} ${message.fileName || "Image"}`;
2941
+ case "file":
2942
+ return `\u{1F4CE} ${message.fileName || "File"}`;
2943
+ case "deep_link":
2944
+ return `\u{1F517} ${message.body}`;
2945
+ default: {
2946
+ const body = message.body || "";
2947
+ return body.length > 100 ? `${body.slice(0, 100)}\u2026` : body;
2948
+ }
2949
+ }
2950
+ }
2951
+ var styles11 = {
2952
+ container: {
2953
+ display: "flex",
2954
+ alignItems: "center",
2955
+ gap: "8px",
2956
+ padding: "6px 12px",
2957
+ backgroundColor: "#eff6ff",
2958
+ borderBottom: "1px solid #dbeafe"
2959
+ },
2960
+ indicator: {
2961
+ display: "flex",
2962
+ alignItems: "center",
2963
+ gap: "4px",
2964
+ flexShrink: 0
2965
+ },
2966
+ pinIcon: {
2967
+ fontSize: "13px"
2968
+ },
2969
+ segments: {
2970
+ display: "flex",
2971
+ flexDirection: "column",
2972
+ gap: "2px"
2973
+ },
2974
+ segment: {
2975
+ width: "3px",
2976
+ height: "6px",
2977
+ borderRadius: "1px"
2978
+ },
2979
+ contentButton: {
2980
+ flex: 1,
2981
+ display: "flex",
2982
+ flexDirection: "column",
2983
+ gap: "2px",
2984
+ padding: "2px 0",
2985
+ backgroundColor: "transparent",
2986
+ border: "none",
2987
+ textAlign: "left",
2988
+ cursor: "pointer",
2989
+ minWidth: 0,
2990
+ overflow: "hidden"
2991
+ },
2992
+ label: {
2993
+ fontSize: "11px",
2994
+ fontWeight: 600,
2995
+ color: "#2563eb"
2996
+ },
2997
+ preview: {
2998
+ fontSize: "12px",
2999
+ color: "#374151",
3000
+ overflow: "hidden",
3001
+ textOverflow: "ellipsis",
3002
+ whiteSpace: "nowrap"
3003
+ },
3004
+ sender: {
3005
+ fontWeight: 600
3006
+ },
3007
+ unpinButton: {
3008
+ width: "24px",
3009
+ height: "24px",
3010
+ display: "flex",
3011
+ alignItems: "center",
3012
+ justifyContent: "center",
3013
+ color: "#6b7280",
3014
+ backgroundColor: "transparent",
3015
+ border: "none",
3016
+ borderRadius: "4px",
3017
+ cursor: "pointer",
3018
+ fontSize: "11px",
3019
+ flexShrink: 0
3020
+ }
3021
+ };
3022
+ function CollabPanel({
3023
+ orderId,
3024
+ patientData,
3025
+ participantIds,
3026
+ showSeenBy = true,
3027
+ className,
3028
+ style
3029
+ }) {
3030
+ const { config } = useCollab();
3031
+ const [showSettings, setShowSettings] = useState(false);
3032
+ const messageListRef = useRef(null);
3033
+ const {
3034
+ conversation,
3035
+ messages,
3036
+ pinnedMessages,
3037
+ participants,
3038
+ typingUsers,
3039
+ isLoading,
3040
+ hasMore,
3041
+ error,
3042
+ replyTo,
3043
+ setReplyTo,
3044
+ sendMessage,
3045
+ sendAudioMessage,
3046
+ sendFileMessage,
3047
+ sendTyping,
3048
+ markAsRead,
3049
+ loadMoreMessages,
3050
+ pinMessage,
3051
+ unpinMessage
3052
+ } = useConversation({ orderId, patientData, participantIds });
3053
+ const channelSettings = useChannelSettings({
3054
+ conversationId: conversation?.id ?? null
3055
+ });
3056
+ const { handleDeepLink } = useDeepLinks();
3057
+ const handleOpenDicom = () => {
3058
+ if (patientData.studyId && patientData.storageId && config.onOpenDicom) {
3059
+ config.onOpenDicom(patientData.studyId, patientData.storageId);
3060
+ }
3061
+ };
3062
+ const handleJumpToMessage = (messageId) => {
3063
+ messageListRef.current?.scrollToMessage(messageId);
3064
+ };
3065
+ const handleCopy = async (text) => {
3066
+ try {
3067
+ if (navigator.clipboard) {
3068
+ await navigator.clipboard.writeText(text);
3069
+ }
3070
+ } catch {
3071
+ }
3072
+ };
3073
+ const pinDisabled = pinnedMessages.length >= MAX_PINNED_MESSAGES;
3074
+ if (error) {
3075
+ return /* @__PURE__ */ jsx("div", { className, style: { ...panelStyles.container, ...style }, children: /* @__PURE__ */ jsxs("div", { style: panelStyles.errorState, children: [
3076
+ /* @__PURE__ */ jsx("p", { style: panelStyles.errorTitle, children: "Unable to load conversation" }),
3077
+ /* @__PURE__ */ jsx("p", { style: panelStyles.errorMessage, children: error })
3078
+ ] }) });
3079
+ }
3080
+ if (isLoading && !conversation) {
3081
+ return /* @__PURE__ */ jsx("div", { className, style: { ...panelStyles.container, ...style }, children: /* @__PURE__ */ jsx("div", { style: panelStyles.loadingState, children: "Loading conversation..." }) });
3082
+ }
3083
+ return /* @__PURE__ */ jsx("div", { className, style: { ...panelStyles.container, ...style }, children: showSettings && conversation ? /* @__PURE__ */ jsx(
3084
+ ChannelSettings,
3085
+ {
3086
+ conversationId: conversation.id,
3087
+ channelName: conversation.name,
3088
+ channelPicture: conversation.picture,
3089
+ participants,
3090
+ currentUserId: config.userId,
3091
+ isAdmin: channelSettings.isAdmin,
3092
+ isUpdating: channelSettings.isUpdating,
3093
+ error: channelSettings.error,
3094
+ onUpdateChannel: channelSettings.updateChannel,
3095
+ onInviteUser: channelSettings.inviteUser,
3096
+ onRemoveUser: channelSettings.removeUser,
3097
+ onLeaveChannel: channelSettings.leaveChannel,
3098
+ onDeleteChannel: channelSettings.deleteChannel,
3099
+ onClose: () => setShowSettings(false)
3100
+ }
3101
+ ) : /* @__PURE__ */ jsxs(Fragment, { children: [
3102
+ /* @__PURE__ */ jsx(
3103
+ PatientHeader,
3104
+ {
3105
+ patientData,
3106
+ participants,
3107
+ onOpenDicom: handleOpenDicom,
3108
+ onOpenSettings: () => setShowSettings(true)
3109
+ }
3110
+ ),
3111
+ pinnedMessages.length > 0 && /* @__PURE__ */ jsx(
3112
+ PinnedMessagesBar,
3113
+ {
3114
+ pinnedMessages,
3115
+ onJumpTo: handleJumpToMessage,
3116
+ onUnpin: unpinMessage
3117
+ }
3118
+ ),
3119
+ /* @__PURE__ */ jsx(
3120
+ MessageList,
3121
+ {
3122
+ ref: messageListRef,
3123
+ messages,
3124
+ currentUserId: config.userId,
3125
+ participants,
3126
+ showSeenBy,
3127
+ typingUsers,
3128
+ hasMore,
3129
+ isLoading,
3130
+ onLoadMore: loadMoreMessages,
3131
+ onDeepLinkClick: handleDeepLink,
3132
+ onMessageVisible: markAsRead,
3133
+ onReply: setReplyTo,
3134
+ onPin: pinMessage,
3135
+ onUnpin: unpinMessage,
3136
+ onCopy: handleCopy,
3137
+ pinDisabled
3138
+ }
3139
+ ),
3140
+ /* @__PURE__ */ jsx(
3141
+ MessageInput,
3142
+ {
3143
+ onSendText: sendMessage,
3144
+ onSendAudio: sendAudioMessage,
3145
+ onSendFile: sendFileMessage,
3146
+ onTyping: sendTyping,
3147
+ replyTo,
3148
+ onCancelReply: () => setReplyTo(null)
3149
+ }
3150
+ )
3151
+ ] }) });
3152
+ }
3153
+ var panelStyles = {
3154
+ container: {
3155
+ display: "flex",
3156
+ flexDirection: "column",
3157
+ height: "100%",
3158
+ backgroundColor: "#ffffff",
3159
+ borderRadius: "8px",
3160
+ overflow: "hidden",
3161
+ border: "1px solid #e5e7eb",
3162
+ fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif'
3163
+ },
3164
+ loadingState: {
3165
+ display: "flex",
3166
+ alignItems: "center",
3167
+ justifyContent: "center",
3168
+ height: "100%",
3169
+ fontSize: "14px",
3170
+ color: "#9ca3af"
3171
+ },
3172
+ errorState: {
3173
+ display: "flex",
3174
+ flexDirection: "column",
3175
+ alignItems: "center",
3176
+ justifyContent: "center",
3177
+ height: "100%",
3178
+ padding: "24px",
3179
+ textAlign: "center"
3180
+ },
3181
+ errorTitle: {
3182
+ fontSize: "15px",
3183
+ fontWeight: 500,
3184
+ color: "#dc2626",
3185
+ margin: "0 0 4px"
3186
+ },
3187
+ errorMessage: {
3188
+ fontSize: "13px",
3189
+ color: "#6b7280",
3190
+ margin: 0
3191
+ }
3192
+ };
3193
+ function CollabPopup({
3194
+ orderId,
3195
+ patientData,
3196
+ participantIds,
3197
+ isOpen,
3198
+ onClose,
3199
+ initialPosition,
3200
+ width = 380,
3201
+ height = 520,
3202
+ className
3203
+ }) {
3204
+ const [position, setPosition] = useState(
3205
+ initialPosition || {
3206
+ x: window.innerWidth - width - 24,
3207
+ y: Math.max(24, window.innerHeight - height - 104)
3208
+ }
3209
+ );
3210
+ const [isDragging, setIsDragging] = useState(false);
3211
+ const [isMinimized, setIsMinimized] = useState(false);
3212
+ const dragOffset = useRef({ x: 0, y: 0 });
3213
+ const containerRef = useRef(null);
3214
+ const handleMouseDown = useCallback(
3215
+ (e) => {
3216
+ if (!e.target.closest("[data-drag-handle]")) return;
3217
+ setIsDragging(true);
3218
+ dragOffset.current = {
3219
+ x: e.clientX - position.x,
3220
+ y: e.clientY - position.y
3221
+ };
3222
+ e.preventDefault();
3223
+ },
3224
+ [position]
3225
+ );
3226
+ useEffect(() => {
3227
+ if (!isOpen) return;
3228
+ const handleKey = (e) => {
3229
+ if (e.key === "Escape") {
3230
+ onClose();
3231
+ }
3232
+ };
3233
+ window.addEventListener("keydown", handleKey);
3234
+ return () => window.removeEventListener("keydown", handleKey);
3235
+ }, [isOpen, onClose]);
3236
+ useEffect(() => {
3237
+ if (!isDragging) return;
3238
+ const handleMouseMove = (e) => {
3239
+ setPosition({
3240
+ x: Math.max(0, Math.min(e.clientX - dragOffset.current.x, window.innerWidth - width)),
3241
+ y: Math.max(0, Math.min(e.clientY - dragOffset.current.y, window.innerHeight - (isMinimized ? 48 : height)))
3242
+ });
3243
+ };
3244
+ const handleMouseUp = () => {
3245
+ setIsDragging(false);
3246
+ };
3247
+ document.addEventListener("mousemove", handleMouseMove);
3248
+ document.addEventListener("mouseup", handleMouseUp);
3249
+ return () => {
3250
+ document.removeEventListener("mousemove", handleMouseMove);
3251
+ document.removeEventListener("mouseup", handleMouseUp);
3252
+ };
3253
+ }, [isDragging, width, height, isMinimized]);
3254
+ if (!isOpen) return null;
3255
+ return /* @__PURE__ */ jsxs(
3256
+ "div",
3257
+ {
3258
+ ref: containerRef,
3259
+ className,
3260
+ style: {
3261
+ ...styles12.container,
3262
+ left: position.x,
3263
+ top: position.y,
3264
+ width,
3265
+ height: isMinimized ? 48 : height,
3266
+ cursor: isDragging ? "grabbing" : "default"
3267
+ },
3268
+ onMouseDown: handleMouseDown,
3269
+ children: [
3270
+ /* @__PURE__ */ jsxs("div", { "data-drag-handle": true, style: styles12.titleBar, children: [
3271
+ /* @__PURE__ */ jsx("span", { style: styles12.titleText, children: buildChannelName(patientData) }),
3272
+ /* @__PURE__ */ jsxs("div", { style: styles12.titleActions, children: [
3273
+ /* @__PURE__ */ jsx(
3274
+ "button",
3275
+ {
3276
+ onClick: () => setIsMinimized(!isMinimized),
3277
+ style: styles12.titleButton,
3278
+ title: isMinimized ? "Expand" : "Minimize",
3279
+ type: "button",
3280
+ children: isMinimized ? "\u25A2" : "\u2014"
3281
+ }
3282
+ ),
3283
+ /* @__PURE__ */ jsx("button", { onClick: onClose, style: styles12.titleButton, title: "Close", type: "button", children: "\u2715" })
3284
+ ] })
3285
+ ] }),
3286
+ !isMinimized && /* @__PURE__ */ jsx("div", { style: styles12.panelWrapper, children: /* @__PURE__ */ jsx(CollabPanel, { orderId, patientData, participantIds }) })
3287
+ ]
3288
+ }
3289
+ );
3290
+ }
3291
+ var styles12 = {
3292
+ container: {
3293
+ position: "fixed",
3294
+ zIndex: 9999,
3295
+ borderRadius: "12px",
3296
+ boxShadow: "0 8px 30px rgba(0, 0, 0, 0.12), 0 2px 8px rgba(0, 0, 0, 0.08)",
3297
+ overflow: "hidden",
3298
+ display: "flex",
3299
+ flexDirection: "column",
3300
+ backgroundColor: "#ffffff",
3301
+ border: "1px solid #e5e7eb",
3302
+ transition: "height 0.2s ease"
3303
+ },
3304
+ titleBar: {
3305
+ display: "flex",
3306
+ justifyContent: "space-between",
3307
+ alignItems: "center",
3308
+ padding: "0 12px",
3309
+ height: "48px",
3310
+ backgroundColor: "#1e293b",
3311
+ cursor: "grab",
3312
+ userSelect: "none",
3313
+ flexShrink: 0
3314
+ },
3315
+ titleText: {
3316
+ fontSize: "13px",
3317
+ fontWeight: 500,
3318
+ color: "#ffffff",
3319
+ overflow: "hidden",
3320
+ textOverflow: "ellipsis",
3321
+ whiteSpace: "nowrap"
3322
+ },
3323
+ titleActions: {
3324
+ display: "flex",
3325
+ gap: "4px",
3326
+ flexShrink: 0
3327
+ },
3328
+ titleButton: {
3329
+ width: "28px",
3330
+ height: "28px",
3331
+ display: "flex",
3332
+ alignItems: "center",
3333
+ justifyContent: "center",
3334
+ backgroundColor: "transparent",
3335
+ border: "none",
3336
+ borderRadius: "4px",
3337
+ cursor: "pointer",
3338
+ fontSize: "13px",
3339
+ color: "#94a3b8"
3340
+ },
3341
+ panelWrapper: {
3342
+ flex: 1,
3343
+ overflow: "hidden"
3344
+ }
3345
+ };
3346
+ function useInlineCollab({
3347
+ orderId,
3348
+ patientData,
3349
+ participantIds = [],
3350
+ messageLimit = 5
3351
+ }) {
3352
+ const {
3353
+ socket,
3354
+ config,
3355
+ requestPreview,
3356
+ invalidatePreview,
3357
+ createConversationWithMessage
3358
+ } = useCollab();
3359
+ const [preview, setPreview] = useState(null);
3360
+ const [messages, setMessages] = useState([]);
3361
+ const [participants, setParticipants] = useState([]);
3362
+ const [unreadCount, setUnreadCount] = useState(0);
3363
+ const [isLoading, setIsLoading] = useState(true);
3364
+ const [isSubscribed, setIsSubscribed] = useState(false);
3365
+ const [error, setError] = useState(null);
3366
+ const elementRef = useRef(null);
3367
+ const observerRef = useRef(null);
3368
+ const subscribedConversationIdRef = useRef(null);
3369
+ const trimToLimit = useCallback(
3370
+ (msgs) => msgs.length > messageLimit ? msgs.slice(msgs.length - messageLimit) : msgs,
3371
+ [messageLimit]
3372
+ );
3373
+ const buildName = useCallback(
3374
+ () => buildChannelName(patientData),
3375
+ [patientData]
3376
+ );
3377
+ useEffect(() => {
3378
+ let cancelled = false;
3379
+ const load = async () => {
3380
+ setIsLoading(true);
3381
+ setError(null);
3382
+ try {
3383
+ const result = await requestPreview(orderId);
3384
+ if (cancelled) return;
3385
+ setPreview(result);
3386
+ if (result) {
3387
+ setMessages(trimToLimit(result.lastMessages));
3388
+ setParticipants(result.participants);
3389
+ setUnreadCount(result.unreadCount);
3390
+ }
3391
+ } catch (err) {
3392
+ if (cancelled) return;
3393
+ setError(err instanceof Error ? err.message : "Failed to load preview");
3394
+ } finally {
3395
+ if (!cancelled) setIsLoading(false);
3396
+ }
3397
+ };
3398
+ load();
3399
+ return () => {
3400
+ cancelled = true;
3401
+ };
3402
+ }, [orderId, requestPreview, trimToLimit]);
3403
+ const subscribe = useCallback(
3404
+ (conversationId) => {
3405
+ if (subscribedConversationIdRef.current === conversationId) return;
3406
+ socket.joinConversation(conversationId, {
3407
+ onMessage: (msg) => {
3408
+ setMessages((prev) => trimToLimit([...prev, msg]));
3409
+ if (msg.senderId !== config.userId) {
3410
+ setUnreadCount((prev) => prev + 1);
3411
+ }
3412
+ },
3413
+ onUserJoined: (participant) => {
3414
+ setParticipants(
3415
+ (prev) => prev.some((p) => p.userId === participant.userId) ? prev : [...prev, participant]
3416
+ );
3417
+ },
3418
+ onUserLeft: (participant) => {
3419
+ setParticipants((prev) => prev.filter((p) => p.userId !== participant.userId));
3420
+ }
3421
+ });
3422
+ subscribedConversationIdRef.current = conversationId;
3423
+ setIsSubscribed(true);
3424
+ },
3425
+ [socket, trimToLimit, config.userId]
3426
+ );
3427
+ const unsubscribe = useCallback(() => {
3428
+ const convId = subscribedConversationIdRef.current;
3429
+ if (convId) {
3430
+ socket.leaveConversation(convId);
3431
+ subscribedConversationIdRef.current = null;
3432
+ setIsSubscribed(false);
3433
+ }
3434
+ }, [socket]);
3435
+ const containerRef = useCallback(
3436
+ (element) => {
3437
+ if (observerRef.current) {
3438
+ observerRef.current.disconnect();
3439
+ observerRef.current = null;
3440
+ }
3441
+ elementRef.current = element;
3442
+ if (!element) return;
3443
+ observerRef.current = new IntersectionObserver(
3444
+ (entries) => {
3445
+ const entry = entries[0];
3446
+ if (!entry) return;
3447
+ const convId = preview?.conversationId;
3448
+ if (!convId) return;
3449
+ if (entry.isIntersecting) {
3450
+ subscribe(convId);
3451
+ } else {
3452
+ unsubscribe();
3453
+ }
3454
+ },
3455
+ { threshold: 0.1 }
3456
+ );
3457
+ observerRef.current.observe(element);
3458
+ },
3459
+ [preview, subscribe, unsubscribe]
3460
+ );
3461
+ useEffect(() => {
3462
+ return () => {
3463
+ if (observerRef.current) observerRef.current.disconnect();
3464
+ unsubscribe();
3465
+ };
3466
+ }, [unsubscribe]);
3467
+ useEffect(() => {
3468
+ if (preview?.conversationId && elementRef.current && !observerRef.current) {
3469
+ containerRef(elementRef.current);
3470
+ }
3471
+ }, [preview, containerRef]);
3472
+ const dispatchSend = useCallback(
3473
+ async (payload) => {
3474
+ if (!preview) {
3475
+ try {
3476
+ const result = await createConversationWithMessage(orderId, payload, {
3477
+ name: buildName(),
3478
+ participantIds
3479
+ });
3480
+ const newPreview = {
3481
+ conversationId: result.conversation.id,
3482
+ orderId,
3483
+ messageCount: 1,
3484
+ unreadCount: 0,
3485
+ lastMessages: [result.message],
3486
+ participants: result.conversation.participants,
3487
+ name: result.conversation.name
3488
+ };
3489
+ setPreview(newPreview);
3490
+ setMessages([result.message]);
3491
+ setParticipants(result.conversation.participants);
3492
+ invalidatePreview(orderId);
3493
+ if (elementRef.current) {
3494
+ subscribe(result.conversation.id);
3495
+ }
3496
+ } catch (err) {
3497
+ setError(err instanceof Error ? err.message : "Failed to send message");
3498
+ config.onError?.({
3499
+ code: "SEND_ERROR",
3500
+ message: "Failed to send first message",
3501
+ details: err
3502
+ });
3503
+ }
3504
+ return;
3505
+ }
3506
+ try {
3507
+ await socket.sendMessage(preview.conversationId, payload);
3508
+ } catch (err) {
3509
+ setError(err instanceof Error ? err.message : "Failed to send message");
3510
+ config.onError?.({
3511
+ code: "SEND_ERROR",
3512
+ message: "Failed to send message",
3513
+ details: err
3514
+ });
3515
+ }
3516
+ },
3517
+ [
3518
+ preview,
3519
+ orderId,
3520
+ buildName,
3521
+ participantIds,
3522
+ createConversationWithMessage,
3523
+ invalidatePreview,
3524
+ subscribe,
3525
+ socket,
3526
+ config
3527
+ ]
3528
+ );
3529
+ const sendMessage = useCallback(
3530
+ async (body) => {
3531
+ const trimmed = body.trim();
3532
+ if (!trimmed) return;
3533
+ await dispatchSend({ body: trimmed, type: "text" });
3534
+ },
3535
+ [dispatchSend]
3536
+ );
3537
+ const sendAudioMessage = useCallback(
3538
+ async (audioBlob, duration) => {
3539
+ if (!config.onUploadFile) {
3540
+ setError("File upload is not configured");
3541
+ return;
3542
+ }
3543
+ try {
3544
+ const url = await config.onUploadFile(audioBlob, "voice-message.webm");
3545
+ await dispatchSend({
3546
+ body: "Voice message",
3547
+ type: "audio",
3548
+ mediaUrl: url,
3549
+ mediaDuration: duration
3550
+ });
3551
+ } catch (err) {
3552
+ setError(err instanceof Error ? err.message : "Failed to send voice message");
3553
+ config.onError?.({
3554
+ code: "SEND_ERROR",
3555
+ message: "Failed to send voice message",
3556
+ details: err
3557
+ });
3558
+ }
3559
+ },
3560
+ [config, dispatchSend]
3561
+ );
3562
+ return {
3563
+ hasConversation: !!preview,
3564
+ messages,
3565
+ participants,
3566
+ unreadCount,
3567
+ isLoading,
3568
+ isSubscribed,
3569
+ error,
3570
+ sendMessage,
3571
+ sendAudioMessage,
3572
+ containerRef
3573
+ };
3574
+ }
3575
+ function CollabInline({
3576
+ orderId,
3577
+ patientData,
3578
+ participantIds,
3579
+ onExpand,
3580
+ messageLimit = 5,
3581
+ placeholder = "Type a message about this case...",
3582
+ className,
3583
+ style
3584
+ }) {
3585
+ const {
3586
+ hasConversation,
3587
+ messages,
3588
+ unreadCount,
3589
+ isLoading,
3590
+ isSubscribed,
3591
+ error,
3592
+ sendMessage,
3593
+ sendAudioMessage,
3594
+ containerRef
3595
+ } = useInlineCollab({ orderId, patientData, participantIds, messageLimit });
3596
+ if (isLoading && !hasConversation) {
3597
+ return /* @__PURE__ */ jsx("div", { className, style: { ...styles13.container, ...style }, children: /* @__PURE__ */ jsx("div", { style: styles13.loadingState, children: "Loading..." }) });
3598
+ }
3599
+ return /* @__PURE__ */ jsxs("div", { ref: containerRef, className, style: { ...styles13.container, ...style }, children: [
3600
+ hasConversation && messages.length > 0 && /* @__PURE__ */ jsx("div", { style: styles13.preview, children: messages.slice(-messageLimit).map((message) => /* @__PURE__ */ jsx(InlineMessageRow, { message }, message.id)) }),
3601
+ error && /* @__PURE__ */ jsx("div", { style: styles13.error, children: error }),
3602
+ /* @__PURE__ */ jsx(
3603
+ InlineInputBar,
3604
+ {
3605
+ onSend: sendMessage,
3606
+ onSendAudio: sendAudioMessage,
3607
+ placeholder,
3608
+ unreadCount: hasConversation ? unreadCount : 0,
3609
+ isLive: isSubscribed,
3610
+ showExpand: hasConversation && !!onExpand,
3611
+ onExpand
3612
+ }
3613
+ )
3614
+ ] });
3615
+ }
3616
+ function InlineMessageRow({ message }) {
3617
+ if (message.type === "system") {
3618
+ return /* @__PURE__ */ jsx("div", { style: styles13.systemRow, children: /* @__PURE__ */ jsx("span", { style: styles13.systemText, children: message.body }) });
3619
+ }
3620
+ if (message.type === "audio" && message.mediaUrl) {
3621
+ return /* @__PURE__ */ jsxs("div", { style: styles13.messageRow, children: [
3622
+ /* @__PURE__ */ jsx(RoleDot, { role: message.senderRole }),
3623
+ /* @__PURE__ */ jsxs("span", { style: styles13.senderName, children: [
3624
+ message.senderName,
3625
+ ":"
3626
+ ] }),
3627
+ /* @__PURE__ */ jsx(InlineAudioPlayer, { url: message.mediaUrl, duration: message.mediaDuration })
3628
+ ] });
3629
+ }
3630
+ const preview = renderMessagePreview(message);
3631
+ return /* @__PURE__ */ jsxs("div", { style: styles13.messageRow, children: [
3632
+ /* @__PURE__ */ jsx(RoleDot, { role: message.senderRole }),
3633
+ /* @__PURE__ */ jsxs("span", { style: styles13.senderName, children: [
3634
+ message.senderName,
3635
+ ":"
3636
+ ] }),
3637
+ /* @__PURE__ */ jsx("span", { style: styles13.messageText, children: preview })
3638
+ ] });
3639
+ }
3640
+ function RoleDot({ role }) {
3641
+ const colors = {
3642
+ radiologist: "#7c3aed",
3643
+ lab: "#2563eb",
3644
+ physician: "#059669",
3645
+ admin: "#dc2626"
3646
+ };
3647
+ return /* @__PURE__ */ jsx("span", { style: { ...styles13.roleDot, backgroundColor: colors[role] } });
3648
+ }
3649
+ function InlineAudioPlayer({ url, duration }) {
3650
+ const audioRef = useRef(null);
3651
+ const [isPlaying, setIsPlaying] = useState(false);
3652
+ const [currentTime, setCurrentTime] = useState(0);
3653
+ const toggle = useCallback(
3654
+ (e) => {
3655
+ e.stopPropagation();
3656
+ const audio = audioRef.current;
3657
+ if (!audio) return;
3658
+ if (isPlaying) {
3659
+ audio.pause();
3660
+ } else {
3661
+ audio.play().catch(() => {
3662
+ });
3663
+ }
3664
+ },
3665
+ [isPlaying]
3666
+ );
3667
+ useEffect(() => {
3668
+ const audio = audioRef.current;
3669
+ if (!audio) return;
3670
+ const onPlay = () => setIsPlaying(true);
3671
+ const onPause = () => setIsPlaying(false);
3672
+ const onEnded = () => {
3673
+ setIsPlaying(false);
3674
+ setCurrentTime(0);
3675
+ };
3676
+ const onTimeUpdate = () => setCurrentTime(audio.currentTime);
3677
+ audio.addEventListener("play", onPlay);
3678
+ audio.addEventListener("pause", onPause);
3679
+ audio.addEventListener("ended", onEnded);
3680
+ audio.addEventListener("timeupdate", onTimeUpdate);
3681
+ return () => {
3682
+ audio.removeEventListener("play", onPlay);
3683
+ audio.removeEventListener("pause", onPause);
3684
+ audio.removeEventListener("ended", onEnded);
3685
+ audio.removeEventListener("timeupdate", onTimeUpdate);
3686
+ };
3687
+ }, []);
3688
+ const total = duration ?? 0;
3689
+ const remaining = Math.max(0, total - Math.floor(currentTime));
3690
+ const displayTime = isPlaying ? remaining : total;
3691
+ return /* @__PURE__ */ jsxs("span", { style: styles13.audioPlayer, children: [
3692
+ /* @__PURE__ */ jsx(
3693
+ "button",
3694
+ {
3695
+ onClick: toggle,
3696
+ style: styles13.audioButton,
3697
+ title: isPlaying ? "Pause" : "Play",
3698
+ type: "button",
3699
+ children: isPlaying ? "\u23F8" : "\u25B6"
3700
+ }
3701
+ ),
3702
+ /* @__PURE__ */ jsxs("span", { style: styles13.audioWaveform, children: [
3703
+ /* @__PURE__ */ jsx("span", { style: { ...styles13.waveBar, height: "40%" } }),
3704
+ /* @__PURE__ */ jsx("span", { style: { ...styles13.waveBar, height: "80%" } }),
3705
+ /* @__PURE__ */ jsx("span", { style: { ...styles13.waveBar, height: "60%" } }),
3706
+ /* @__PURE__ */ jsx("span", { style: { ...styles13.waveBar, height: "90%" } }),
3707
+ /* @__PURE__ */ jsx("span", { style: { ...styles13.waveBar, height: "50%" } }),
3708
+ /* @__PURE__ */ jsx("span", { style: { ...styles13.waveBar, height: "70%" } }),
3709
+ /* @__PURE__ */ jsx("span", { style: { ...styles13.waveBar, height: "40%" } })
3710
+ ] }),
3711
+ /* @__PURE__ */ jsx("span", { style: styles13.audioDuration, children: formatDuration3(displayTime) }),
3712
+ /* @__PURE__ */ jsx("audio", { ref: audioRef, src: url, preload: "metadata" })
3713
+ ] });
3714
+ }
3715
+ function renderMessagePreview(message) {
3716
+ switch (message.type) {
3717
+ case "image":
3718
+ return `\u{1F4F7} ${message.fileName || "Image"}`;
3719
+ case "file":
3720
+ return `\u{1F4CE} ${message.fileName || "File"}`;
3721
+ case "deep_link":
3722
+ return `\u{1F517} ${message.body}`;
3723
+ default:
3724
+ return message.body;
3725
+ }
3726
+ }
3727
+ function formatDuration3(seconds) {
3728
+ const safe = Math.max(0, Math.floor(seconds));
3729
+ const m = Math.floor(safe / 60);
3730
+ const s = safe % 60;
3731
+ return `${m}:${s.toString().padStart(2, "0")}`;
3732
+ }
3733
+ function InlineInputBar({
3734
+ onSend,
3735
+ onSendAudio,
3736
+ placeholder,
3737
+ unreadCount,
3738
+ isLive,
3739
+ showExpand,
3740
+ onExpand
3741
+ }) {
3742
+ const [text, setText] = useState("");
3743
+ const [isSending, setIsSending] = useState(false);
3744
+ const inputRef = useRef(null);
3745
+ const {
3746
+ isRecording,
3747
+ duration,
3748
+ start: startRecording,
3749
+ stop: stopRecording,
3750
+ cancel: cancelRecording,
3751
+ isSupported: micSupported,
3752
+ error: micError
3753
+ } = useAudioRecorder();
3754
+ const handleSend = useCallback(async () => {
3755
+ const trimmed = text.trim();
3756
+ if (!trimmed || isSending) return;
3757
+ setIsSending(true);
3758
+ try {
3759
+ await onSend(trimmed);
3760
+ setText("");
3761
+ inputRef.current?.focus();
3762
+ } finally {
3763
+ setIsSending(false);
3764
+ }
3765
+ }, [text, isSending, onSend]);
3766
+ const handleKeyDown = useCallback(
3767
+ (e) => {
3768
+ if (e.key === "Enter" && !e.shiftKey) {
3769
+ e.preventDefault();
3770
+ handleSend();
3771
+ }
3772
+ },
3773
+ [handleSend]
3774
+ );
3775
+ const handleStopRecording = useCallback(async () => {
3776
+ const result = await stopRecording();
3777
+ if (result) {
3778
+ setIsSending(true);
3779
+ try {
3780
+ await onSendAudio(result.blob, result.duration);
3781
+ } finally {
3782
+ setIsSending(false);
3783
+ }
3784
+ }
3785
+ }, [stopRecording, onSendAudio]);
3786
+ if (isRecording) {
3787
+ return /* @__PURE__ */ jsxs("div", { style: styles13.recordingBar, children: [
3788
+ /* @__PURE__ */ jsx("span", { style: styles13.recordingDot }),
3789
+ /* @__PURE__ */ jsxs("span", { style: styles13.recordingLabel, children: [
3790
+ "Recording ",
3791
+ formatDuration3(duration)
3792
+ ] }),
3793
+ /* @__PURE__ */ jsx(
3794
+ "button",
3795
+ {
3796
+ onClick: cancelRecording,
3797
+ style: styles13.recordingCancel,
3798
+ title: "Cancel recording",
3799
+ type: "button",
3800
+ children: "Cancel"
3801
+ }
3802
+ ),
3803
+ /* @__PURE__ */ jsx(
3804
+ "button",
3805
+ {
3806
+ onClick: handleStopRecording,
3807
+ style: styles13.recordingSend,
3808
+ title: "Send voice message",
3809
+ type: "button",
3810
+ children: "Send"
3811
+ }
3812
+ )
3813
+ ] });
3814
+ }
3815
+ return /* @__PURE__ */ jsxs("div", { style: styles13.inputBar, children: [
3816
+ /* @__PURE__ */ jsx(
3817
+ "input",
3818
+ {
3819
+ ref: inputRef,
3820
+ type: "text",
3821
+ value: text,
3822
+ onChange: (e) => setText(e.target.value),
3823
+ onKeyDown: handleKeyDown,
3824
+ placeholder: micError ?? placeholder,
3825
+ disabled: isSending,
3826
+ style: styles13.input
3827
+ }
3828
+ ),
3829
+ unreadCount > 0 && /* @__PURE__ */ jsx("span", { style: styles13.unreadBadge, title: `${unreadCount} unread`, children: unreadCount > 99 ? "99+" : unreadCount }),
3830
+ isLive && /* @__PURE__ */ jsx("span", { style: styles13.liveDot, title: "Live updates active" }),
3831
+ micSupported && !text.trim() && /* @__PURE__ */ jsx(
3832
+ "button",
3833
+ {
3834
+ onClick: startRecording,
3835
+ disabled: isSending,
3836
+ style: styles13.micButton,
3837
+ title: "Record voice message",
3838
+ type: "button",
3839
+ children: /* @__PURE__ */ jsx(MicIcon, { size: 16, color: "#374151" })
3840
+ }
3841
+ ),
3842
+ text.trim() && /* @__PURE__ */ jsx(
3843
+ "button",
3844
+ {
3845
+ onClick: handleSend,
3846
+ disabled: isSending,
3847
+ style: styles13.sendButton,
3848
+ title: "Send message",
3849
+ type: "button",
3850
+ children: "\u27A4"
3851
+ }
3852
+ ),
3853
+ showExpand && onExpand && /* @__PURE__ */ jsx(
3854
+ "button",
3855
+ {
3856
+ onClick: onExpand,
3857
+ style: styles13.expandButton,
3858
+ title: "Expand to full chat",
3859
+ type: "button",
3860
+ children: "\u26F6"
3861
+ }
3862
+ )
3863
+ ] });
3864
+ }
3865
+ var styles13 = {
3866
+ container: {
3867
+ display: "flex",
3868
+ flexDirection: "column",
3869
+ width: "100%",
3870
+ padding: "6px 8px",
3871
+ backgroundColor: "#ffffff",
3872
+ border: "1px solid #e5e7eb",
3873
+ borderRadius: "8px",
3874
+ fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif'
3875
+ },
3876
+ loadingState: {
3877
+ padding: "8px",
3878
+ fontSize: "12px",
3879
+ color: "#9ca3af",
3880
+ textAlign: "center"
3881
+ },
3882
+ error: {
3883
+ padding: "4px 8px",
3884
+ marginBottom: "4px",
3885
+ fontSize: "11px",
3886
+ color: "#dc2626",
3887
+ backgroundColor: "#fef2f2",
3888
+ borderRadius: "4px"
3889
+ },
3890
+ preview: {
3891
+ display: "flex",
3892
+ flexDirection: "column",
3893
+ gap: "2px",
3894
+ marginBottom: "6px",
3895
+ paddingBottom: "6px",
3896
+ borderBottom: "1px solid #f3f4f6"
3897
+ },
3898
+ messageRow: {
3899
+ display: "flex",
3900
+ alignItems: "center",
3901
+ gap: "6px",
3902
+ fontSize: "12px",
3903
+ lineHeight: "1.4",
3904
+ overflow: "hidden"
3905
+ },
3906
+ roleDot: {
3907
+ flexShrink: 0,
3908
+ width: "6px",
3909
+ height: "6px",
3910
+ borderRadius: "50%"
3911
+ },
3912
+ senderName: {
3913
+ flexShrink: 0,
3914
+ fontWeight: 600,
3915
+ color: "#374151"
3916
+ },
3917
+ messageText: {
3918
+ color: "#4b5563",
3919
+ overflow: "hidden",
3920
+ textOverflow: "ellipsis",
3921
+ whiteSpace: "nowrap",
3922
+ minWidth: 0,
3923
+ flex: 1
3924
+ },
3925
+ systemRow: {
3926
+ fontSize: "11px",
3927
+ color: "#9ca3af",
3928
+ fontStyle: "italic",
3929
+ textAlign: "center",
3930
+ padding: "2px 0"
3931
+ },
3932
+ systemText: {
3933
+ opacity: 0.8
3934
+ },
3935
+ // ── Audio player (inline) ──
3936
+ audioPlayer: {
3937
+ display: "inline-flex",
3938
+ alignItems: "center",
3939
+ gap: "6px",
3940
+ padding: "2px 8px",
3941
+ borderRadius: "12px",
3942
+ backgroundColor: "#eff6ff",
3943
+ border: "1px solid #dbeafe",
3944
+ flex: 1,
3945
+ minWidth: 0
3946
+ },
3947
+ audioButton: {
3948
+ width: "20px",
3949
+ height: "20px",
3950
+ display: "flex",
3951
+ alignItems: "center",
3952
+ justifyContent: "center",
3953
+ fontSize: "10px",
3954
+ color: "#ffffff",
3955
+ backgroundColor: "#2563eb",
3956
+ border: "none",
3957
+ borderRadius: "50%",
3958
+ cursor: "pointer",
3959
+ flexShrink: 0
3960
+ },
3961
+ audioWaveform: {
3962
+ display: "inline-flex",
3963
+ alignItems: "center",
3964
+ gap: "2px",
3965
+ height: "16px",
3966
+ flex: 1,
3967
+ minWidth: 0
3968
+ },
3969
+ waveBar: {
3970
+ display: "inline-block",
3971
+ width: "2px",
3972
+ borderRadius: "1px",
3973
+ backgroundColor: "#60a5fa"
3974
+ },
3975
+ audioDuration: {
3976
+ fontSize: "11px",
3977
+ color: "#2563eb",
3978
+ fontVariantNumeric: "tabular-nums",
3979
+ fontWeight: 500,
3980
+ flexShrink: 0
3981
+ },
3982
+ // ── Input bar ──
3983
+ inputBar: {
3984
+ display: "flex",
3985
+ alignItems: "center",
3986
+ gap: "6px"
3987
+ },
3988
+ input: {
3989
+ flex: 1,
3990
+ padding: "6px 10px",
3991
+ fontSize: "13px",
3992
+ border: "1px solid #e5e7eb",
3993
+ borderRadius: "16px",
3994
+ outline: "none",
3995
+ fontFamily: "inherit",
3996
+ minWidth: 0
3997
+ },
3998
+ unreadBadge: {
3999
+ flexShrink: 0,
4000
+ minWidth: "18px",
4001
+ height: "18px",
4002
+ padding: "0 5px",
4003
+ fontSize: "10px",
4004
+ fontWeight: 600,
4005
+ color: "#ffffff",
4006
+ backgroundColor: "#dc2626",
4007
+ borderRadius: "10px",
4008
+ display: "flex",
4009
+ alignItems: "center",
4010
+ justifyContent: "center"
4011
+ },
4012
+ liveDot: {
4013
+ flexShrink: 0,
4014
+ width: "6px",
4015
+ height: "6px",
4016
+ borderRadius: "50%",
4017
+ backgroundColor: "#22c55e"
4018
+ },
4019
+ micButton: {
4020
+ flexShrink: 0,
4021
+ width: "28px",
4022
+ height: "28px",
4023
+ display: "flex",
4024
+ alignItems: "center",
4025
+ justifyContent: "center",
4026
+ fontSize: "13px",
4027
+ backgroundColor: "#f3f4f6",
4028
+ border: "none",
4029
+ borderRadius: "50%",
4030
+ cursor: "pointer"
4031
+ },
4032
+ sendButton: {
4033
+ flexShrink: 0,
4034
+ width: "28px",
4035
+ height: "28px",
4036
+ display: "flex",
4037
+ alignItems: "center",
4038
+ justifyContent: "center",
4039
+ fontSize: "13px",
4040
+ color: "#ffffff",
4041
+ backgroundColor: "#2563eb",
4042
+ border: "none",
4043
+ borderRadius: "50%",
4044
+ cursor: "pointer"
4045
+ },
4046
+ expandButton: {
4047
+ flexShrink: 0,
4048
+ width: "28px",
4049
+ height: "28px",
4050
+ display: "flex",
4051
+ alignItems: "center",
4052
+ justifyContent: "center",
4053
+ fontSize: "12px",
4054
+ color: "#6b7280",
4055
+ backgroundColor: "transparent",
4056
+ border: "1px solid #e5e7eb",
4057
+ borderRadius: "50%",
4058
+ cursor: "pointer"
4059
+ },
4060
+ // ── Recording mode ──
4061
+ recordingBar: {
4062
+ display: "flex",
4063
+ alignItems: "center",
4064
+ gap: "8px",
4065
+ padding: "6px 10px",
4066
+ backgroundColor: "#fef2f2",
4067
+ border: "1px solid #fecaca",
4068
+ borderRadius: "16px"
4069
+ },
4070
+ recordingDot: {
4071
+ width: "8px",
4072
+ height: "8px",
4073
+ borderRadius: "50%",
4074
+ backgroundColor: "#dc2626",
4075
+ flexShrink: 0,
4076
+ animation: "pulse 1.5s infinite"
4077
+ },
4078
+ recordingLabel: {
4079
+ flex: 1,
4080
+ fontSize: "12px",
4081
+ fontWeight: 500,
4082
+ color: "#dc2626",
4083
+ fontVariantNumeric: "tabular-nums"
4084
+ },
4085
+ recordingCancel: {
4086
+ padding: "4px 10px",
4087
+ fontSize: "11px",
4088
+ color: "#6b7280",
4089
+ backgroundColor: "#ffffff",
4090
+ border: "1px solid #d1d5db",
4091
+ borderRadius: "12px",
4092
+ cursor: "pointer",
4093
+ flexShrink: 0
4094
+ },
4095
+ recordingSend: {
4096
+ padding: "4px 10px",
4097
+ fontSize: "11px",
4098
+ fontWeight: 500,
4099
+ color: "#ffffff",
4100
+ backgroundColor: "#dc2626",
4101
+ border: "none",
4102
+ borderRadius: "12px",
4103
+ cursor: "pointer",
4104
+ flexShrink: 0
4105
+ }
4106
+ };
4107
+ function useConversationList(options) {
4108
+ const enabled = options?.enabled ?? true;
4109
+ const { fetchConversationList, config, totalUnread: serverTotalUnread } = useCollab();
4110
+ const [conversations, setConversations] = useState([]);
4111
+ const [isLoading, setIsLoading] = useState(enabled);
4112
+ const [error, setError] = useState(null);
4113
+ const silentRefresh = useCallback(async () => {
4114
+ try {
4115
+ const list = await fetchConversationList();
4116
+ setConversations(list);
4117
+ } catch (err) {
4118
+ config.onError?.({
4119
+ code: "LIST_ERROR",
4120
+ message: err instanceof Error ? err.message : "Failed to load conversations",
4121
+ details: err
4122
+ });
4123
+ }
4124
+ }, [fetchConversationList, config]);
4125
+ const refresh = useCallback(async () => {
4126
+ setIsLoading(true);
4127
+ setError(null);
4128
+ try {
4129
+ const list = await fetchConversationList();
4130
+ setConversations(list);
4131
+ } catch (err) {
4132
+ const msg = err instanceof Error ? err.message : "Failed to load conversations";
4133
+ setError(msg);
4134
+ config.onError?.({
4135
+ code: "LIST_ERROR",
4136
+ message: msg,
4137
+ details: err
4138
+ });
4139
+ } finally {
4140
+ setIsLoading(false);
4141
+ }
4142
+ }, [fetchConversationList, config]);
4143
+ useEffect(() => {
4144
+ if (!enabled) return;
4145
+ refresh();
4146
+ }, [enabled, refresh]);
4147
+ const hasHandledInitialUnreadRef = useRef(false);
4148
+ useEffect(() => {
4149
+ if (!enabled) return;
4150
+ if (!hasHandledInitialUnreadRef.current) {
4151
+ hasHandledInitialUnreadRef.current = true;
4152
+ return;
4153
+ }
4154
+ silentRefresh();
4155
+ }, [serverTotalUnread, enabled]);
4156
+ const filter = useCallback(
4157
+ (query) => {
4158
+ const q = query.trim().toLowerCase();
4159
+ if (!q) return conversations;
4160
+ return conversations.filter((c) => {
4161
+ const patient = c.patientSnapshot?.patientName?.toLowerCase() || "";
4162
+ const name = c.name.toLowerCase();
4163
+ return name.includes(q) || patient.includes(q);
4164
+ });
4165
+ },
4166
+ [conversations]
4167
+ );
4168
+ const totalUnread = conversations.reduce((sum, c) => sum + c.unreadCount, 0);
4169
+ return {
4170
+ conversations,
4171
+ isLoading,
4172
+ error,
4173
+ refresh,
4174
+ filter,
4175
+ totalUnread
4176
+ };
4177
+ }
4178
+ function ConversationListItem({
4179
+ item,
4180
+ isSelected,
4181
+ onClick,
4182
+ className
4183
+ }) {
4184
+ const hasUnread = item.unreadCount > 0;
4185
+ const displayName = item.name || item.patientSnapshot?.patientName || "Unknown";
4186
+ return /* @__PURE__ */ jsxs(
4187
+ "button",
4188
+ {
4189
+ className,
4190
+ onClick,
4191
+ style: {
4192
+ ...styles14.container,
4193
+ ...isSelected ? styles14.selected : {},
4194
+ ...hasUnread ? styles14.unread : {}
4195
+ },
4196
+ type: "button",
4197
+ children: [
4198
+ /* @__PURE__ */ jsxs("div", { style: styles14.avatarWrapper, children: [
4199
+ item.picture ? /* @__PURE__ */ jsx("img", { src: item.picture, alt: "", style: styles14.avatar }) : /* @__PURE__ */ jsx("div", { style: styles14.avatarFallback, children: (displayName).charAt(0).toUpperCase() }),
4200
+ hasUnread && /* @__PURE__ */ jsx("span", { style: styles14.unreadDot })
4201
+ ] }),
4202
+ /* @__PURE__ */ jsxs("div", { style: styles14.content, children: [
4203
+ /* @__PURE__ */ jsxs("div", { style: styles14.topRow, children: [
4204
+ /* @__PURE__ */ jsx(
4205
+ "span",
4206
+ {
4207
+ style: {
4208
+ ...styles14.name,
4209
+ ...hasUnread ? styles14.nameUnread : {}
4210
+ },
4211
+ children: displayName
4212
+ }
4213
+ ),
4214
+ /* @__PURE__ */ jsx(
4215
+ "span",
4216
+ {
4217
+ style: {
4218
+ ...styles14.time,
4219
+ ...hasUnread ? styles14.timeUnread : {}
4220
+ },
4221
+ children: formatRelativeTime(item.lastActivityAt)
4222
+ }
4223
+ )
4224
+ ] }),
4225
+ /* @__PURE__ */ jsxs("div", { style: styles14.bottomRow, children: [
4226
+ /* @__PURE__ */ jsx(
4227
+ "span",
4228
+ {
4229
+ style: {
4230
+ ...styles14.preview,
4231
+ ...hasUnread ? styles14.previewUnread : {}
4232
+ },
4233
+ children: renderLastMessagePreview(item.lastMessage)
4234
+ }
4235
+ ),
4236
+ hasUnread && /* @__PURE__ */ jsx("span", { style: styles14.badge, children: item.unreadCount > 99 ? "99+" : item.unreadCount })
4237
+ ] })
4238
+ ] })
4239
+ ]
4240
+ }
4241
+ );
4242
+ }
4243
+ function renderLastMessagePreview(message) {
4244
+ if (!message) return "No messages yet";
4245
+ const prefix = message.senderName ? `${message.senderName}: ` : "";
4246
+ switch (message.type) {
4247
+ case "audio":
4248
+ return `${prefix}\u{1F3A4} Voice message`;
4249
+ case "image":
4250
+ return `${prefix}\u{1F4F7} ${message.fileName || "Image"}`;
4251
+ case "file":
4252
+ return `${prefix}\u{1F4CE} ${message.fileName || "File"}`;
4253
+ case "deep_link":
4254
+ return `${prefix}\u{1F517} ${message.body}`;
4255
+ case "system":
4256
+ return message.body;
4257
+ default: {
4258
+ const body = message.body || "";
4259
+ const preview = body.length > 60 ? `${body.slice(0, 60)}\u2026` : body;
4260
+ return `${prefix}${preview}`;
4261
+ }
4262
+ }
4263
+ }
4264
+ function formatRelativeTime(iso) {
4265
+ try {
4266
+ const date = new Date(iso);
4267
+ const now = /* @__PURE__ */ new Date();
4268
+ const diffMs = now.getTime() - date.getTime();
4269
+ const diffMin = Math.floor(diffMs / 6e4);
4270
+ const diffHr = Math.floor(diffMs / 36e5);
4271
+ const diffDay = Math.floor(diffMs / 864e5);
4272
+ if (diffMin < 1) return "now";
4273
+ if (diffMin < 60) return `${diffMin}m`;
4274
+ if (diffHr < 24) return `${diffHr}h`;
4275
+ if (diffDay < 7) return `${diffDay}d`;
4276
+ return date.toLocaleDateString([], { month: "short", day: "numeric" });
4277
+ } catch {
4278
+ return "";
4279
+ }
4280
+ }
4281
+ var styles14 = {
4282
+ container: {
4283
+ display: "flex",
4284
+ alignItems: "center",
4285
+ gap: "12px",
4286
+ padding: "10px 12px",
4287
+ width: "100%",
4288
+ backgroundColor: "transparent",
4289
+ border: "none",
4290
+ borderBottom: "1px solid #f3f4f6",
4291
+ cursor: "pointer",
4292
+ textAlign: "left"
4293
+ },
4294
+ selected: {
4295
+ backgroundColor: "#eff6ff"
4296
+ },
4297
+ unread: {},
4298
+ avatarWrapper: {
4299
+ position: "relative",
4300
+ width: "40px",
4301
+ height: "40px",
4302
+ flexShrink: 0
4303
+ },
4304
+ avatar: {
4305
+ width: "40px",
4306
+ height: "40px",
4307
+ borderRadius: "50%",
4308
+ objectFit: "cover"
4309
+ },
4310
+ avatarFallback: {
4311
+ width: "40px",
4312
+ height: "40px",
4313
+ borderRadius: "50%",
4314
+ backgroundColor: "#e5e7eb",
4315
+ display: "flex",
4316
+ alignItems: "center",
4317
+ justifyContent: "center",
4318
+ fontSize: "15px",
4319
+ fontWeight: 600,
4320
+ color: "#6b7280"
4321
+ },
4322
+ unreadDot: {
4323
+ position: "absolute",
4324
+ top: "0",
4325
+ left: "0",
4326
+ width: "10px",
4327
+ height: "10px",
4328
+ borderRadius: "50%",
4329
+ backgroundColor: "#2563eb",
4330
+ border: "2px solid #ffffff"
4331
+ },
4332
+ content: {
4333
+ flex: 1,
4334
+ display: "flex",
4335
+ flexDirection: "column",
4336
+ gap: "2px",
4337
+ minWidth: 0
4338
+ },
4339
+ topRow: {
4340
+ display: "flex",
4341
+ justifyContent: "space-between",
4342
+ alignItems: "baseline",
4343
+ gap: "8px"
4344
+ },
4345
+ name: {
4346
+ fontSize: "14px",
4347
+ fontWeight: 500,
4348
+ color: "#111827",
4349
+ overflow: "hidden",
4350
+ textOverflow: "ellipsis",
4351
+ whiteSpace: "nowrap",
4352
+ flex: 1,
4353
+ minWidth: 0
4354
+ },
4355
+ nameUnread: {
4356
+ fontWeight: 700
4357
+ },
4358
+ time: {
4359
+ fontSize: "11px",
4360
+ color: "#9ca3af",
4361
+ flexShrink: 0
4362
+ },
4363
+ timeUnread: {
4364
+ color: "#2563eb",
4365
+ fontWeight: 600
4366
+ },
4367
+ bottomRow: {
4368
+ display: "flex",
4369
+ justifyContent: "space-between",
4370
+ alignItems: "center",
4371
+ gap: "8px"
4372
+ },
4373
+ preview: {
4374
+ fontSize: "13px",
4375
+ color: "#6b7280",
4376
+ overflow: "hidden",
4377
+ textOverflow: "ellipsis",
4378
+ whiteSpace: "nowrap",
4379
+ flex: 1,
4380
+ minWidth: 0
4381
+ },
4382
+ previewUnread: {
4383
+ color: "#111827",
4384
+ fontWeight: 500
4385
+ },
4386
+ badge: {
4387
+ minWidth: "20px",
4388
+ height: "20px",
4389
+ padding: "0 6px",
4390
+ fontSize: "11px",
4391
+ fontWeight: 600,
4392
+ color: "#ffffff",
4393
+ backgroundColor: "#2563eb",
4394
+ borderRadius: "10px",
4395
+ display: "flex",
4396
+ alignItems: "center",
4397
+ justifyContent: "center",
4398
+ flexShrink: 0
4399
+ }
4400
+ };
4401
+ function ConversationList({
4402
+ conversations,
4403
+ selectedId,
4404
+ isLoading,
4405
+ error,
4406
+ onSelect,
4407
+ title = "Conversations",
4408
+ className
4409
+ }) {
4410
+ const [query, setQuery] = useState("");
4411
+ const [showUnreadOnly, setShowUnreadOnly] = useState(false);
4412
+ const filtered = useMemo(() => {
4413
+ let result = conversations;
4414
+ if (showUnreadOnly) {
4415
+ result = result.filter((c) => c.unreadCount > 0);
4416
+ }
4417
+ const q = query.trim().toLowerCase();
4418
+ if (q) {
4419
+ result = result.filter((c) => {
4420
+ const patient = c.patientSnapshot?.patientName?.toLowerCase() || "";
4421
+ const name = c.name.toLowerCase();
4422
+ return name.includes(q) || patient.includes(q);
4423
+ });
4424
+ }
4425
+ return result;
4426
+ }, [conversations, query, showUnreadOnly]);
4427
+ const totalUnread = conversations.reduce((sum, c) => sum + c.unreadCount, 0);
4428
+ return /* @__PURE__ */ jsxs("div", { className, style: styles15.container, children: [
4429
+ /* @__PURE__ */ jsxs("div", { style: styles15.header, children: [
4430
+ /* @__PURE__ */ jsxs("div", { style: styles15.titleRow, children: [
4431
+ /* @__PURE__ */ jsx("h3", { style: styles15.title, children: title }),
4432
+ totalUnread > 0 && /* @__PURE__ */ jsx("span", { style: styles15.totalBadge, children: totalUnread })
4433
+ ] }),
4434
+ /* @__PURE__ */ jsx("div", { style: styles15.searchRow, children: /* @__PURE__ */ jsx(
4435
+ "input",
4436
+ {
4437
+ type: "text",
4438
+ value: query,
4439
+ onChange: (e) => setQuery(e.target.value),
4440
+ placeholder: "Search patients or channels...",
4441
+ style: styles15.searchInput
4442
+ }
4443
+ ) }),
4444
+ /* @__PURE__ */ jsxs("div", { style: styles15.filterRow, children: [
4445
+ /* @__PURE__ */ jsx(
4446
+ "button",
4447
+ {
4448
+ onClick: () => setShowUnreadOnly(false),
4449
+ style: {
4450
+ ...styles15.filterButton,
4451
+ ...!showUnreadOnly ? styles15.filterButtonActive : {}
4452
+ },
4453
+ type: "button",
4454
+ children: "All"
4455
+ }
4456
+ ),
4457
+ /* @__PURE__ */ jsxs(
4458
+ "button",
4459
+ {
4460
+ onClick: () => setShowUnreadOnly(true),
4461
+ style: {
4462
+ ...styles15.filterButton,
4463
+ ...showUnreadOnly ? styles15.filterButtonActive : {}
4464
+ },
4465
+ type: "button",
4466
+ children: [
4467
+ "Unread ",
4468
+ totalUnread > 0 && `(${totalUnread})`
4469
+ ]
4470
+ }
4471
+ )
4472
+ ] })
4473
+ ] }),
4474
+ /* @__PURE__ */ jsxs("div", { style: styles15.list, children: [
4475
+ isLoading && /* @__PURE__ */ jsx("div", { style: styles15.state, children: "Loading conversations..." }),
4476
+ error && /* @__PURE__ */ jsx("div", { style: styles15.errorState, children: error }),
4477
+ !isLoading && !error && filtered.length === 0 && /* @__PURE__ */ jsx("div", { style: styles15.state, children: query ? "No matching conversations" : "No conversations yet" }),
4478
+ filtered.map((item) => /* @__PURE__ */ jsx(
4479
+ ConversationListItem,
4480
+ {
4481
+ item,
4482
+ isSelected: selectedId === item.id,
4483
+ onClick: () => onSelect(item)
4484
+ },
4485
+ item.id
4486
+ ))
4487
+ ] })
4488
+ ] });
4489
+ }
4490
+ var styles15 = {
4491
+ container: {
4492
+ display: "flex",
4493
+ flexDirection: "column",
4494
+ height: "100%",
4495
+ backgroundColor: "#ffffff",
4496
+ borderRight: "1px solid #e5e7eb"
4497
+ },
4498
+ header: {
4499
+ padding: "12px",
4500
+ borderBottom: "1px solid #e5e7eb",
4501
+ backgroundColor: "#f9fafb",
4502
+ flexShrink: 0
4503
+ },
4504
+ titleRow: {
4505
+ display: "flex",
4506
+ alignItems: "center",
4507
+ gap: "8px",
4508
+ marginBottom: "8px"
4509
+ },
4510
+ title: {
4511
+ margin: 0,
4512
+ fontSize: "15px",
4513
+ fontWeight: 600,
4514
+ color: "#111827"
4515
+ },
4516
+ totalBadge: {
4517
+ minWidth: "20px",
4518
+ height: "20px",
4519
+ padding: "0 6px",
4520
+ fontSize: "11px",
4521
+ fontWeight: 600,
4522
+ color: "#ffffff",
4523
+ backgroundColor: "#2563eb",
4524
+ borderRadius: "10px",
4525
+ display: "flex",
4526
+ alignItems: "center",
4527
+ justifyContent: "center"
4528
+ },
4529
+ searchRow: {
4530
+ marginBottom: "8px"
4531
+ },
4532
+ searchInput: {
4533
+ width: "100%",
4534
+ padding: "6px 10px",
4535
+ fontSize: "13px",
4536
+ border: "1px solid #e5e7eb",
4537
+ borderRadius: "6px",
4538
+ outline: "none",
4539
+ fontFamily: "inherit"
4540
+ },
4541
+ filterRow: {
4542
+ display: "flex",
4543
+ gap: "4px"
4544
+ },
4545
+ filterButton: {
4546
+ flex: 1,
4547
+ padding: "5px 8px",
4548
+ fontSize: "12px",
4549
+ color: "#6b7280",
4550
+ backgroundColor: "#ffffff",
4551
+ border: "1px solid #e5e7eb",
4552
+ borderRadius: "6px",
4553
+ cursor: "pointer"
4554
+ },
4555
+ filterButtonActive: {
4556
+ color: "#ffffff",
4557
+ backgroundColor: "#2563eb",
4558
+ borderColor: "#2563eb",
4559
+ fontWeight: 500
4560
+ },
4561
+ list: {
4562
+ flex: 1,
4563
+ overflowY: "auto"
4564
+ },
4565
+ state: {
4566
+ padding: "24px 16px",
4567
+ fontSize: "13px",
4568
+ color: "#9ca3af",
4569
+ textAlign: "center"
4570
+ },
4571
+ errorState: {
4572
+ padding: "16px",
4573
+ fontSize: "13px",
4574
+ color: "#dc2626",
4575
+ backgroundColor: "#fef2f2",
4576
+ margin: "12px",
4577
+ borderRadius: "6px",
4578
+ textAlign: "center"
4579
+ }
4580
+ };
4581
+ function CollabInbox({
4582
+ initialConversationId,
4583
+ onSelectConversation,
4584
+ title,
4585
+ className,
4586
+ style
4587
+ }) {
4588
+ const { conversations, isLoading, error } = useConversationList();
4589
+ const [selectedId, setSelectedId] = useState(initialConversationId ?? null);
4590
+ const selected = conversations.find((c) => c.id === selectedId) || null;
4591
+ const handleSelect = (item) => {
4592
+ setSelectedId(item.id);
4593
+ onSelectConversation?.(item);
4594
+ };
4595
+ return /* @__PURE__ */ jsxs("div", { className, style: { ...styles16.container, ...style }, children: [
4596
+ /* @__PURE__ */ jsx("div", { style: styles16.sidebar, children: /* @__PURE__ */ jsx(
4597
+ ConversationList,
4598
+ {
4599
+ conversations,
4600
+ selectedId,
4601
+ isLoading,
4602
+ error,
4603
+ onSelect: handleSelect,
4604
+ title
4605
+ }
4606
+ ) }),
4607
+ /* @__PURE__ */ jsx("div", { style: styles16.main, children: selected ? /* @__PURE__ */ jsx(
4608
+ CollabPanel,
4609
+ {
4610
+ orderId: selected.orderId,
4611
+ patientData: buildPatientData(selected),
4612
+ showSeenBy: true,
4613
+ style: styles16.panel
4614
+ },
4615
+ selected.id
4616
+ ) : /* @__PURE__ */ jsx(EmptyState, { hasAny: conversations.length > 0 }) })
4617
+ ] });
4618
+ }
4619
+ function buildPatientData(item) {
4620
+ if (item.patientSnapshot) return item.patientSnapshot;
4621
+ return {
4622
+ patientName: item.name,
4623
+ orderId: item.orderId
4624
+ };
4625
+ }
4626
+ function EmptyState({ hasAny }) {
4627
+ return /* @__PURE__ */ jsxs("div", { style: styles16.empty, children: [
4628
+ /* @__PURE__ */ jsx("div", { style: styles16.emptyIcon, children: "\u{1F4AC}" }),
4629
+ /* @__PURE__ */ jsx("h3", { style: styles16.emptyTitle, children: hasAny ? "Select a conversation" : "No conversations yet" }),
4630
+ /* @__PURE__ */ jsx("p", { style: styles16.emptySubtitle, children: hasAny ? "Pick a patient from the list to start collaborating" : "Conversations appear here once you start messaging about a case" })
4631
+ ] });
4632
+ }
4633
+ var styles16 = {
4634
+ container: {
4635
+ display: "flex",
4636
+ height: "100%",
4637
+ width: "100%",
4638
+ backgroundColor: "#ffffff",
4639
+ fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif'
4640
+ },
4641
+ sidebar: {
4642
+ width: "320px",
4643
+ minWidth: "280px",
4644
+ flexShrink: 0,
4645
+ display: "flex",
4646
+ flexDirection: "column"
4647
+ },
4648
+ main: {
4649
+ flex: 1,
4650
+ display: "flex",
4651
+ flexDirection: "column",
4652
+ minWidth: 0,
4653
+ padding: "12px",
4654
+ backgroundColor: "#f3f4f6"
4655
+ },
4656
+ panel: {
4657
+ height: "100%",
4658
+ width: "100%"
4659
+ },
4660
+ empty: {
4661
+ flex: 1,
4662
+ display: "flex",
4663
+ flexDirection: "column",
4664
+ alignItems: "center",
4665
+ justifyContent: "center",
4666
+ padding: "48px 24px",
4667
+ textAlign: "center"
4668
+ },
4669
+ emptyIcon: {
4670
+ fontSize: "48px",
4671
+ marginBottom: "12px"
4672
+ },
4673
+ emptyTitle: {
4674
+ margin: "0 0 6px",
4675
+ fontSize: "16px",
4676
+ fontWeight: 600,
4677
+ color: "#374151"
4678
+ },
4679
+ emptySubtitle: {
4680
+ margin: 0,
4681
+ fontSize: "13px",
4682
+ color: "#6b7280",
4683
+ maxWidth: "320px"
4684
+ }
4685
+ };
4686
+ function useMessages({
4687
+ conversationId,
4688
+ initialLimit = MESSAGES_PAGE_SIZE
4689
+ }) {
4690
+ const { fetchMessages, config } = useCollab();
4691
+ const [messages, setMessages] = useState([]);
4692
+ const [isLoading, setIsLoading] = useState(false);
4693
+ const [hasMore, setHasMore] = useState(true);
4694
+ const [error, setError] = useState(null);
4695
+ const loadedRef = useRef(null);
4696
+ useEffect(() => {
4697
+ if (!conversationId) {
4698
+ setMessages([]);
4699
+ setHasMore(false);
4700
+ loadedRef.current = null;
4701
+ return;
4702
+ }
4703
+ if (loadedRef.current === conversationId) return;
4704
+ let cancelled = false;
4705
+ const loadInitial = async () => {
4706
+ setIsLoading(true);
4707
+ setError(null);
4708
+ try {
4709
+ const initial = await fetchMessages(conversationId, { limit: initialLimit });
4710
+ if (cancelled) return;
4711
+ setMessages(initial);
4712
+ setHasMore(initial.length >= initialLimit);
4713
+ loadedRef.current = conversationId;
4714
+ } catch (err) {
4715
+ if (cancelled) return;
4716
+ const msg = err instanceof Error ? err.message : "Failed to load messages";
4717
+ setError(msg);
4718
+ config.onError?.({
4719
+ code: "LOAD_ERROR",
4720
+ message: "Failed to load messages",
4721
+ details: err
4722
+ });
4723
+ } finally {
4724
+ if (!cancelled) setIsLoading(false);
4725
+ }
4726
+ };
4727
+ loadInitial();
4728
+ return () => {
4729
+ cancelled = true;
4730
+ };
4731
+ }, [conversationId, initialLimit, fetchMessages, config]);
4732
+ const loadMore = useCallback(async () => {
4733
+ if (!conversationId || !hasMore || isLoading) return;
4734
+ const oldestId = messages[0]?.id;
4735
+ setIsLoading(true);
4736
+ try {
4737
+ const older = await fetchMessages(conversationId, {
4738
+ limit: initialLimit,
4739
+ before: oldestId
4740
+ });
4741
+ setMessages((prev) => [...older, ...prev]);
4742
+ setHasMore(older.length >= initialLimit);
4743
+ } catch (err) {
4744
+ config.onError?.({
4745
+ code: "LOAD_ERROR",
4746
+ message: "Failed to load older messages",
4747
+ details: err
4748
+ });
4749
+ } finally {
4750
+ setIsLoading(false);
4751
+ }
4752
+ }, [conversationId, hasMore, isLoading, messages, initialLimit, fetchMessages, config]);
4753
+ const addMessage = useCallback((message) => {
4754
+ setMessages((prev) => [...prev, message]);
4755
+ }, []);
4756
+ const updateMessageReadBy = useCallback((messageId, userId) => {
4757
+ setMessages(
4758
+ (prev) => prev.map(
4759
+ (msg) => msg.id === messageId && !(msg.readBy ?? []).includes(userId) ? { ...msg, readBy: [...msg.readBy ?? [], userId] } : msg
4760
+ )
4761
+ );
4762
+ }, []);
4763
+ const reset = useCallback(() => {
4764
+ setMessages([]);
4765
+ setHasMore(true);
4766
+ setError(null);
4767
+ loadedRef.current = null;
4768
+ }, []);
4769
+ return {
4770
+ messages,
4771
+ isLoading,
4772
+ hasMore,
4773
+ error,
4774
+ loadMore,
4775
+ addMessage,
4776
+ updateMessageReadBy,
4777
+ reset
4778
+ };
4779
+ }
4780
+ function useUnreadCount() {
4781
+ const { socket, totalUnread } = useCollab();
4782
+ const [counts, setCounts] = useState({});
4783
+ useEffect(() => {
4784
+ socket.onUnreadCountUpdate((serverCounts) => {
4785
+ setCounts(serverCounts);
4786
+ });
4787
+ }, [socket]);
4788
+ const getCountForConversation = useCallback(
4789
+ (conversationId) => {
4790
+ return counts[conversationId] ?? 0;
4791
+ },
4792
+ [counts]
4793
+ );
4794
+ const clearForConversation = useCallback((conversationId) => {
4795
+ setCounts((prev) => {
4796
+ const next = { ...prev };
4797
+ delete next[conversationId];
4798
+ return next;
4799
+ });
4800
+ }, []);
4801
+ return {
4802
+ totalUnread,
4803
+ getCountForConversation,
4804
+ clearForConversation
4805
+ };
4806
+ }
4807
+ function usePinnedMessages({
4808
+ conversationId,
4809
+ initial = []
4810
+ }) {
4811
+ const { pinMessage, unpinMessage, fetchPinnedMessages, config } = useCollab();
4812
+ const [pinnedMessages, setPinnedMessages] = useState(initial);
4813
+ const [isLoading, setIsLoading] = useState(false);
4814
+ const [error, setError] = useState(null);
4815
+ const refresh = useCallback(async () => {
4816
+ if (!conversationId) return;
4817
+ setIsLoading(true);
4818
+ setError(null);
4819
+ try {
4820
+ const pinned = await fetchPinnedMessages(conversationId);
4821
+ setPinnedMessages(pinned);
4822
+ } catch (err) {
4823
+ const msg = err instanceof Error ? err.message : "Failed to load pinned messages";
4824
+ setError(msg);
4825
+ } finally {
4826
+ setIsLoading(false);
4827
+ }
4828
+ }, [conversationId, fetchPinnedMessages]);
4829
+ useEffect(() => {
4830
+ if (conversationId && initial.length === 0) {
4831
+ refresh();
4832
+ }
4833
+ }, [conversationId]);
4834
+ const pin = useCallback(
4835
+ async (messageId) => {
4836
+ if (!conversationId) return;
4837
+ setError(null);
4838
+ try {
4839
+ await pinMessage(conversationId, messageId);
4840
+ } catch (err) {
4841
+ const msg = err instanceof Error ? err.message : "Failed to pin message";
4842
+ setError(msg);
4843
+ config.onError?.({
4844
+ code: "PIN_ERROR",
4845
+ message: msg,
4846
+ details: err
4847
+ });
4848
+ }
4849
+ },
4850
+ [conversationId, pinMessage, config]
4851
+ );
4852
+ const unpin = useCallback(
4853
+ async (messageId) => {
4854
+ if (!conversationId) return;
4855
+ setError(null);
4856
+ try {
4857
+ await unpinMessage(conversationId, messageId);
4858
+ } catch (err) {
4859
+ const msg = err instanceof Error ? err.message : "Failed to unpin message";
4860
+ setError(msg);
4861
+ config.onError?.({
4862
+ code: "UNPIN_ERROR",
4863
+ message: msg,
4864
+ details: err
4865
+ });
4866
+ }
4867
+ },
4868
+ [conversationId, unpinMessage, config]
4869
+ );
4870
+ const handlePinned = useCallback((message) => {
4871
+ setPinnedMessages((prev) => {
4872
+ if (prev.some((m) => m.id === message.id)) return prev;
4873
+ return [...prev, message];
4874
+ });
4875
+ }, []);
4876
+ const handleUnpinned = useCallback((messageId) => {
4877
+ setPinnedMessages((prev) => prev.filter((m) => m.id !== messageId));
4878
+ }, []);
4879
+ return {
4880
+ pinnedMessages,
4881
+ isLoading,
4882
+ error,
4883
+ pin,
4884
+ unpin,
4885
+ handlePinned,
4886
+ handleUnpinned,
4887
+ refresh
4888
+ };
4889
+ }
4890
+
4891
+ export { AUDIO_MIME_TYPE, ChannelSettings, CollabInbox, CollabInline, CollabPanel, CollabPopup, CollabProvider, CollabSocket, ConversationList, ConversationListItem, DEEP_LINK_PREFIX, EVENTS, MAX_FILE_SIZE, MAX_PINNED_MESSAGES, MESSAGES_PAGE_SIZE, MESSAGE_TYPES, MessageActionsMenu, MessageBubble, MessageInput, MessageList, ParticipantsList, PatientHeader, PinnedMessagesBar, ReplyPreview, ReplyQuoteBlock, SUPPORTED_IMAGE_TYPES, SeenByIndicator, TYPING_DEBOUNCE_MS, useAudioRecorder, useChannelSettings, useCollab, useConversation, useConversationList, useDeepLinks, useInlineCollab, useMessages, usePinnedMessages, useUnreadCount };
4892
+ //# sourceMappingURL=index.mjs.map
4893
+ //# sourceMappingURL=index.mjs.map