@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.mjs CHANGED
@@ -57,9 +57,29 @@ function toCamelKey(key) {
57
57
  }
58
58
 
59
59
  // src/core/socket.ts
60
+ function normalizeUnreadPayload(payload) {
61
+ if (payload && typeof payload === "object") {
62
+ const obj = payload;
63
+ if (obj.conversation && typeof obj.conversation === "object") {
64
+ return {
65
+ conversation: obj.conversation,
66
+ order: obj.order && typeof obj.order === "object" ? obj.order : {}
67
+ };
68
+ }
69
+ return { conversation: payload, order: {} };
70
+ }
71
+ return { conversation: {}, order: {} };
72
+ }
60
73
  var CollabSocket = class {
61
74
  constructor() {
62
75
  this.socket = null;
76
+ /**
77
+ * Conversation channels keyed by id. Each entry is reference-counted so
78
+ * multiple surfaces (e.g. inline chat + expanded panel mounted at once
79
+ * for the same conversation) can coexist without one's `leaveConversation`
80
+ * tearing the channel out from under the other. See `joinConversation`
81
+ * and the returned `ChannelSubscription.release`.
82
+ */
63
83
  this.channels = /* @__PURE__ */ new Map();
64
84
  this.presences = /* @__PURE__ */ new Map();
65
85
  this.userChannel = null;
@@ -115,7 +135,7 @@ var CollabSocket = class {
115
135
  if (!this.socket || !this.config) return;
116
136
  this.userChannel = this.socket.channel("user_notifications", {});
117
137
  this.userChannel.on("unread_update", (payload) => {
118
- this.onUnreadUpdate?.(payload);
138
+ this.onUnreadUpdate?.(normalizeUnreadPayload(payload));
119
139
  });
120
140
  this.userChannel.join().receive("ok", () => {
121
141
  }).receive("error", (reason) => {
@@ -126,63 +146,91 @@ var CollabSocket = class {
126
146
  });
127
147
  });
128
148
  }
129
- /** Register callback for unread count changes */
149
+ /** Register callback for unread count changes. The callback receives
150
+ * the normalised {@link UnreadCounts} shape regardless of which
151
+ * payload format the backend pushed. */
130
152
  onUnreadCountUpdate(callback) {
131
153
  this.onUnreadUpdate = callback;
132
154
  }
133
- /** Join a conversation channel and subscribe to events */
155
+ /**
156
+ * Join a conversation channel and subscribe to events.
157
+ *
158
+ * Reference-counted: multiple callers can join the same conversation
159
+ * (e.g. inline preview + expanded panel mounted side-by-side). Each
160
+ * call binds its own listeners and gets back a `ChannelSubscription`.
161
+ * The underlying channel only `.leave()`s the server when the LAST
162
+ * subscriber calls `release()`.
163
+ */
134
164
  joinConversation(conversationId, callbacks) {
135
165
  if (!this.socket) return null;
136
- if (this.channels.has(conversationId)) {
137
- return this.channels.get(conversationId);
166
+ let entry = this.channels.get(conversationId);
167
+ if (!entry) {
168
+ const channel2 = this.socket.channel(`conversation:${conversationId}`, {});
169
+ channel2.join().receive("ok", () => {
170
+ }).receive("error", (reason) => {
171
+ this.config?.onError?.({
172
+ code: "CHANNEL_JOIN_ERROR",
173
+ message: `Failed to join conversation ${conversationId}`,
174
+ details: reason
175
+ });
176
+ });
177
+ entry = { channel: channel2, subscribers: 0, presence: null };
178
+ this.channels.set(conversationId, entry);
138
179
  }
139
- const channel = this.socket.channel(`conversation:${conversationId}`, {});
180
+ const channel = entry.channel;
181
+ const refs = [];
182
+ const bind = (event, fn) => {
183
+ const ref = channel.on(event, fn);
184
+ refs.push({ event, ref });
185
+ };
140
186
  if (callbacks.onMessage) {
141
- channel.on(EVENTS.MESSAGE_NEW, (payload) => {
187
+ bind(EVENTS.MESSAGE_NEW, (payload) => {
142
188
  callbacks.onMessage(snakeToCamel(payload));
143
189
  });
144
190
  }
145
191
  if (callbacks.onTyping) {
146
- channel.on(EVENTS.USER_TYPING, (payload) => {
192
+ bind(EVENTS.USER_TYPING, (payload) => {
147
193
  callbacks.onTyping(snakeToCamel(payload));
148
194
  });
149
195
  }
150
196
  if (callbacks.onUserJoined) {
151
- channel.on(EVENTS.USER_JOINED, (payload) => {
197
+ bind(EVENTS.USER_JOINED, (payload) => {
152
198
  callbacks.onUserJoined(snakeToCamel(payload));
153
199
  });
154
200
  }
155
201
  if (callbacks.onUserLeft) {
156
- channel.on(EVENTS.USER_LEFT, (payload) => {
202
+ bind(EVENTS.USER_LEFT, (payload) => {
157
203
  callbacks.onUserLeft(snakeToCamel(payload));
158
204
  });
159
205
  }
160
206
  if (callbacks.onChannelUpdated) {
161
- channel.on(EVENTS.CHANNEL_UPDATED, (payload) => {
207
+ bind(EVENTS.CHANNEL_UPDATED, (payload) => {
162
208
  callbacks.onChannelUpdated(snakeToCamel(payload));
163
209
  });
164
210
  }
165
211
  if (callbacks.onChannelDeleted) {
166
- channel.on(EVENTS.CHANNEL_DELETED, () => {
212
+ bind(EVENTS.CHANNEL_DELETED, () => {
167
213
  callbacks.onChannelDeleted();
168
214
  });
169
215
  }
170
216
  if (callbacks.onMessageRead) {
171
- channel.on(EVENTS.MESSAGE_READ, (payload) => {
172
- callbacks.onMessageRead(snakeToCamel(payload));
217
+ bind(EVENTS.MESSAGE_READ, (payload) => {
218
+ callbacks.onMessageRead(
219
+ snakeToCamel(payload)
220
+ );
173
221
  });
174
222
  }
175
223
  if (callbacks.onMessagePinned) {
176
- channel.on(EVENTS.MESSAGE_PINNED, (payload) => {
224
+ bind(EVENTS.MESSAGE_PINNED, (payload) => {
177
225
  callbacks.onMessagePinned(snakeToCamel(payload));
178
226
  });
179
227
  }
180
228
  if (callbacks.onMessageUnpinned) {
181
- channel.on(EVENTS.MESSAGE_UNPINNED, (payload) => {
229
+ bind(EVENTS.MESSAGE_UNPINNED, (payload) => {
182
230
  callbacks.onMessageUnpinned(snakeToCamel(payload));
183
231
  });
184
232
  }
185
- if (callbacks.onPresence) {
233
+ if (callbacks.onPresence && !entry.presence) {
186
234
  const presence = new Presence(channel);
187
235
  presence.onSync(() => {
188
236
  const online = {};
@@ -191,27 +239,45 @@ var CollabSocket = class {
191
239
  });
192
240
  callbacks.onPresence(online);
193
241
  });
242
+ entry.presence = presence;
194
243
  this.presences.set(conversationId, presence);
195
244
  }
196
- channel.join().receive("ok", () => {
197
- }).receive("error", (reason) => {
198
- this.config?.onError?.({
199
- code: "CHANNEL_JOIN_ERROR",
200
- message: `Failed to join conversation ${conversationId}`,
201
- details: reason
202
- });
203
- });
204
- this.channels.set(conversationId, channel);
205
- return channel;
245
+ entry.subscribers += 1;
246
+ let released = false;
247
+ const release = () => {
248
+ if (released) return;
249
+ released = true;
250
+ const current = this.channels.get(conversationId);
251
+ if (!current) return;
252
+ for (const { event, ref } of refs) {
253
+ current.channel.off(event, ref);
254
+ }
255
+ current.subscribers -= 1;
256
+ if (current.subscribers <= 0) {
257
+ current.channel.leave();
258
+ this.channels.delete(conversationId);
259
+ this.presences.delete(conversationId);
260
+ }
261
+ };
262
+ return { channel, release };
206
263
  }
207
- /** Leave a conversation channel */
208
- leaveConversation(conversationId) {
209
- const channel = this.channels.get(conversationId);
210
- if (channel) {
211
- channel.leave();
212
- this.channels.delete(conversationId);
213
- this.presences.delete(conversationId);
214
- }
264
+ /**
265
+ * @deprecated Use the `release()` method returned by `joinConversation()`.
266
+ * Kept as a no-op so older callers don't throw — but it cannot identify
267
+ * which subscriber should leave, so it silently does nothing. Any code
268
+ * still calling this will leak listeners and prevent the channel from
269
+ * ever being torn down. Migrate to the subscription handle.
270
+ */
271
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
272
+ leaveConversation(_conversationId) {
273
+ }
274
+ /**
275
+ * Look up the underlying Phoenix Channel for a conversation, if any
276
+ * subscriber is still holding it. All send/push paths go through this
277
+ * helper so the refcounted entry shape is contained to joinConversation.
278
+ */
279
+ getChannel(conversationId) {
280
+ return this.channels.get(conversationId)?.channel ?? null;
215
281
  }
216
282
  /** Send a message to a conversation.
217
283
  *
@@ -223,7 +289,7 @@ var CollabSocket = class {
223
289
  */
224
290
  sendMessage(conversationId, payload) {
225
291
  return new Promise((resolve, reject) => {
226
- const channel = this.channels.get(conversationId);
292
+ const channel = this.getChannel(conversationId);
227
293
  if (!channel) {
228
294
  reject({ code: "NOT_JOINED", message: "Not joined to this conversation" });
229
295
  return;
@@ -246,7 +312,7 @@ var CollabSocket = class {
246
312
  }
247
313
  /** Broadcast typing indicator */
248
314
  sendTyping(conversationId, isTyping) {
249
- const channel = this.channels.get(conversationId);
315
+ const channel = this.getChannel(conversationId);
250
316
  channel?.push(EVENTS.USER_TYPING, {
251
317
  userId: this.config?.userId,
252
318
  userName: this.config?.userName,
@@ -255,7 +321,7 @@ var CollabSocket = class {
255
321
  }
256
322
  /** Mark messages as read */
257
323
  markAsRead(conversationId, messageId) {
258
- const channel = this.channels.get(conversationId);
324
+ const channel = this.getChannel(conversationId);
259
325
  channel?.push(EVENTS.MESSAGE_READ, {
260
326
  messageId,
261
327
  userId: this.config?.userId
@@ -289,7 +355,7 @@ var CollabSocket = class {
289
355
  /** Generic push with promise wrapper */
290
356
  channelPush(conversationId, event, payload) {
291
357
  return new Promise((resolve, reject) => {
292
- const channel = this.channels.get(conversationId);
358
+ const channel = this.getChannel(conversationId);
293
359
  if (!channel) {
294
360
  reject({ code: "NOT_JOINED", message: "Not joined to this conversation" });
295
361
  return;
@@ -306,7 +372,7 @@ var CollabSocket = class {
306
372
  }
307
373
  /** Disconnect socket and leave all channels */
308
374
  disconnect() {
309
- this.channels.forEach((channel) => channel.leave());
375
+ this.channels.forEach((entry) => entry.channel.leave());
310
376
  this.channels.clear();
311
377
  this.presences.clear();
312
378
  this.userChannel?.leave();
@@ -361,7 +427,21 @@ var THEME_VAR = {
361
427
  dangerBg: "--natoe-colab-danger-bg",
362
428
  dangerBorder: "--natoe-colab-danger-border",
363
429
  dangerFg: "--natoe-colab-danger-fg",
364
- fontStack: "--natoe-colab-font-stack"
430
+ fontStack: "--natoe-colab-font-stack",
431
+ // Neutral surface palette — promoted to CSS vars so consumers (e.g.
432
+ // CollabPanel themeMode='dark') can flip the whole grayscale at a subtree
433
+ // level without re-themeing every component.
434
+ white: "--natoe-colab-white",
435
+ neutral50: "--natoe-colab-neutral-50",
436
+ neutral100: "--natoe-colab-neutral-100",
437
+ neutral200: "--natoe-colab-neutral-200",
438
+ neutral300: "--natoe-colab-neutral-300",
439
+ neutral400: "--natoe-colab-neutral-400",
440
+ neutral500: "--natoe-colab-neutral-500",
441
+ neutral600: "--natoe-colab-neutral-600",
442
+ neutral700: "--natoe-colab-neutral-700",
443
+ neutral800: "--natoe-colab-neutral-800",
444
+ neutral900: "--natoe-colab-neutral-900"
365
445
  };
366
446
  var THEME_DEFAULTS = {
367
447
  primary: "#2563eb",
@@ -374,18 +454,9 @@ var THEME_DEFAULTS = {
374
454
  dangerBg: "#fef2f2",
375
455
  dangerBorder: "#fecaca",
376
456
  dangerFg: "#b91c1c",
377
- fontStack: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif'
378
- };
379
- var cssVar = (name, fallback) => `var(${name}, ${fallback})`;
380
- var COLOR = {
381
- // Brand — themeable
382
- primary: cssVar(THEME_VAR.primary, THEME_DEFAULTS.primary),
383
- primaryHover: cssVar(THEME_VAR.primaryHover, THEME_DEFAULTS.primaryHover),
384
- /** Tinted surface for chips/cards on brand-coloured states. */
385
- primaryBg: cssVar(THEME_VAR.primaryBg, THEME_DEFAULTS.primaryBg),
386
- primaryFg: cssVar(THEME_VAR.primaryFg, THEME_DEFAULTS.primaryFg),
387
- // Neutral grayscale — not themeable, taken from Tailwind's zinc-leaning
388
- // slate to match the package's existing tonal balance.
457
+ fontStack: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
458
+ // Neutral grayscale — these defaults are the same hex literals the
459
+ // package shipped with; they're now overridable per-subtree.
389
460
  white: "#ffffff",
390
461
  neutral50: "#f9fafb",
391
462
  neutral100: "#f3f4f6",
@@ -396,7 +467,32 @@ var COLOR = {
396
467
  neutral600: "#4b5563",
397
468
  neutral700: "#374151",
398
469
  neutral800: "#1f2937",
399
- neutral900: "#111827",
470
+ neutral900: "#111827"
471
+ };
472
+ var cssVar = (name, fallback) => `var(${name}, ${fallback})`;
473
+ var COLOR = {
474
+ // Brand — themeable
475
+ primary: cssVar(THEME_VAR.primary, THEME_DEFAULTS.primary),
476
+ primaryHover: cssVar(THEME_VAR.primaryHover, THEME_DEFAULTS.primaryHover),
477
+ /** Tinted surface for chips/cards on brand-coloured states. */
478
+ primaryBg: cssVar(THEME_VAR.primaryBg, THEME_DEFAULTS.primaryBg),
479
+ primaryFg: cssVar(THEME_VAR.primaryFg, THEME_DEFAULTS.primaryFg),
480
+ // Neutral grayscale — themeable via CSS variables. Light-mode defaults
481
+ // taken from Tailwind's zinc-leaning slate to match the package's
482
+ // existing tonal balance; a host can override the entire palette by
483
+ // setting --natoe-colab-* values on any ancestor element (used by
484
+ // CollabPanel themeMode='dark' for the viewer's left-panel surface).
485
+ white: cssVar(THEME_VAR.white, THEME_DEFAULTS.white),
486
+ neutral50: cssVar(THEME_VAR.neutral50, THEME_DEFAULTS.neutral50),
487
+ neutral100: cssVar(THEME_VAR.neutral100, THEME_DEFAULTS.neutral100),
488
+ neutral200: cssVar(THEME_VAR.neutral200, THEME_DEFAULTS.neutral200),
489
+ neutral300: cssVar(THEME_VAR.neutral300, THEME_DEFAULTS.neutral300),
490
+ neutral400: cssVar(THEME_VAR.neutral400, THEME_DEFAULTS.neutral400),
491
+ neutral500: cssVar(THEME_VAR.neutral500, THEME_DEFAULTS.neutral500),
492
+ neutral600: cssVar(THEME_VAR.neutral600, THEME_DEFAULTS.neutral600),
493
+ neutral700: cssVar(THEME_VAR.neutral700, THEME_DEFAULTS.neutral700),
494
+ neutral800: cssVar(THEME_VAR.neutral800, THEME_DEFAULTS.neutral800),
495
+ neutral900: cssVar(THEME_VAR.neutral900, THEME_DEFAULTS.neutral900),
400
496
  slate800: "#1e293b",
401
497
  // Semantic — themeable
402
498
  success: cssVar(THEME_VAR.success, THEME_DEFAULTS.success),
@@ -496,6 +592,7 @@ function CollabProvider({ config, apiBaseUrl, children }) {
496
592
  return new CollabSocket();
497
593
  });
498
594
  const [unreadCounts, setUnreadCounts] = useState({});
595
+ const [unreadCountsByOrder, setUnreadCountsByOrder] = useState({});
499
596
  const pendingOrderIds = useRef(/* @__PURE__ */ new Set());
500
597
  const pendingResolvers = useRef(/* @__PURE__ */ new Map());
501
598
  const previewCache = useRef(/* @__PURE__ */ new Map());
@@ -511,7 +608,8 @@ function CollabProvider({ config, apiBaseUrl, children }) {
511
608
  setSocket(s);
512
609
  }
513
610
  s.onUnreadCountUpdate((counts) => {
514
- setUnreadCounts(counts);
611
+ setUnreadCounts(counts.conversation);
612
+ setUnreadCountsByOrder(counts.order);
515
613
  previewCache.current.clear();
516
614
  });
517
615
  if (!s.isConnected()) {
@@ -719,6 +817,7 @@ function CollabProvider({ config, apiBaseUrl, children }) {
719
817
  apiBaseUrl,
720
818
  totalUnread,
721
819
  unreadCounts,
820
+ unreadCountsByOrder,
722
821
  requestPreview,
723
822
  invalidatePreview,
724
823
  fetchMessages,
@@ -775,6 +874,7 @@ function useConversation({
775
874
  const [isConnected, setIsConnected] = useState(false);
776
875
  const [replyTo, setReplyTo] = useState(null);
777
876
  const joinedConversationId = useRef(null);
877
+ const channelSubscription = useRef(null);
778
878
  const typingTimers = useRef(/* @__PURE__ */ new Map());
779
879
  const ensureConversationInFlight = useRef(null);
780
880
  const markedReadIdsRef = useRef(/* @__PURE__ */ new Set());
@@ -788,7 +888,9 @@ function useConversation({
788
888
  const joinChannel = useCallback(
789
889
  (conv) => {
790
890
  if (joinedConversationId.current === conv.id) return;
791
- socket.joinConversation(conv.id, {
891
+ channelSubscription.current?.release();
892
+ channelSubscription.current = null;
893
+ const subscription = socket.joinConversation(conv.id, {
792
894
  // Dedupe by id so an optimistic message (added client-side on send)
793
895
  // doesn't double up when the server's broadcast arrives.
794
896
  onMessage: (msg) => setMessages((prev) => {
@@ -838,6 +940,8 @@ function useConversation({
838
940
  onChannelDeleted: () => {
839
941
  setConversation(null);
840
942
  setMessages([]);
943
+ channelSubscription.current?.release();
944
+ channelSubscription.current = null;
841
945
  joinedConversationId.current = null;
842
946
  setIsConnected(false);
843
947
  },
@@ -863,6 +967,7 @@ function useConversation({
863
967
  );
864
968
  }
865
969
  });
970
+ channelSubscription.current = subscription;
866
971
  joinedConversationId.current = conv.id;
867
972
  setIsConnected(true);
868
973
  },
@@ -923,10 +1028,9 @@ function useConversation({
923
1028
  init();
924
1029
  return () => {
925
1030
  cancelled = true;
926
- if (joinedConversationId.current) {
927
- socket.leaveConversation(joinedConversationId.current);
928
- joinedConversationId.current = null;
929
- }
1031
+ channelSubscription.current?.release();
1032
+ channelSubscription.current = null;
1033
+ joinedConversationId.current = null;
930
1034
  typingTimers.current.forEach((timer) => clearTimeout(timer));
931
1035
  typingTimers.current.clear();
932
1036
  markedReadIdsRef.current.clear();
@@ -1342,17 +1446,26 @@ function DicomIcon({ size = 18, color = "currentColor" }) {
1342
1446
  }
1343
1447
  );
1344
1448
  }
1345
- function SettingsIcon({ size = 18, color = "currentColor" }) {
1346
- return /* @__PURE__ */ jsx(
1449
+ function PeopleIcon({ size = 18, color = "currentColor" }) {
1450
+ return /* @__PURE__ */ jsxs(
1347
1451
  "svg",
1348
1452
  {
1349
1453
  xmlns: "http://www.w3.org/2000/svg",
1350
1454
  width: size,
1351
1455
  height: size,
1352
1456
  viewBox: "0 0 24 24",
1353
- fill: color,
1457
+ fill: "none",
1458
+ stroke: color,
1459
+ strokeWidth: "2",
1460
+ strokeLinecap: "round",
1461
+ strokeLinejoin: "round",
1354
1462
  "aria-hidden": "true",
1355
- children: /* @__PURE__ */ 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" })
1463
+ children: [
1464
+ /* @__PURE__ */ jsx("path", { d: "M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" }),
1465
+ /* @__PURE__ */ jsx("circle", { cx: "9", cy: "7", r: "4" }),
1466
+ /* @__PURE__ */ jsx("path", { d: "M22 21v-2a4 4 0 0 0-3-3.87" }),
1467
+ /* @__PURE__ */ jsx("path", { d: "M16 3.13a4 4 0 0 1 0 7.75" })
1468
+ ]
1356
1469
  }
1357
1470
  );
1358
1471
  }
@@ -1377,6 +1490,7 @@ function resolveDisplayName(patientData, displayName) {
1377
1490
  }
1378
1491
  function PatientHeader({
1379
1492
  patientData,
1493
+ participants,
1380
1494
  onOpenDicom,
1381
1495
  onOpenSettings,
1382
1496
  onBack,
@@ -1442,15 +1556,18 @@ function PatientHeader({
1442
1556
  ]
1443
1557
  }
1444
1558
  ),
1445
- onOpenSettings && /* @__PURE__ */ jsx(
1559
+ onOpenSettings && /* @__PURE__ */ jsxs(
1446
1560
  "button",
1447
1561
  {
1448
1562
  onClick: onOpenSettings,
1449
1563
  style: styles.settingsIconButton,
1450
1564
  type: "button",
1451
- "aria-label": "Open channel settings",
1452
- title: "Settings",
1453
- children: /* @__PURE__ */ jsx(SettingsIcon, { size: 18, color: COLOR.neutral600 })
1565
+ "aria-label": `Open channel settings (${participants.length} participants)`,
1566
+ title: "Channel participants",
1567
+ children: [
1568
+ /* @__PURE__ */ jsx(PeopleIcon, { size: 18, color: COLOR.neutral600 }),
1569
+ /* @__PURE__ */ jsx("span", { style: styles.participantCount, children: participants.length })
1570
+ ]
1454
1571
  }
1455
1572
  )
1456
1573
  ] })
@@ -1558,20 +1675,28 @@ var styles = {
1558
1675
  borderRadius: RADIUS.lg,
1559
1676
  cursor: "pointer"
1560
1677
  },
1561
- // Settings is icon-only (40x40 tile) since it's a secondary action
1562
- // and View DICOM already carries a label.
1678
+ // Channel-info button: people icon + participant count. Click opens the
1679
+ // channel settings overlay (kept on the same handler as before so hosts
1680
+ // don't have to re-wire — the affordance just looks like a "members"
1681
+ // pill now instead of a gear).
1563
1682
  settingsIconButton: {
1564
- width: SIZE.control,
1565
- height: SIZE.control,
1683
+ minHeight: SIZE.control,
1566
1684
  display: "inline-flex",
1567
1685
  alignItems: "center",
1568
- justifyContent: "center",
1686
+ gap: SPACE.S2,
1687
+ padding: `0 ${SPACE.S3}`,
1569
1688
  backgroundColor: COLOR.white,
1570
1689
  border: `1px solid ${COLOR.neutral200}`,
1571
1690
  borderRadius: RADIUS.lg,
1572
1691
  color: COLOR.neutral600,
1573
1692
  cursor: "pointer",
1574
1693
  flexShrink: 0
1694
+ },
1695
+ participantCount: {
1696
+ fontSize: FONT_SIZE.sm,
1697
+ fontWeight: FONT_WEIGHT.semibold,
1698
+ color: COLOR.neutral700,
1699
+ fontVariantNumeric: "tabular-nums"
1575
1700
  }
1576
1701
  };
1577
1702
  function ReplyQuoteBlock({
@@ -3992,6 +4117,20 @@ var styles12 = {
3992
4117
  }
3993
4118
  };
3994
4119
  ensureGlobalStyles();
4120
+ var DARK_THEME_OVERRIDES = {
4121
+ ["--natoe-colab-white"]: "#0b0b0c",
4122
+ ["--natoe-colab-neutral-50"]: "#18181c",
4123
+ ["--natoe-colab-neutral-100"]: "#1f1f23",
4124
+ ["--natoe-colab-neutral-200"]: "#2a2a2e",
4125
+ ["--natoe-colab-neutral-300"]: "#3f3f44",
4126
+ ["--natoe-colab-neutral-400"]: "#6b7280",
4127
+ ["--natoe-colab-neutral-500"]: "#9ca3af",
4128
+ ["--natoe-colab-neutral-600"]: "#cbd5e1",
4129
+ ["--natoe-colab-neutral-700"]: "#e5e7eb",
4130
+ ["--natoe-colab-neutral-800"]: "#f3f4f6",
4131
+ ["--natoe-colab-neutral-900"]: "#ffffff",
4132
+ ["--natoe-colab-primary-bg"]: "rgba(37, 99, 235, 0.18)"
4133
+ };
3995
4134
  function CollabPanel({
3996
4135
  orderId,
3997
4136
  patientData,
@@ -4000,6 +4139,7 @@ function CollabPanel({
4000
4139
  onBack,
4001
4140
  hidePatientName = false,
4002
4141
  onConversationChange,
4142
+ themeMode = "light",
4003
4143
  className,
4004
4144
  style
4005
4145
  }) {
@@ -4075,16 +4215,21 @@ function CollabPanel({
4075
4215
  }
4076
4216
  };
4077
4217
  const pinDisabled = pinnedMessages.length >= MAX_PINNED_MESSAGES;
4218
+ const containerStyle = {
4219
+ ...panelStyles.container,
4220
+ ...themeMode === "dark" ? DARK_THEME_OVERRIDES : {},
4221
+ ...style
4222
+ };
4078
4223
  if (error) {
4079
- return /* @__PURE__ */ jsx("div", { className: [ROOT_CLASS, className].filter(Boolean).join(" "), style: { ...panelStyles.container, ...style }, children: /* @__PURE__ */ jsxs("div", { style: panelStyles.errorState, children: [
4224
+ return /* @__PURE__ */ jsx("div", { className: [ROOT_CLASS, className].filter(Boolean).join(" "), style: containerStyle, children: /* @__PURE__ */ jsxs("div", { style: panelStyles.errorState, children: [
4080
4225
  /* @__PURE__ */ jsx("p", { style: panelStyles.errorTitle, children: "Unable to load conversation" }),
4081
4226
  /* @__PURE__ */ jsx("p", { style: panelStyles.errorMessage, children: error })
4082
4227
  ] }) });
4083
4228
  }
4084
4229
  if (isLoading && !conversation) {
4085
- return /* @__PURE__ */ jsx("div", { className: [ROOT_CLASS, className].filter(Boolean).join(" "), style: { ...panelStyles.container, ...style }, children: /* @__PURE__ */ jsx("div", { style: panelStyles.loadingState, children: "Loading conversation..." }) });
4230
+ return /* @__PURE__ */ jsx("div", { className: [ROOT_CLASS, className].filter(Boolean).join(" "), style: containerStyle, children: /* @__PURE__ */ jsx("div", { style: panelStyles.loadingState, children: "Loading conversation..." }) });
4086
4231
  }
4087
- return /* @__PURE__ */ jsxs("div", { className, style: { ...panelStyles.container, ...style }, children: [
4232
+ return /* @__PURE__ */ jsxs("div", { className: [ROOT_CLASS, className].filter(Boolean).join(" "), style: containerStyle, children: [
4088
4233
  showSettings && conversation ? /* @__PURE__ */ jsx(
4089
4234
  ChannelSettings,
4090
4235
  {
@@ -4520,6 +4665,7 @@ function useInlineCollab({
4520
4665
  const elementRef = useRef(null);
4521
4666
  const observerRef = useRef(null);
4522
4667
  const subscribedConversationIdRef = useRef(null);
4668
+ const channelSubscriptionRef = useRef(null);
4523
4669
  const trimToLimit = useCallback(
4524
4670
  (msgs) => msgs.length > messageLimit ? msgs.slice(msgs.length - messageLimit) : msgs,
4525
4671
  [messageLimit]
@@ -4557,7 +4703,9 @@ function useInlineCollab({
4557
4703
  const subscribe = useCallback(
4558
4704
  (conversationId) => {
4559
4705
  if (subscribedConversationIdRef.current === conversationId) return;
4560
- socket.joinConversation(conversationId, {
4706
+ channelSubscriptionRef.current?.release();
4707
+ channelSubscriptionRef.current = null;
4708
+ const subscription = socket.joinConversation(conversationId, {
4561
4709
  onMessage: (msg) => {
4562
4710
  setMessages((prev) => trimToLimit([...prev, msg]));
4563
4711
  if (msg.senderId !== config.userId) {
@@ -4573,19 +4721,18 @@ function useInlineCollab({
4573
4721
  setParticipants((prev) => prev.filter((p) => p.userId !== participant.userId));
4574
4722
  }
4575
4723
  });
4724
+ channelSubscriptionRef.current = subscription;
4576
4725
  subscribedConversationIdRef.current = conversationId;
4577
4726
  setIsSubscribed(true);
4578
4727
  },
4579
4728
  [socket, trimToLimit, config.userId]
4580
4729
  );
4581
4730
  const unsubscribe = useCallback(() => {
4582
- const convId = subscribedConversationIdRef.current;
4583
- if (convId) {
4584
- socket.leaveConversation(convId);
4585
- subscribedConversationIdRef.current = null;
4586
- setIsSubscribed(false);
4587
- }
4588
- }, [socket]);
4731
+ channelSubscriptionRef.current?.release();
4732
+ channelSubscriptionRef.current = null;
4733
+ subscribedConversationIdRef.current = null;
4734
+ setIsSubscribed(false);
4735
+ }, []);
4589
4736
  const containerRef = useCallback(
4590
4737
  (element) => {
4591
4738
  if (observerRef.current) {
@@ -4727,16 +4874,52 @@ function useInlineCollab({
4727
4874
  };
4728
4875
  }
4729
4876
  ensureGlobalStyles();
4877
+ function palette(mode) {
4878
+ if (mode === "dark") {
4879
+ return {
4880
+ bg: "transparent",
4881
+ border: "transparent",
4882
+ previewBorder: "#1f1f23",
4883
+ senderName: "#f3f4f6",
4884
+ messageText: "#cbd5e1",
4885
+ systemText: "#6b7280",
4886
+ inputBg: "#18181c",
4887
+ inputBorder: "#2a2a2e",
4888
+ inputText: "#e5e7eb",
4889
+ audioBg: "rgba(37, 99, 235, 0.18)",
4890
+ audioFg: "#93c5fd",
4891
+ expandBorder: "#2a2a2e",
4892
+ expandColor: "#9ca3af"
4893
+ };
4894
+ }
4895
+ return {
4896
+ bg: COLOR.white,
4897
+ border: COLOR.neutral200,
4898
+ previewBorder: COLOR.neutral100,
4899
+ senderName: COLOR.neutral700,
4900
+ messageText: COLOR.neutral600,
4901
+ systemText: COLOR.neutral400,
4902
+ inputBg: "transparent",
4903
+ inputBorder: COLOR.neutral200,
4904
+ inputText: COLOR.neutral900,
4905
+ audioBg: COLOR.primaryBg,
4906
+ audioFg: COLOR.primary,
4907
+ expandBorder: COLOR.neutral200,
4908
+ expandColor: COLOR.neutral500
4909
+ };
4910
+ }
4730
4911
  function CollabInline({
4731
4912
  orderId,
4732
4913
  patientData,
4733
4914
  participantIds,
4734
4915
  onExpand,
4735
- messageLimit = 5,
4916
+ messageLimit = 1,
4736
4917
  placeholder = "Type a message about this case...",
4918
+ mode = "light",
4737
4919
  className,
4738
4920
  style
4739
4921
  }) {
4922
+ const pal = palette(mode);
4740
4923
  const {
4741
4924
  hasConversation,
4742
4925
  messages,
@@ -4748,11 +4931,17 @@ function CollabInline({
4748
4931
  sendAudioMessage,
4749
4932
  containerRef
4750
4933
  } = useInlineCollab({ orderId, patientData, participantIds, messageLimit });
4934
+ const containerStyle = {
4935
+ ...styles14.container,
4936
+ backgroundColor: pal.bg,
4937
+ borderColor: pal.border,
4938
+ ...style
4939
+ };
4751
4940
  if (isLoading && !hasConversation) {
4752
- return /* @__PURE__ */ jsx("div", { className: [ROOT_CLASS, className].filter(Boolean).join(" "), style: { ...styles14.container, ...style }, children: /* @__PURE__ */ jsx("div", { style: styles14.loadingState, children: "Loading..." }) });
4941
+ return /* @__PURE__ */ jsx("div", { className: [ROOT_CLASS, className].filter(Boolean).join(" "), style: containerStyle, children: /* @__PURE__ */ jsx("div", { style: styles14.loadingState, children: "Loading..." }) });
4753
4942
  }
4754
- return /* @__PURE__ */ jsxs("div", { ref: containerRef, className: [ROOT_CLASS, className].filter(Boolean).join(" "), style: { ...styles14.container, ...style }, children: [
4755
- hasConversation && messages.length > 0 && /* @__PURE__ */ jsx("div", { style: styles14.preview, children: messages.slice(-messageLimit).map((message) => /* @__PURE__ */ jsx(InlineMessageRow, { message }, message.id)) }),
4943
+ return /* @__PURE__ */ jsxs("div", { ref: containerRef, className: [ROOT_CLASS, className].filter(Boolean).join(" "), style: containerStyle, children: [
4944
+ hasConversation && messages.length > 0 && /* @__PURE__ */ jsx("div", { style: { ...styles14.preview, borderBottomColor: pal.previewBorder }, children: messages.slice(-messageLimit).map((message) => /* @__PURE__ */ jsx(InlineMessageRow, { message, pal }, message.id)) }),
4756
4945
  error && /* @__PURE__ */ jsx("div", { style: styles14.error, children: error }),
4757
4946
  /* @__PURE__ */ jsx(
4758
4947
  InlineInputBar,
@@ -4763,33 +4952,34 @@ function CollabInline({
4763
4952
  unreadCount: hasConversation ? unreadCount : 0,
4764
4953
  isLive: isSubscribed,
4765
4954
  showExpand: hasConversation && !!onExpand,
4766
- onExpand
4955
+ onExpand,
4956
+ pal
4767
4957
  }
4768
4958
  )
4769
4959
  ] });
4770
4960
  }
4771
- function InlineMessageRow({ message }) {
4961
+ function InlineMessageRow({ message, pal }) {
4772
4962
  if (message.type === "system") {
4773
- return /* @__PURE__ */ jsx("div", { style: styles14.systemRow, children: /* @__PURE__ */ jsx("span", { style: styles14.systemText, children: message.body }) });
4963
+ return /* @__PURE__ */ jsx("div", { style: styles14.systemRow, children: /* @__PURE__ */ jsx("span", { style: { ...styles14.systemText, color: pal.systemText }, children: message.body }) });
4774
4964
  }
4775
4965
  if (message.type === "audio" && message.mediaUrl) {
4776
4966
  return /* @__PURE__ */ jsxs("div", { style: styles14.messageRow, children: [
4777
4967
  /* @__PURE__ */ jsx(RoleDot, { role: message.senderRole }),
4778
- /* @__PURE__ */ jsxs("span", { style: styles14.senderName, children: [
4968
+ /* @__PURE__ */ jsxs("span", { style: { ...styles14.senderName, color: pal.senderName }, children: [
4779
4969
  message.senderName,
4780
4970
  ":"
4781
4971
  ] }),
4782
- /* @__PURE__ */ jsx(InlineAudioPlayer, { url: message.mediaUrl, duration: message.mediaDuration })
4972
+ /* @__PURE__ */ jsx(InlineAudioPlayer, { url: message.mediaUrl, duration: message.mediaDuration, pal })
4783
4973
  ] });
4784
4974
  }
4785
4975
  const preview = renderMessagePreview(message);
4786
4976
  return /* @__PURE__ */ jsxs("div", { style: styles14.messageRow, children: [
4787
4977
  /* @__PURE__ */ jsx(RoleDot, { role: message.senderRole }),
4788
- /* @__PURE__ */ jsxs("span", { style: styles14.senderName, children: [
4978
+ /* @__PURE__ */ jsxs("span", { style: { ...styles14.senderName, color: pal.senderName }, children: [
4789
4979
  message.senderName,
4790
4980
  ":"
4791
4981
  ] }),
4792
- /* @__PURE__ */ jsx("span", { style: styles14.messageText, children: preview })
4982
+ /* @__PURE__ */ jsx("span", { style: { ...styles14.messageText, color: pal.messageText }, children: preview })
4793
4983
  ] });
4794
4984
  }
4795
4985
  function RoleDot({ role }) {
@@ -4805,7 +4995,11 @@ function RoleDot({ role }) {
4805
4995
  };
4806
4996
  return /* @__PURE__ */ jsx("span", { style: { ...styles14.roleDot, backgroundColor: colors[role] } });
4807
4997
  }
4808
- function InlineAudioPlayer({ url, duration }) {
4998
+ function InlineAudioPlayer({
4999
+ url,
5000
+ duration,
5001
+ pal
5002
+ }) {
4809
5003
  const audioRef = useRef(null);
4810
5004
  const [isPlaying, setIsPlaying] = useState(false);
4811
5005
  const [currentTime, setCurrentTime] = useState(0);
@@ -4847,7 +5041,7 @@ function InlineAudioPlayer({ url, duration }) {
4847
5041
  const total = duration ?? 0;
4848
5042
  const remaining = Math.max(0, total - Math.floor(currentTime));
4849
5043
  const displayTime = isPlaying ? remaining : total;
4850
- return /* @__PURE__ */ jsxs("span", { style: styles14.audioPlayer, children: [
5044
+ return /* @__PURE__ */ jsxs("span", { style: { ...styles14.audioPlayer, backgroundColor: pal.audioBg, borderColor: pal.audioBg }, children: [
4851
5045
  /* @__PURE__ */ jsx(
4852
5046
  "button",
4853
5047
  {
@@ -4859,15 +5053,15 @@ function InlineAudioPlayer({ url, duration }) {
4859
5053
  }
4860
5054
  ),
4861
5055
  /* @__PURE__ */ jsxs("span", { style: styles14.audioWaveform, children: [
4862
- /* @__PURE__ */ jsx("span", { style: { ...styles14.waveBar, height: "40%" } }),
4863
- /* @__PURE__ */ jsx("span", { style: { ...styles14.waveBar, height: "80%" } }),
4864
- /* @__PURE__ */ jsx("span", { style: { ...styles14.waveBar, height: "60%" } }),
4865
- /* @__PURE__ */ jsx("span", { style: { ...styles14.waveBar, height: "90%" } }),
4866
- /* @__PURE__ */ jsx("span", { style: { ...styles14.waveBar, height: "50%" } }),
4867
- /* @__PURE__ */ jsx("span", { style: { ...styles14.waveBar, height: "70%" } }),
4868
- /* @__PURE__ */ jsx("span", { style: { ...styles14.waveBar, height: "40%" } })
5056
+ /* @__PURE__ */ jsx("span", { style: { ...styles14.waveBar, height: "40%", backgroundColor: pal.audioFg } }),
5057
+ /* @__PURE__ */ jsx("span", { style: { ...styles14.waveBar, height: "80%", backgroundColor: pal.audioFg } }),
5058
+ /* @__PURE__ */ jsx("span", { style: { ...styles14.waveBar, height: "60%", backgroundColor: pal.audioFg } }),
5059
+ /* @__PURE__ */ jsx("span", { style: { ...styles14.waveBar, height: "90%", backgroundColor: pal.audioFg } }),
5060
+ /* @__PURE__ */ jsx("span", { style: { ...styles14.waveBar, height: "50%", backgroundColor: pal.audioFg } }),
5061
+ /* @__PURE__ */ jsx("span", { style: { ...styles14.waveBar, height: "70%", backgroundColor: pal.audioFg } }),
5062
+ /* @__PURE__ */ jsx("span", { style: { ...styles14.waveBar, height: "40%", backgroundColor: pal.audioFg } })
4869
5063
  ] }),
4870
- /* @__PURE__ */ jsx("span", { style: styles14.audioDuration, children: formatDuration2(displayTime) }),
5064
+ /* @__PURE__ */ jsx("span", { style: { ...styles14.audioDuration, color: pal.audioFg }, children: formatDuration2(displayTime) }),
4871
5065
  /* @__PURE__ */ jsx("audio", { ref: audioRef, src: url, preload: "metadata" })
4872
5066
  ] });
4873
5067
  }
@@ -4897,7 +5091,8 @@ function InlineInputBar({
4897
5091
  unreadCount,
4898
5092
  isLive,
4899
5093
  showExpand,
4900
- onExpand
5094
+ onExpand,
5095
+ pal
4901
5096
  }) {
4902
5097
  const [text, setText] = useState("");
4903
5098
  const [isSending, setIsSending] = useState(false);
@@ -4934,7 +5129,12 @@ function InlineInputBar({
4934
5129
  onKeyDown: handleKeyDown,
4935
5130
  placeholder,
4936
5131
  disabled: isSending,
4937
- style: styles14.input
5132
+ style: {
5133
+ ...styles14.input,
5134
+ backgroundColor: pal.inputBg,
5135
+ borderColor: pal.inputBorder,
5136
+ color: pal.inputText
5137
+ }
4938
5138
  }
4939
5139
  ),
4940
5140
  unreadCount > 0 && /* @__PURE__ */ jsx("span", { style: styles14.unreadBadge, title: `${unreadCount} unread`, children: unreadCount > 99 ? "99+" : unreadCount }),
@@ -4954,7 +5154,11 @@ function InlineInputBar({
4954
5154
  "button",
4955
5155
  {
4956
5156
  onClick: onExpand,
4957
- style: styles14.expandButton,
5157
+ style: {
5158
+ ...styles14.expandButton,
5159
+ borderColor: pal.expandBorder,
5160
+ color: pal.expandColor
5161
+ },
4958
5162
  title: "Expand to full chat",
4959
5163
  type: "button",
4960
5164
  children: "\u26F6"
@@ -5501,8 +5705,11 @@ function ConversationList({
5501
5705
  onSelect,
5502
5706
  className
5503
5707
  }) {
5708
+ const { totalUnread } = useCollab();
5504
5709
  const [query, setQuery] = useState("");
5505
- const [filter, setFilter] = useState("all");
5710
+ const [filter, setFilter] = useState(
5711
+ () => totalUnread > 0 ? "unread" : "all"
5712
+ );
5506
5713
  const [searchFocused, setSearchFocused] = useState(false);
5507
5714
  const unreadCount = useMemo(
5508
5715
  () => conversations.filter((c) => c.unreadCount > 0).length,
@@ -6132,7 +6339,7 @@ function useUnreadCount() {
6132
6339
  const [counts, setCounts] = useState({});
6133
6340
  useEffect(() => {
6134
6341
  socket.onUnreadCountUpdate((serverCounts) => {
6135
- setCounts(serverCounts);
6342
+ setCounts(serverCounts.conversation);
6136
6343
  });
6137
6344
  }, [socket]);
6138
6345
  const getCountForConversation = useCallback(