@natoe/colab 0.1.13 → 0.1.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -63,9 +63,29 @@ function toCamelKey(key) {
63
63
  }
64
64
 
65
65
  // src/core/socket.ts
66
+ function normalizeUnreadPayload(payload) {
67
+ if (payload && typeof payload === "object") {
68
+ const obj = payload;
69
+ if (obj.conversation && typeof obj.conversation === "object") {
70
+ return {
71
+ conversation: obj.conversation,
72
+ order: obj.order && typeof obj.order === "object" ? obj.order : {}
73
+ };
74
+ }
75
+ return { conversation: payload, order: {} };
76
+ }
77
+ return { conversation: {}, order: {} };
78
+ }
66
79
  var CollabSocket = class {
67
80
  constructor() {
68
81
  this.socket = null;
82
+ /**
83
+ * Conversation channels keyed by id. Each entry is reference-counted so
84
+ * multiple surfaces (e.g. inline chat + expanded panel mounted at once
85
+ * for the same conversation) can coexist without one's `leaveConversation`
86
+ * tearing the channel out from under the other. See `joinConversation`
87
+ * and the returned `ChannelSubscription.release`.
88
+ */
69
89
  this.channels = /* @__PURE__ */ new Map();
70
90
  this.presences = /* @__PURE__ */ new Map();
71
91
  this.userChannel = null;
@@ -121,7 +141,7 @@ var CollabSocket = class {
121
141
  if (!this.socket || !this.config) return;
122
142
  this.userChannel = this.socket.channel("user_notifications", {});
123
143
  this.userChannel.on("unread_update", (payload) => {
124
- this.onUnreadUpdate?.(payload);
144
+ this.onUnreadUpdate?.(normalizeUnreadPayload(payload));
125
145
  });
126
146
  this.userChannel.join().receive("ok", () => {
127
147
  }).receive("error", (reason) => {
@@ -132,63 +152,91 @@ var CollabSocket = class {
132
152
  });
133
153
  });
134
154
  }
135
- /** Register callback for unread count changes */
155
+ /** Register callback for unread count changes. The callback receives
156
+ * the normalised {@link UnreadCounts} shape regardless of which
157
+ * payload format the backend pushed. */
136
158
  onUnreadCountUpdate(callback) {
137
159
  this.onUnreadUpdate = callback;
138
160
  }
139
- /** Join a conversation channel and subscribe to events */
161
+ /**
162
+ * Join a conversation channel and subscribe to events.
163
+ *
164
+ * Reference-counted: multiple callers can join the same conversation
165
+ * (e.g. inline preview + expanded panel mounted side-by-side). Each
166
+ * call binds its own listeners and gets back a `ChannelSubscription`.
167
+ * The underlying channel only `.leave()`s the server when the LAST
168
+ * subscriber calls `release()`.
169
+ */
140
170
  joinConversation(conversationId, callbacks) {
141
171
  if (!this.socket) return null;
142
- if (this.channels.has(conversationId)) {
143
- return this.channels.get(conversationId);
172
+ let entry = this.channels.get(conversationId);
173
+ if (!entry) {
174
+ const channel2 = this.socket.channel(`conversation:${conversationId}`, {});
175
+ channel2.join().receive("ok", () => {
176
+ }).receive("error", (reason) => {
177
+ this.config?.onError?.({
178
+ code: "CHANNEL_JOIN_ERROR",
179
+ message: `Failed to join conversation ${conversationId}`,
180
+ details: reason
181
+ });
182
+ });
183
+ entry = { channel: channel2, subscribers: 0, presence: null };
184
+ this.channels.set(conversationId, entry);
144
185
  }
145
- const channel = this.socket.channel(`conversation:${conversationId}`, {});
186
+ const channel = entry.channel;
187
+ const refs = [];
188
+ const bind = (event, fn) => {
189
+ const ref = channel.on(event, fn);
190
+ refs.push({ event, ref });
191
+ };
146
192
  if (callbacks.onMessage) {
147
- channel.on(EVENTS.MESSAGE_NEW, (payload) => {
193
+ bind(EVENTS.MESSAGE_NEW, (payload) => {
148
194
  callbacks.onMessage(snakeToCamel(payload));
149
195
  });
150
196
  }
151
197
  if (callbacks.onTyping) {
152
- channel.on(EVENTS.USER_TYPING, (payload) => {
198
+ bind(EVENTS.USER_TYPING, (payload) => {
153
199
  callbacks.onTyping(snakeToCamel(payload));
154
200
  });
155
201
  }
156
202
  if (callbacks.onUserJoined) {
157
- channel.on(EVENTS.USER_JOINED, (payload) => {
203
+ bind(EVENTS.USER_JOINED, (payload) => {
158
204
  callbacks.onUserJoined(snakeToCamel(payload));
159
205
  });
160
206
  }
161
207
  if (callbacks.onUserLeft) {
162
- channel.on(EVENTS.USER_LEFT, (payload) => {
208
+ bind(EVENTS.USER_LEFT, (payload) => {
163
209
  callbacks.onUserLeft(snakeToCamel(payload));
164
210
  });
165
211
  }
166
212
  if (callbacks.onChannelUpdated) {
167
- channel.on(EVENTS.CHANNEL_UPDATED, (payload) => {
213
+ bind(EVENTS.CHANNEL_UPDATED, (payload) => {
168
214
  callbacks.onChannelUpdated(snakeToCamel(payload));
169
215
  });
170
216
  }
171
217
  if (callbacks.onChannelDeleted) {
172
- channel.on(EVENTS.CHANNEL_DELETED, () => {
218
+ bind(EVENTS.CHANNEL_DELETED, () => {
173
219
  callbacks.onChannelDeleted();
174
220
  });
175
221
  }
176
222
  if (callbacks.onMessageRead) {
177
- channel.on(EVENTS.MESSAGE_READ, (payload) => {
178
- callbacks.onMessageRead(snakeToCamel(payload));
223
+ bind(EVENTS.MESSAGE_READ, (payload) => {
224
+ callbacks.onMessageRead(
225
+ snakeToCamel(payload)
226
+ );
179
227
  });
180
228
  }
181
229
  if (callbacks.onMessagePinned) {
182
- channel.on(EVENTS.MESSAGE_PINNED, (payload) => {
230
+ bind(EVENTS.MESSAGE_PINNED, (payload) => {
183
231
  callbacks.onMessagePinned(snakeToCamel(payload));
184
232
  });
185
233
  }
186
234
  if (callbacks.onMessageUnpinned) {
187
- channel.on(EVENTS.MESSAGE_UNPINNED, (payload) => {
235
+ bind(EVENTS.MESSAGE_UNPINNED, (payload) => {
188
236
  callbacks.onMessageUnpinned(snakeToCamel(payload));
189
237
  });
190
238
  }
191
- if (callbacks.onPresence) {
239
+ if (callbacks.onPresence && !entry.presence) {
192
240
  const presence = new phoenix.Presence(channel);
193
241
  presence.onSync(() => {
194
242
  const online = {};
@@ -197,27 +245,45 @@ var CollabSocket = class {
197
245
  });
198
246
  callbacks.onPresence(online);
199
247
  });
248
+ entry.presence = presence;
200
249
  this.presences.set(conversationId, presence);
201
250
  }
202
- channel.join().receive("ok", () => {
203
- }).receive("error", (reason) => {
204
- this.config?.onError?.({
205
- code: "CHANNEL_JOIN_ERROR",
206
- message: `Failed to join conversation ${conversationId}`,
207
- details: reason
208
- });
209
- });
210
- this.channels.set(conversationId, channel);
211
- return channel;
251
+ entry.subscribers += 1;
252
+ let released = false;
253
+ const release = () => {
254
+ if (released) return;
255
+ released = true;
256
+ const current = this.channels.get(conversationId);
257
+ if (!current) return;
258
+ for (const { event, ref } of refs) {
259
+ current.channel.off(event, ref);
260
+ }
261
+ current.subscribers -= 1;
262
+ if (current.subscribers <= 0) {
263
+ current.channel.leave();
264
+ this.channels.delete(conversationId);
265
+ this.presences.delete(conversationId);
266
+ }
267
+ };
268
+ return { channel, release };
212
269
  }
213
- /** Leave a conversation channel */
214
- leaveConversation(conversationId) {
215
- const channel = this.channels.get(conversationId);
216
- if (channel) {
217
- channel.leave();
218
- this.channels.delete(conversationId);
219
- this.presences.delete(conversationId);
220
- }
270
+ /**
271
+ * @deprecated Use the `release()` method returned by `joinConversation()`.
272
+ * Kept as a no-op so older callers don't throw — but it cannot identify
273
+ * which subscriber should leave, so it silently does nothing. Any code
274
+ * still calling this will leak listeners and prevent the channel from
275
+ * ever being torn down. Migrate to the subscription handle.
276
+ */
277
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
278
+ leaveConversation(_conversationId) {
279
+ }
280
+ /**
281
+ * Look up the underlying Phoenix Channel for a conversation, if any
282
+ * subscriber is still holding it. All send/push paths go through this
283
+ * helper so the refcounted entry shape is contained to joinConversation.
284
+ */
285
+ getChannel(conversationId) {
286
+ return this.channels.get(conversationId)?.channel ?? null;
221
287
  }
222
288
  /** Send a message to a conversation.
223
289
  *
@@ -229,7 +295,7 @@ var CollabSocket = class {
229
295
  */
230
296
  sendMessage(conversationId, payload) {
231
297
  return new Promise((resolve, reject) => {
232
- const channel = this.channels.get(conversationId);
298
+ const channel = this.getChannel(conversationId);
233
299
  if (!channel) {
234
300
  reject({ code: "NOT_JOINED", message: "Not joined to this conversation" });
235
301
  return;
@@ -252,7 +318,7 @@ var CollabSocket = class {
252
318
  }
253
319
  /** Broadcast typing indicator */
254
320
  sendTyping(conversationId, isTyping) {
255
- const channel = this.channels.get(conversationId);
321
+ const channel = this.getChannel(conversationId);
256
322
  channel?.push(EVENTS.USER_TYPING, {
257
323
  userId: this.config?.userId,
258
324
  userName: this.config?.userName,
@@ -261,7 +327,7 @@ var CollabSocket = class {
261
327
  }
262
328
  /** Mark messages as read */
263
329
  markAsRead(conversationId, messageId) {
264
- const channel = this.channels.get(conversationId);
330
+ const channel = this.getChannel(conversationId);
265
331
  channel?.push(EVENTS.MESSAGE_READ, {
266
332
  messageId,
267
333
  userId: this.config?.userId
@@ -295,7 +361,7 @@ var CollabSocket = class {
295
361
  /** Generic push with promise wrapper */
296
362
  channelPush(conversationId, event, payload) {
297
363
  return new Promise((resolve, reject) => {
298
- const channel = this.channels.get(conversationId);
364
+ const channel = this.getChannel(conversationId);
299
365
  if (!channel) {
300
366
  reject({ code: "NOT_JOINED", message: "Not joined to this conversation" });
301
367
  return;
@@ -312,7 +378,7 @@ var CollabSocket = class {
312
378
  }
313
379
  /** Disconnect socket and leave all channels */
314
380
  disconnect() {
315
- this.channels.forEach((channel) => channel.leave());
381
+ this.channels.forEach((entry) => entry.channel.leave());
316
382
  this.channels.clear();
317
383
  this.presences.clear();
318
384
  this.userChannel?.leave();
@@ -367,7 +433,21 @@ var THEME_VAR = {
367
433
  dangerBg: "--natoe-colab-danger-bg",
368
434
  dangerBorder: "--natoe-colab-danger-border",
369
435
  dangerFg: "--natoe-colab-danger-fg",
370
- fontStack: "--natoe-colab-font-stack"
436
+ fontStack: "--natoe-colab-font-stack",
437
+ // Neutral surface palette — promoted to CSS vars so consumers (e.g.
438
+ // CollabPanel themeMode='dark') can flip the whole grayscale at a subtree
439
+ // level without re-themeing every component.
440
+ white: "--natoe-colab-white",
441
+ neutral50: "--natoe-colab-neutral-50",
442
+ neutral100: "--natoe-colab-neutral-100",
443
+ neutral200: "--natoe-colab-neutral-200",
444
+ neutral300: "--natoe-colab-neutral-300",
445
+ neutral400: "--natoe-colab-neutral-400",
446
+ neutral500: "--natoe-colab-neutral-500",
447
+ neutral600: "--natoe-colab-neutral-600",
448
+ neutral700: "--natoe-colab-neutral-700",
449
+ neutral800: "--natoe-colab-neutral-800",
450
+ neutral900: "--natoe-colab-neutral-900"
371
451
  };
372
452
  var THEME_DEFAULTS = {
373
453
  primary: "#2563eb",
@@ -380,18 +460,9 @@ var THEME_DEFAULTS = {
380
460
  dangerBg: "#fef2f2",
381
461
  dangerBorder: "#fecaca",
382
462
  dangerFg: "#b91c1c",
383
- fontStack: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif'
384
- };
385
- var cssVar = (name, fallback) => `var(${name}, ${fallback})`;
386
- var COLOR = {
387
- // Brand — themeable
388
- primary: cssVar(THEME_VAR.primary, THEME_DEFAULTS.primary),
389
- primaryHover: cssVar(THEME_VAR.primaryHover, THEME_DEFAULTS.primaryHover),
390
- /** Tinted surface for chips/cards on brand-coloured states. */
391
- primaryBg: cssVar(THEME_VAR.primaryBg, THEME_DEFAULTS.primaryBg),
392
- primaryFg: cssVar(THEME_VAR.primaryFg, THEME_DEFAULTS.primaryFg),
393
- // Neutral grayscale — not themeable, taken from Tailwind's zinc-leaning
394
- // slate to match the package's existing tonal balance.
463
+ fontStack: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
464
+ // Neutral grayscale — these defaults are the same hex literals the
465
+ // package shipped with; they're now overridable per-subtree.
395
466
  white: "#ffffff",
396
467
  neutral50: "#f9fafb",
397
468
  neutral100: "#f3f4f6",
@@ -402,7 +473,32 @@ var COLOR = {
402
473
  neutral600: "#4b5563",
403
474
  neutral700: "#374151",
404
475
  neutral800: "#1f2937",
405
- neutral900: "#111827",
476
+ neutral900: "#111827"
477
+ };
478
+ var cssVar = (name, fallback) => `var(${name}, ${fallback})`;
479
+ var COLOR = {
480
+ // Brand — themeable
481
+ primary: cssVar(THEME_VAR.primary, THEME_DEFAULTS.primary),
482
+ primaryHover: cssVar(THEME_VAR.primaryHover, THEME_DEFAULTS.primaryHover),
483
+ /** Tinted surface for chips/cards on brand-coloured states. */
484
+ primaryBg: cssVar(THEME_VAR.primaryBg, THEME_DEFAULTS.primaryBg),
485
+ primaryFg: cssVar(THEME_VAR.primaryFg, THEME_DEFAULTS.primaryFg),
486
+ // Neutral grayscale — themeable via CSS variables. Light-mode defaults
487
+ // taken from Tailwind's zinc-leaning slate to match the package's
488
+ // existing tonal balance; a host can override the entire palette by
489
+ // setting --natoe-colab-* values on any ancestor element (used by
490
+ // CollabPanel themeMode='dark' for the viewer's left-panel surface).
491
+ white: cssVar(THEME_VAR.white, THEME_DEFAULTS.white),
492
+ neutral50: cssVar(THEME_VAR.neutral50, THEME_DEFAULTS.neutral50),
493
+ neutral100: cssVar(THEME_VAR.neutral100, THEME_DEFAULTS.neutral100),
494
+ neutral200: cssVar(THEME_VAR.neutral200, THEME_DEFAULTS.neutral200),
495
+ neutral300: cssVar(THEME_VAR.neutral300, THEME_DEFAULTS.neutral300),
496
+ neutral400: cssVar(THEME_VAR.neutral400, THEME_DEFAULTS.neutral400),
497
+ neutral500: cssVar(THEME_VAR.neutral500, THEME_DEFAULTS.neutral500),
498
+ neutral600: cssVar(THEME_VAR.neutral600, THEME_DEFAULTS.neutral600),
499
+ neutral700: cssVar(THEME_VAR.neutral700, THEME_DEFAULTS.neutral700),
500
+ neutral800: cssVar(THEME_VAR.neutral800, THEME_DEFAULTS.neutral800),
501
+ neutral900: cssVar(THEME_VAR.neutral900, THEME_DEFAULTS.neutral900),
406
502
  slate800: "#1e293b",
407
503
  // Semantic — themeable
408
504
  success: cssVar(THEME_VAR.success, THEME_DEFAULTS.success),
@@ -502,6 +598,7 @@ function CollabProvider({ config, apiBaseUrl, children }) {
502
598
  return new CollabSocket();
503
599
  });
504
600
  const [unreadCounts, setUnreadCounts] = React4.useState({});
601
+ const [unreadCountsByOrder, setUnreadCountsByOrder] = React4.useState({});
505
602
  const pendingOrderIds = React4.useRef(/* @__PURE__ */ new Set());
506
603
  const pendingResolvers = React4.useRef(/* @__PURE__ */ new Map());
507
604
  const previewCache = React4.useRef(/* @__PURE__ */ new Map());
@@ -517,7 +614,8 @@ function CollabProvider({ config, apiBaseUrl, children }) {
517
614
  setSocket(s);
518
615
  }
519
616
  s.onUnreadCountUpdate((counts) => {
520
- setUnreadCounts(counts);
617
+ setUnreadCounts(counts.conversation);
618
+ setUnreadCountsByOrder(counts.order);
521
619
  previewCache.current.clear();
522
620
  });
523
621
  if (!s.isConnected()) {
@@ -725,6 +823,7 @@ function CollabProvider({ config, apiBaseUrl, children }) {
725
823
  apiBaseUrl,
726
824
  totalUnread,
727
825
  unreadCounts,
826
+ unreadCountsByOrder,
728
827
  requestPreview,
729
828
  invalidatePreview,
730
829
  fetchMessages,
@@ -781,6 +880,7 @@ function useConversation({
781
880
  const [isConnected, setIsConnected] = React4.useState(false);
782
881
  const [replyTo, setReplyTo] = React4.useState(null);
783
882
  const joinedConversationId = React4.useRef(null);
883
+ const channelSubscription = React4.useRef(null);
784
884
  const typingTimers = React4.useRef(/* @__PURE__ */ new Map());
785
885
  const ensureConversationInFlight = React4.useRef(null);
786
886
  const markedReadIdsRef = React4.useRef(/* @__PURE__ */ new Set());
@@ -794,7 +894,9 @@ function useConversation({
794
894
  const joinChannel = React4.useCallback(
795
895
  (conv) => {
796
896
  if (joinedConversationId.current === conv.id) return;
797
- socket.joinConversation(conv.id, {
897
+ channelSubscription.current?.release();
898
+ channelSubscription.current = null;
899
+ const subscription = socket.joinConversation(conv.id, {
798
900
  // Dedupe by id so an optimistic message (added client-side on send)
799
901
  // doesn't double up when the server's broadcast arrives.
800
902
  onMessage: (msg) => setMessages((prev) => {
@@ -844,6 +946,8 @@ function useConversation({
844
946
  onChannelDeleted: () => {
845
947
  setConversation(null);
846
948
  setMessages([]);
949
+ channelSubscription.current?.release();
950
+ channelSubscription.current = null;
847
951
  joinedConversationId.current = null;
848
952
  setIsConnected(false);
849
953
  },
@@ -869,6 +973,7 @@ function useConversation({
869
973
  );
870
974
  }
871
975
  });
976
+ channelSubscription.current = subscription;
872
977
  joinedConversationId.current = conv.id;
873
978
  setIsConnected(true);
874
979
  },
@@ -929,10 +1034,9 @@ function useConversation({
929
1034
  init();
930
1035
  return () => {
931
1036
  cancelled = true;
932
- if (joinedConversationId.current) {
933
- socket.leaveConversation(joinedConversationId.current);
934
- joinedConversationId.current = null;
935
- }
1037
+ channelSubscription.current?.release();
1038
+ channelSubscription.current = null;
1039
+ joinedConversationId.current = null;
936
1040
  typingTimers.current.forEach((timer) => clearTimeout(timer));
937
1041
  typingTimers.current.clear();
938
1042
  markedReadIdsRef.current.clear();
@@ -1348,17 +1452,26 @@ function DicomIcon({ size = 18, color = "currentColor" }) {
1348
1452
  }
1349
1453
  );
1350
1454
  }
1351
- function SettingsIcon({ size = 18, color = "currentColor" }) {
1352
- return /* @__PURE__ */ jsxRuntime.jsx(
1455
+ function PeopleIcon({ size = 18, color = "currentColor" }) {
1456
+ return /* @__PURE__ */ jsxRuntime.jsxs(
1353
1457
  "svg",
1354
1458
  {
1355
1459
  xmlns: "http://www.w3.org/2000/svg",
1356
1460
  width: size,
1357
1461
  height: size,
1358
1462
  viewBox: "0 0 24 24",
1359
- fill: color,
1463
+ fill: "none",
1464
+ stroke: color,
1465
+ strokeWidth: "2",
1466
+ strokeLinecap: "round",
1467
+ strokeLinejoin: "round",
1360
1468
  "aria-hidden": "true",
1361
- children: /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58a.49.49 0 0 0 .12-.61l-1.92-3.32a.488.488 0 0 0-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54a.484.484 0 0 0-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.09.63-.09.94s.02.64.07.94l-2.03 1.58a.49.49 0 0 0-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z" })
1469
+ children: [
1470
+ /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" }),
1471
+ /* @__PURE__ */ jsxRuntime.jsx("circle", { cx: "9", cy: "7", r: "4" }),
1472
+ /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M22 21v-2a4 4 0 0 0-3-3.87" }),
1473
+ /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M16 3.13a4 4 0 0 1 0 7.75" })
1474
+ ]
1362
1475
  }
1363
1476
  );
1364
1477
  }
@@ -1383,6 +1496,7 @@ function resolveDisplayName(patientData, displayName) {
1383
1496
  }
1384
1497
  function PatientHeader({
1385
1498
  patientData,
1499
+ participants,
1386
1500
  onOpenDicom,
1387
1501
  onOpenSettings,
1388
1502
  onBack,
@@ -1448,15 +1562,18 @@ function PatientHeader({
1448
1562
  ]
1449
1563
  }
1450
1564
  ),
1451
- onOpenSettings && /* @__PURE__ */ jsxRuntime.jsx(
1565
+ onOpenSettings && /* @__PURE__ */ jsxRuntime.jsxs(
1452
1566
  "button",
1453
1567
  {
1454
1568
  onClick: onOpenSettings,
1455
1569
  style: styles.settingsIconButton,
1456
1570
  type: "button",
1457
- "aria-label": "Open channel settings",
1458
- title: "Settings",
1459
- children: /* @__PURE__ */ jsxRuntime.jsx(SettingsIcon, { size: 18, color: COLOR.neutral600 })
1571
+ "aria-label": `Open channel settings (${participants.length} participants)`,
1572
+ title: "Channel participants",
1573
+ children: [
1574
+ /* @__PURE__ */ jsxRuntime.jsx(PeopleIcon, { size: 18, color: COLOR.neutral600 }),
1575
+ /* @__PURE__ */ jsxRuntime.jsx("span", { style: styles.participantCount, children: participants.length })
1576
+ ]
1460
1577
  }
1461
1578
  )
1462
1579
  ] })
@@ -1564,20 +1681,28 @@ var styles = {
1564
1681
  borderRadius: RADIUS.lg,
1565
1682
  cursor: "pointer"
1566
1683
  },
1567
- // Settings is icon-only (40x40 tile) since it's a secondary action
1568
- // and View DICOM already carries a label.
1684
+ // Channel-info button: people icon + participant count. Click opens the
1685
+ // channel settings overlay (kept on the same handler as before so hosts
1686
+ // don't have to re-wire — the affordance just looks like a "members"
1687
+ // pill now instead of a gear).
1569
1688
  settingsIconButton: {
1570
- width: SIZE.control,
1571
- height: SIZE.control,
1689
+ minHeight: SIZE.control,
1572
1690
  display: "inline-flex",
1573
1691
  alignItems: "center",
1574
- justifyContent: "center",
1692
+ gap: SPACE.S2,
1693
+ padding: `0 ${SPACE.S3}`,
1575
1694
  backgroundColor: COLOR.white,
1576
1695
  border: `1px solid ${COLOR.neutral200}`,
1577
1696
  borderRadius: RADIUS.lg,
1578
1697
  color: COLOR.neutral600,
1579
1698
  cursor: "pointer",
1580
1699
  flexShrink: 0
1700
+ },
1701
+ participantCount: {
1702
+ fontSize: FONT_SIZE.sm,
1703
+ fontWeight: FONT_WEIGHT.semibold,
1704
+ color: COLOR.neutral700,
1705
+ fontVariantNumeric: "tabular-nums"
1581
1706
  }
1582
1707
  };
1583
1708
  function ReplyQuoteBlock({
@@ -3998,6 +4123,20 @@ var styles12 = {
3998
4123
  }
3999
4124
  };
4000
4125
  ensureGlobalStyles();
4126
+ var DARK_THEME_OVERRIDES = {
4127
+ ["--natoe-colab-white"]: "#0b0b0c",
4128
+ ["--natoe-colab-neutral-50"]: "#18181c",
4129
+ ["--natoe-colab-neutral-100"]: "#1f1f23",
4130
+ ["--natoe-colab-neutral-200"]: "#2a2a2e",
4131
+ ["--natoe-colab-neutral-300"]: "#3f3f44",
4132
+ ["--natoe-colab-neutral-400"]: "#6b7280",
4133
+ ["--natoe-colab-neutral-500"]: "#9ca3af",
4134
+ ["--natoe-colab-neutral-600"]: "#cbd5e1",
4135
+ ["--natoe-colab-neutral-700"]: "#e5e7eb",
4136
+ ["--natoe-colab-neutral-800"]: "#f3f4f6",
4137
+ ["--natoe-colab-neutral-900"]: "#ffffff",
4138
+ ["--natoe-colab-primary-bg"]: "rgba(37, 99, 235, 0.18)"
4139
+ };
4001
4140
  function CollabPanel({
4002
4141
  orderId,
4003
4142
  patientData,
@@ -4006,6 +4145,7 @@ function CollabPanel({
4006
4145
  onBack,
4007
4146
  hidePatientName = false,
4008
4147
  onConversationChange,
4148
+ themeMode = "light",
4009
4149
  className,
4010
4150
  style
4011
4151
  }) {
@@ -4081,16 +4221,21 @@ function CollabPanel({
4081
4221
  }
4082
4222
  };
4083
4223
  const pinDisabled = pinnedMessages.length >= MAX_PINNED_MESSAGES;
4224
+ const containerStyle = {
4225
+ ...panelStyles.container,
4226
+ ...themeMode === "dark" ? DARK_THEME_OVERRIDES : {},
4227
+ ...style
4228
+ };
4084
4229
  if (error) {
4085
- return /* @__PURE__ */ jsxRuntime.jsx("div", { className: [ROOT_CLASS, className].filter(Boolean).join(" "), style: { ...panelStyles.container, ...style }, children: /* @__PURE__ */ jsxRuntime.jsxs("div", { style: panelStyles.errorState, children: [
4230
+ return /* @__PURE__ */ jsxRuntime.jsx("div", { className: [ROOT_CLASS, className].filter(Boolean).join(" "), style: containerStyle, children: /* @__PURE__ */ jsxRuntime.jsxs("div", { style: panelStyles.errorState, children: [
4086
4231
  /* @__PURE__ */ jsxRuntime.jsx("p", { style: panelStyles.errorTitle, children: "Unable to load conversation" }),
4087
4232
  /* @__PURE__ */ jsxRuntime.jsx("p", { style: panelStyles.errorMessage, children: error })
4088
4233
  ] }) });
4089
4234
  }
4090
4235
  if (isLoading && !conversation) {
4091
- return /* @__PURE__ */ jsxRuntime.jsx("div", { className: [ROOT_CLASS, className].filter(Boolean).join(" "), style: { ...panelStyles.container, ...style }, children: /* @__PURE__ */ jsxRuntime.jsx("div", { style: panelStyles.loadingState, children: "Loading conversation..." }) });
4236
+ return /* @__PURE__ */ jsxRuntime.jsx("div", { className: [ROOT_CLASS, className].filter(Boolean).join(" "), style: containerStyle, children: /* @__PURE__ */ jsxRuntime.jsx("div", { style: panelStyles.loadingState, children: "Loading conversation..." }) });
4092
4237
  }
4093
- return /* @__PURE__ */ jsxRuntime.jsxs("div", { className, style: { ...panelStyles.container, ...style }, children: [
4238
+ return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: [ROOT_CLASS, className].filter(Boolean).join(" "), style: containerStyle, children: [
4094
4239
  showSettings && conversation ? /* @__PURE__ */ jsxRuntime.jsx(
4095
4240
  ChannelSettings,
4096
4241
  {
@@ -4526,6 +4671,7 @@ function useInlineCollab({
4526
4671
  const elementRef = React4.useRef(null);
4527
4672
  const observerRef = React4.useRef(null);
4528
4673
  const subscribedConversationIdRef = React4.useRef(null);
4674
+ const channelSubscriptionRef = React4.useRef(null);
4529
4675
  const trimToLimit = React4.useCallback(
4530
4676
  (msgs) => msgs.length > messageLimit ? msgs.slice(msgs.length - messageLimit) : msgs,
4531
4677
  [messageLimit]
@@ -4563,7 +4709,9 @@ function useInlineCollab({
4563
4709
  const subscribe = React4.useCallback(
4564
4710
  (conversationId) => {
4565
4711
  if (subscribedConversationIdRef.current === conversationId) return;
4566
- socket.joinConversation(conversationId, {
4712
+ channelSubscriptionRef.current?.release();
4713
+ channelSubscriptionRef.current = null;
4714
+ const subscription = socket.joinConversation(conversationId, {
4567
4715
  onMessage: (msg) => {
4568
4716
  setMessages((prev) => trimToLimit([...prev, msg]));
4569
4717
  if (msg.senderId !== config.userId) {
@@ -4579,19 +4727,18 @@ function useInlineCollab({
4579
4727
  setParticipants((prev) => prev.filter((p) => p.userId !== participant.userId));
4580
4728
  }
4581
4729
  });
4730
+ channelSubscriptionRef.current = subscription;
4582
4731
  subscribedConversationIdRef.current = conversationId;
4583
4732
  setIsSubscribed(true);
4584
4733
  },
4585
4734
  [socket, trimToLimit, config.userId]
4586
4735
  );
4587
4736
  const unsubscribe = React4.useCallback(() => {
4588
- const convId = subscribedConversationIdRef.current;
4589
- if (convId) {
4590
- socket.leaveConversation(convId);
4591
- subscribedConversationIdRef.current = null;
4592
- setIsSubscribed(false);
4593
- }
4594
- }, [socket]);
4737
+ channelSubscriptionRef.current?.release();
4738
+ channelSubscriptionRef.current = null;
4739
+ subscribedConversationIdRef.current = null;
4740
+ setIsSubscribed(false);
4741
+ }, []);
4595
4742
  const containerRef = React4.useCallback(
4596
4743
  (element) => {
4597
4744
  if (observerRef.current) {
@@ -4733,16 +4880,52 @@ function useInlineCollab({
4733
4880
  };
4734
4881
  }
4735
4882
  ensureGlobalStyles();
4883
+ function palette(mode) {
4884
+ if (mode === "dark") {
4885
+ return {
4886
+ bg: "transparent",
4887
+ border: "transparent",
4888
+ previewBorder: "#1f1f23",
4889
+ senderName: "#f3f4f6",
4890
+ messageText: "#cbd5e1",
4891
+ systemText: "#6b7280",
4892
+ inputBg: "#18181c",
4893
+ inputBorder: "#2a2a2e",
4894
+ inputText: "#e5e7eb",
4895
+ audioBg: "rgba(37, 99, 235, 0.18)",
4896
+ audioFg: "#93c5fd",
4897
+ expandBorder: "#2a2a2e",
4898
+ expandColor: "#9ca3af"
4899
+ };
4900
+ }
4901
+ return {
4902
+ bg: COLOR.white,
4903
+ border: COLOR.neutral200,
4904
+ previewBorder: COLOR.neutral100,
4905
+ senderName: COLOR.neutral700,
4906
+ messageText: COLOR.neutral600,
4907
+ systemText: COLOR.neutral400,
4908
+ inputBg: "transparent",
4909
+ inputBorder: COLOR.neutral200,
4910
+ inputText: COLOR.neutral900,
4911
+ audioBg: COLOR.primaryBg,
4912
+ audioFg: COLOR.primary,
4913
+ expandBorder: COLOR.neutral200,
4914
+ expandColor: COLOR.neutral500
4915
+ };
4916
+ }
4736
4917
  function CollabInline({
4737
4918
  orderId,
4738
4919
  patientData,
4739
4920
  participantIds,
4740
4921
  onExpand,
4741
- messageLimit = 5,
4922
+ messageLimit = 1,
4742
4923
  placeholder = "Type a message about this case...",
4924
+ mode = "light",
4743
4925
  className,
4744
4926
  style
4745
4927
  }) {
4928
+ const pal = palette(mode);
4746
4929
  const {
4747
4930
  hasConversation,
4748
4931
  messages,
@@ -4754,11 +4937,17 @@ function CollabInline({
4754
4937
  sendAudioMessage,
4755
4938
  containerRef
4756
4939
  } = useInlineCollab({ orderId, patientData, participantIds, messageLimit });
4940
+ const containerStyle = {
4941
+ ...styles14.container,
4942
+ backgroundColor: pal.bg,
4943
+ borderColor: pal.border,
4944
+ ...style
4945
+ };
4757
4946
  if (isLoading && !hasConversation) {
4758
- return /* @__PURE__ */ jsxRuntime.jsx("div", { className: [ROOT_CLASS, className].filter(Boolean).join(" "), style: { ...styles14.container, ...style }, children: /* @__PURE__ */ jsxRuntime.jsx("div", { style: styles14.loadingState, children: "Loading..." }) });
4947
+ return /* @__PURE__ */ jsxRuntime.jsx("div", { className: [ROOT_CLASS, className].filter(Boolean).join(" "), style: containerStyle, children: /* @__PURE__ */ jsxRuntime.jsx("div", { style: styles14.loadingState, children: "Loading..." }) });
4759
4948
  }
4760
- return /* @__PURE__ */ jsxRuntime.jsxs("div", { ref: containerRef, className: [ROOT_CLASS, className].filter(Boolean).join(" "), style: { ...styles14.container, ...style }, children: [
4761
- hasConversation && messages.length > 0 && /* @__PURE__ */ jsxRuntime.jsx("div", { style: styles14.preview, children: messages.slice(-messageLimit).map((message) => /* @__PURE__ */ jsxRuntime.jsx(InlineMessageRow, { message }, message.id)) }),
4949
+ return /* @__PURE__ */ jsxRuntime.jsxs("div", { ref: containerRef, className: [ROOT_CLASS, className].filter(Boolean).join(" "), style: containerStyle, children: [
4950
+ hasConversation && messages.length > 0 && /* @__PURE__ */ jsxRuntime.jsx("div", { style: { ...styles14.preview, borderBottomColor: pal.previewBorder }, children: messages.slice(-messageLimit).map((message) => /* @__PURE__ */ jsxRuntime.jsx(InlineMessageRow, { message, pal }, message.id)) }),
4762
4951
  error && /* @__PURE__ */ jsxRuntime.jsx("div", { style: styles14.error, children: error }),
4763
4952
  /* @__PURE__ */ jsxRuntime.jsx(
4764
4953
  InlineInputBar,
@@ -4769,33 +4958,34 @@ function CollabInline({
4769
4958
  unreadCount: hasConversation ? unreadCount : 0,
4770
4959
  isLive: isSubscribed,
4771
4960
  showExpand: hasConversation && !!onExpand,
4772
- onExpand
4961
+ onExpand,
4962
+ pal
4773
4963
  }
4774
4964
  )
4775
4965
  ] });
4776
4966
  }
4777
- function InlineMessageRow({ message }) {
4967
+ function InlineMessageRow({ message, pal }) {
4778
4968
  if (message.type === "system") {
4779
- return /* @__PURE__ */ jsxRuntime.jsx("div", { style: styles14.systemRow, children: /* @__PURE__ */ jsxRuntime.jsx("span", { style: styles14.systemText, children: message.body }) });
4969
+ return /* @__PURE__ */ jsxRuntime.jsx("div", { style: styles14.systemRow, children: /* @__PURE__ */ jsxRuntime.jsx("span", { style: { ...styles14.systemText, color: pal.systemText }, children: message.body }) });
4780
4970
  }
4781
4971
  if (message.type === "audio" && message.mediaUrl) {
4782
4972
  return /* @__PURE__ */ jsxRuntime.jsxs("div", { style: styles14.messageRow, children: [
4783
4973
  /* @__PURE__ */ jsxRuntime.jsx(RoleDot, { role: message.senderRole }),
4784
- /* @__PURE__ */ jsxRuntime.jsxs("span", { style: styles14.senderName, children: [
4974
+ /* @__PURE__ */ jsxRuntime.jsxs("span", { style: { ...styles14.senderName, color: pal.senderName }, children: [
4785
4975
  message.senderName,
4786
4976
  ":"
4787
4977
  ] }),
4788
- /* @__PURE__ */ jsxRuntime.jsx(InlineAudioPlayer, { url: message.mediaUrl, duration: message.mediaDuration })
4978
+ /* @__PURE__ */ jsxRuntime.jsx(InlineAudioPlayer, { url: message.mediaUrl, duration: message.mediaDuration, pal })
4789
4979
  ] });
4790
4980
  }
4791
4981
  const preview = renderMessagePreview(message);
4792
4982
  return /* @__PURE__ */ jsxRuntime.jsxs("div", { style: styles14.messageRow, children: [
4793
4983
  /* @__PURE__ */ jsxRuntime.jsx(RoleDot, { role: message.senderRole }),
4794
- /* @__PURE__ */ jsxRuntime.jsxs("span", { style: styles14.senderName, children: [
4984
+ /* @__PURE__ */ jsxRuntime.jsxs("span", { style: { ...styles14.senderName, color: pal.senderName }, children: [
4795
4985
  message.senderName,
4796
4986
  ":"
4797
4987
  ] }),
4798
- /* @__PURE__ */ jsxRuntime.jsx("span", { style: styles14.messageText, children: preview })
4988
+ /* @__PURE__ */ jsxRuntime.jsx("span", { style: { ...styles14.messageText, color: pal.messageText }, children: preview })
4799
4989
  ] });
4800
4990
  }
4801
4991
  function RoleDot({ role }) {
@@ -4811,7 +5001,11 @@ function RoleDot({ role }) {
4811
5001
  };
4812
5002
  return /* @__PURE__ */ jsxRuntime.jsx("span", { style: { ...styles14.roleDot, backgroundColor: colors[role] } });
4813
5003
  }
4814
- function InlineAudioPlayer({ url, duration }) {
5004
+ function InlineAudioPlayer({
5005
+ url,
5006
+ duration,
5007
+ pal
5008
+ }) {
4815
5009
  const audioRef = React4.useRef(null);
4816
5010
  const [isPlaying, setIsPlaying] = React4.useState(false);
4817
5011
  const [currentTime, setCurrentTime] = React4.useState(0);
@@ -4853,7 +5047,7 @@ function InlineAudioPlayer({ url, duration }) {
4853
5047
  const total = duration ?? 0;
4854
5048
  const remaining = Math.max(0, total - Math.floor(currentTime));
4855
5049
  const displayTime = isPlaying ? remaining : total;
4856
- return /* @__PURE__ */ jsxRuntime.jsxs("span", { style: styles14.audioPlayer, children: [
5050
+ return /* @__PURE__ */ jsxRuntime.jsxs("span", { style: { ...styles14.audioPlayer, backgroundColor: pal.audioBg, borderColor: pal.audioBg }, children: [
4857
5051
  /* @__PURE__ */ jsxRuntime.jsx(
4858
5052
  "button",
4859
5053
  {
@@ -4865,15 +5059,15 @@ function InlineAudioPlayer({ url, duration }) {
4865
5059
  }
4866
5060
  ),
4867
5061
  /* @__PURE__ */ jsxRuntime.jsxs("span", { style: styles14.audioWaveform, children: [
4868
- /* @__PURE__ */ jsxRuntime.jsx("span", { style: { ...styles14.waveBar, height: "40%" } }),
4869
- /* @__PURE__ */ jsxRuntime.jsx("span", { style: { ...styles14.waveBar, height: "80%" } }),
4870
- /* @__PURE__ */ jsxRuntime.jsx("span", { style: { ...styles14.waveBar, height: "60%" } }),
4871
- /* @__PURE__ */ jsxRuntime.jsx("span", { style: { ...styles14.waveBar, height: "90%" } }),
4872
- /* @__PURE__ */ jsxRuntime.jsx("span", { style: { ...styles14.waveBar, height: "50%" } }),
4873
- /* @__PURE__ */ jsxRuntime.jsx("span", { style: { ...styles14.waveBar, height: "70%" } }),
4874
- /* @__PURE__ */ jsxRuntime.jsx("span", { style: { ...styles14.waveBar, height: "40%" } })
5062
+ /* @__PURE__ */ jsxRuntime.jsx("span", { style: { ...styles14.waveBar, height: "40%", backgroundColor: pal.audioFg } }),
5063
+ /* @__PURE__ */ jsxRuntime.jsx("span", { style: { ...styles14.waveBar, height: "80%", backgroundColor: pal.audioFg } }),
5064
+ /* @__PURE__ */ jsxRuntime.jsx("span", { style: { ...styles14.waveBar, height: "60%", backgroundColor: pal.audioFg } }),
5065
+ /* @__PURE__ */ jsxRuntime.jsx("span", { style: { ...styles14.waveBar, height: "90%", backgroundColor: pal.audioFg } }),
5066
+ /* @__PURE__ */ jsxRuntime.jsx("span", { style: { ...styles14.waveBar, height: "50%", backgroundColor: pal.audioFg } }),
5067
+ /* @__PURE__ */ jsxRuntime.jsx("span", { style: { ...styles14.waveBar, height: "70%", backgroundColor: pal.audioFg } }),
5068
+ /* @__PURE__ */ jsxRuntime.jsx("span", { style: { ...styles14.waveBar, height: "40%", backgroundColor: pal.audioFg } })
4875
5069
  ] }),
4876
- /* @__PURE__ */ jsxRuntime.jsx("span", { style: styles14.audioDuration, children: formatDuration2(displayTime) }),
5070
+ /* @__PURE__ */ jsxRuntime.jsx("span", { style: { ...styles14.audioDuration, color: pal.audioFg }, children: formatDuration2(displayTime) }),
4877
5071
  /* @__PURE__ */ jsxRuntime.jsx("audio", { ref: audioRef, src: url, preload: "metadata" })
4878
5072
  ] });
4879
5073
  }
@@ -4903,7 +5097,8 @@ function InlineInputBar({
4903
5097
  unreadCount,
4904
5098
  isLive,
4905
5099
  showExpand,
4906
- onExpand
5100
+ onExpand,
5101
+ pal
4907
5102
  }) {
4908
5103
  const [text, setText] = React4.useState("");
4909
5104
  const [isSending, setIsSending] = React4.useState(false);
@@ -4940,7 +5135,12 @@ function InlineInputBar({
4940
5135
  onKeyDown: handleKeyDown,
4941
5136
  placeholder,
4942
5137
  disabled: isSending,
4943
- style: styles14.input
5138
+ style: {
5139
+ ...styles14.input,
5140
+ backgroundColor: pal.inputBg,
5141
+ borderColor: pal.inputBorder,
5142
+ color: pal.inputText
5143
+ }
4944
5144
  }
4945
5145
  ),
4946
5146
  unreadCount > 0 && /* @__PURE__ */ jsxRuntime.jsx("span", { style: styles14.unreadBadge, title: `${unreadCount} unread`, children: unreadCount > 99 ? "99+" : unreadCount }),
@@ -4960,7 +5160,11 @@ function InlineInputBar({
4960
5160
  "button",
4961
5161
  {
4962
5162
  onClick: onExpand,
4963
- style: styles14.expandButton,
5163
+ style: {
5164
+ ...styles14.expandButton,
5165
+ borderColor: pal.expandBorder,
5166
+ color: pal.expandColor
5167
+ },
4964
5168
  title: "Expand to full chat",
4965
5169
  type: "button",
4966
5170
  children: "\u26F6"
@@ -5507,8 +5711,11 @@ function ConversationList({
5507
5711
  onSelect,
5508
5712
  className
5509
5713
  }) {
5714
+ const { totalUnread } = useCollab();
5510
5715
  const [query, setQuery] = React4.useState("");
5511
- const [filter, setFilter] = React4.useState("all");
5716
+ const [filter, setFilter] = React4.useState(
5717
+ () => totalUnread > 0 ? "unread" : "all"
5718
+ );
5512
5719
  const [searchFocused, setSearchFocused] = React4.useState(false);
5513
5720
  const unreadCount = React4.useMemo(
5514
5721
  () => conversations.filter((c) => c.unreadCount > 0).length,
@@ -6138,7 +6345,7 @@ function useUnreadCount() {
6138
6345
  const [counts, setCounts] = React4.useState({});
6139
6346
  React4.useEffect(() => {
6140
6347
  socket.onUnreadCountUpdate((serverCounts) => {
6141
- setCounts(serverCounts);
6348
+ setCounts(serverCounts.conversation);
6142
6349
  });
6143
6350
  }, [socket]);
6144
6351
  const getCountForConversation = React4.useCallback(