@natoe/colab 0.1.13 → 0.1.14

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
@@ -60,6 +60,13 @@ function toCamelKey(key) {
60
60
  var CollabSocket = class {
61
61
  constructor() {
62
62
  this.socket = null;
63
+ /**
64
+ * Conversation channels keyed by id. Each entry is reference-counted so
65
+ * multiple surfaces (e.g. inline chat + expanded panel mounted at once
66
+ * for the same conversation) can coexist without one's `leaveConversation`
67
+ * tearing the channel out from under the other. See `joinConversation`
68
+ * and the returned `ChannelSubscription.release`.
69
+ */
63
70
  this.channels = /* @__PURE__ */ new Map();
64
71
  this.presences = /* @__PURE__ */ new Map();
65
72
  this.userChannel = null;
@@ -130,59 +137,85 @@ var CollabSocket = class {
130
137
  onUnreadCountUpdate(callback) {
131
138
  this.onUnreadUpdate = callback;
132
139
  }
133
- /** Join a conversation channel and subscribe to events */
140
+ /**
141
+ * Join a conversation channel and subscribe to events.
142
+ *
143
+ * Reference-counted: multiple callers can join the same conversation
144
+ * (e.g. inline preview + expanded panel mounted side-by-side). Each
145
+ * call binds its own listeners and gets back a `ChannelSubscription`.
146
+ * The underlying channel only `.leave()`s the server when the LAST
147
+ * subscriber calls `release()`.
148
+ */
134
149
  joinConversation(conversationId, callbacks) {
135
150
  if (!this.socket) return null;
136
- if (this.channels.has(conversationId)) {
137
- return this.channels.get(conversationId);
151
+ let entry = this.channels.get(conversationId);
152
+ if (!entry) {
153
+ const channel2 = this.socket.channel(`conversation:${conversationId}`, {});
154
+ channel2.join().receive("ok", () => {
155
+ }).receive("error", (reason) => {
156
+ this.config?.onError?.({
157
+ code: "CHANNEL_JOIN_ERROR",
158
+ message: `Failed to join conversation ${conversationId}`,
159
+ details: reason
160
+ });
161
+ });
162
+ entry = { channel: channel2, subscribers: 0, presence: null };
163
+ this.channels.set(conversationId, entry);
138
164
  }
139
- const channel = this.socket.channel(`conversation:${conversationId}`, {});
165
+ const channel = entry.channel;
166
+ const refs = [];
167
+ const bind = (event, fn) => {
168
+ const ref = channel.on(event, fn);
169
+ refs.push({ event, ref });
170
+ };
140
171
  if (callbacks.onMessage) {
141
- channel.on(EVENTS.MESSAGE_NEW, (payload) => {
172
+ bind(EVENTS.MESSAGE_NEW, (payload) => {
142
173
  callbacks.onMessage(snakeToCamel(payload));
143
174
  });
144
175
  }
145
176
  if (callbacks.onTyping) {
146
- channel.on(EVENTS.USER_TYPING, (payload) => {
177
+ bind(EVENTS.USER_TYPING, (payload) => {
147
178
  callbacks.onTyping(snakeToCamel(payload));
148
179
  });
149
180
  }
150
181
  if (callbacks.onUserJoined) {
151
- channel.on(EVENTS.USER_JOINED, (payload) => {
182
+ bind(EVENTS.USER_JOINED, (payload) => {
152
183
  callbacks.onUserJoined(snakeToCamel(payload));
153
184
  });
154
185
  }
155
186
  if (callbacks.onUserLeft) {
156
- channel.on(EVENTS.USER_LEFT, (payload) => {
187
+ bind(EVENTS.USER_LEFT, (payload) => {
157
188
  callbacks.onUserLeft(snakeToCamel(payload));
158
189
  });
159
190
  }
160
191
  if (callbacks.onChannelUpdated) {
161
- channel.on(EVENTS.CHANNEL_UPDATED, (payload) => {
192
+ bind(EVENTS.CHANNEL_UPDATED, (payload) => {
162
193
  callbacks.onChannelUpdated(snakeToCamel(payload));
163
194
  });
164
195
  }
165
196
  if (callbacks.onChannelDeleted) {
166
- channel.on(EVENTS.CHANNEL_DELETED, () => {
197
+ bind(EVENTS.CHANNEL_DELETED, () => {
167
198
  callbacks.onChannelDeleted();
168
199
  });
169
200
  }
170
201
  if (callbacks.onMessageRead) {
171
- channel.on(EVENTS.MESSAGE_READ, (payload) => {
172
- callbacks.onMessageRead(snakeToCamel(payload));
202
+ bind(EVENTS.MESSAGE_READ, (payload) => {
203
+ callbacks.onMessageRead(
204
+ snakeToCamel(payload)
205
+ );
173
206
  });
174
207
  }
175
208
  if (callbacks.onMessagePinned) {
176
- channel.on(EVENTS.MESSAGE_PINNED, (payload) => {
209
+ bind(EVENTS.MESSAGE_PINNED, (payload) => {
177
210
  callbacks.onMessagePinned(snakeToCamel(payload));
178
211
  });
179
212
  }
180
213
  if (callbacks.onMessageUnpinned) {
181
- channel.on(EVENTS.MESSAGE_UNPINNED, (payload) => {
214
+ bind(EVENTS.MESSAGE_UNPINNED, (payload) => {
182
215
  callbacks.onMessageUnpinned(snakeToCamel(payload));
183
216
  });
184
217
  }
185
- if (callbacks.onPresence) {
218
+ if (callbacks.onPresence && !entry.presence) {
186
219
  const presence = new Presence(channel);
187
220
  presence.onSync(() => {
188
221
  const online = {};
@@ -191,27 +224,45 @@ var CollabSocket = class {
191
224
  });
192
225
  callbacks.onPresence(online);
193
226
  });
227
+ entry.presence = presence;
194
228
  this.presences.set(conversationId, presence);
195
229
  }
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;
230
+ entry.subscribers += 1;
231
+ let released = false;
232
+ const release = () => {
233
+ if (released) return;
234
+ released = true;
235
+ const current = this.channels.get(conversationId);
236
+ if (!current) return;
237
+ for (const { event, ref } of refs) {
238
+ current.channel.off(event, ref);
239
+ }
240
+ current.subscribers -= 1;
241
+ if (current.subscribers <= 0) {
242
+ current.channel.leave();
243
+ this.channels.delete(conversationId);
244
+ this.presences.delete(conversationId);
245
+ }
246
+ };
247
+ return { channel, release };
206
248
  }
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
- }
249
+ /**
250
+ * @deprecated Use the `release()` method returned by `joinConversation()`.
251
+ * Kept as a no-op so older callers don't throw — but it cannot identify
252
+ * which subscriber should leave, so it silently does nothing. Any code
253
+ * still calling this will leak listeners and prevent the channel from
254
+ * ever being torn down. Migrate to the subscription handle.
255
+ */
256
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
257
+ leaveConversation(_conversationId) {
258
+ }
259
+ /**
260
+ * Look up the underlying Phoenix Channel for a conversation, if any
261
+ * subscriber is still holding it. All send/push paths go through this
262
+ * helper so the refcounted entry shape is contained to joinConversation.
263
+ */
264
+ getChannel(conversationId) {
265
+ return this.channels.get(conversationId)?.channel ?? null;
215
266
  }
216
267
  /** Send a message to a conversation.
217
268
  *
@@ -223,7 +274,7 @@ var CollabSocket = class {
223
274
  */
224
275
  sendMessage(conversationId, payload) {
225
276
  return new Promise((resolve, reject) => {
226
- const channel = this.channels.get(conversationId);
277
+ const channel = this.getChannel(conversationId);
227
278
  if (!channel) {
228
279
  reject({ code: "NOT_JOINED", message: "Not joined to this conversation" });
229
280
  return;
@@ -246,7 +297,7 @@ var CollabSocket = class {
246
297
  }
247
298
  /** Broadcast typing indicator */
248
299
  sendTyping(conversationId, isTyping) {
249
- const channel = this.channels.get(conversationId);
300
+ const channel = this.getChannel(conversationId);
250
301
  channel?.push(EVENTS.USER_TYPING, {
251
302
  userId: this.config?.userId,
252
303
  userName: this.config?.userName,
@@ -255,7 +306,7 @@ var CollabSocket = class {
255
306
  }
256
307
  /** Mark messages as read */
257
308
  markAsRead(conversationId, messageId) {
258
- const channel = this.channels.get(conversationId);
309
+ const channel = this.getChannel(conversationId);
259
310
  channel?.push(EVENTS.MESSAGE_READ, {
260
311
  messageId,
261
312
  userId: this.config?.userId
@@ -289,7 +340,7 @@ var CollabSocket = class {
289
340
  /** Generic push with promise wrapper */
290
341
  channelPush(conversationId, event, payload) {
291
342
  return new Promise((resolve, reject) => {
292
- const channel = this.channels.get(conversationId);
343
+ const channel = this.getChannel(conversationId);
293
344
  if (!channel) {
294
345
  reject({ code: "NOT_JOINED", message: "Not joined to this conversation" });
295
346
  return;
@@ -306,7 +357,7 @@ var CollabSocket = class {
306
357
  }
307
358
  /** Disconnect socket and leave all channels */
308
359
  disconnect() {
309
- this.channels.forEach((channel) => channel.leave());
360
+ this.channels.forEach((entry) => entry.channel.leave());
310
361
  this.channels.clear();
311
362
  this.presences.clear();
312
363
  this.userChannel?.leave();
@@ -361,7 +412,21 @@ var THEME_VAR = {
361
412
  dangerBg: "--natoe-colab-danger-bg",
362
413
  dangerBorder: "--natoe-colab-danger-border",
363
414
  dangerFg: "--natoe-colab-danger-fg",
364
- fontStack: "--natoe-colab-font-stack"
415
+ fontStack: "--natoe-colab-font-stack",
416
+ // Neutral surface palette — promoted to CSS vars so consumers (e.g.
417
+ // CollabPanel themeMode='dark') can flip the whole grayscale at a subtree
418
+ // level without re-themeing every component.
419
+ white: "--natoe-colab-white",
420
+ neutral50: "--natoe-colab-neutral-50",
421
+ neutral100: "--natoe-colab-neutral-100",
422
+ neutral200: "--natoe-colab-neutral-200",
423
+ neutral300: "--natoe-colab-neutral-300",
424
+ neutral400: "--natoe-colab-neutral-400",
425
+ neutral500: "--natoe-colab-neutral-500",
426
+ neutral600: "--natoe-colab-neutral-600",
427
+ neutral700: "--natoe-colab-neutral-700",
428
+ neutral800: "--natoe-colab-neutral-800",
429
+ neutral900: "--natoe-colab-neutral-900"
365
430
  };
366
431
  var THEME_DEFAULTS = {
367
432
  primary: "#2563eb",
@@ -374,18 +439,9 @@ var THEME_DEFAULTS = {
374
439
  dangerBg: "#fef2f2",
375
440
  dangerBorder: "#fecaca",
376
441
  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.
442
+ fontStack: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
443
+ // Neutral grayscale — these defaults are the same hex literals the
444
+ // package shipped with; they're now overridable per-subtree.
389
445
  white: "#ffffff",
390
446
  neutral50: "#f9fafb",
391
447
  neutral100: "#f3f4f6",
@@ -396,7 +452,32 @@ var COLOR = {
396
452
  neutral600: "#4b5563",
397
453
  neutral700: "#374151",
398
454
  neutral800: "#1f2937",
399
- neutral900: "#111827",
455
+ neutral900: "#111827"
456
+ };
457
+ var cssVar = (name, fallback) => `var(${name}, ${fallback})`;
458
+ var COLOR = {
459
+ // Brand — themeable
460
+ primary: cssVar(THEME_VAR.primary, THEME_DEFAULTS.primary),
461
+ primaryHover: cssVar(THEME_VAR.primaryHover, THEME_DEFAULTS.primaryHover),
462
+ /** Tinted surface for chips/cards on brand-coloured states. */
463
+ primaryBg: cssVar(THEME_VAR.primaryBg, THEME_DEFAULTS.primaryBg),
464
+ primaryFg: cssVar(THEME_VAR.primaryFg, THEME_DEFAULTS.primaryFg),
465
+ // Neutral grayscale — themeable via CSS variables. Light-mode defaults
466
+ // taken from Tailwind's zinc-leaning slate to match the package's
467
+ // existing tonal balance; a host can override the entire palette by
468
+ // setting --natoe-colab-* values on any ancestor element (used by
469
+ // CollabPanel themeMode='dark' for the viewer's left-panel surface).
470
+ white: cssVar(THEME_VAR.white, THEME_DEFAULTS.white),
471
+ neutral50: cssVar(THEME_VAR.neutral50, THEME_DEFAULTS.neutral50),
472
+ neutral100: cssVar(THEME_VAR.neutral100, THEME_DEFAULTS.neutral100),
473
+ neutral200: cssVar(THEME_VAR.neutral200, THEME_DEFAULTS.neutral200),
474
+ neutral300: cssVar(THEME_VAR.neutral300, THEME_DEFAULTS.neutral300),
475
+ neutral400: cssVar(THEME_VAR.neutral400, THEME_DEFAULTS.neutral400),
476
+ neutral500: cssVar(THEME_VAR.neutral500, THEME_DEFAULTS.neutral500),
477
+ neutral600: cssVar(THEME_VAR.neutral600, THEME_DEFAULTS.neutral600),
478
+ neutral700: cssVar(THEME_VAR.neutral700, THEME_DEFAULTS.neutral700),
479
+ neutral800: cssVar(THEME_VAR.neutral800, THEME_DEFAULTS.neutral800),
480
+ neutral900: cssVar(THEME_VAR.neutral900, THEME_DEFAULTS.neutral900),
400
481
  slate800: "#1e293b",
401
482
  // Semantic — themeable
402
483
  success: cssVar(THEME_VAR.success, THEME_DEFAULTS.success),
@@ -775,6 +856,7 @@ function useConversation({
775
856
  const [isConnected, setIsConnected] = useState(false);
776
857
  const [replyTo, setReplyTo] = useState(null);
777
858
  const joinedConversationId = useRef(null);
859
+ const channelSubscription = useRef(null);
778
860
  const typingTimers = useRef(/* @__PURE__ */ new Map());
779
861
  const ensureConversationInFlight = useRef(null);
780
862
  const markedReadIdsRef = useRef(/* @__PURE__ */ new Set());
@@ -788,7 +870,9 @@ function useConversation({
788
870
  const joinChannel = useCallback(
789
871
  (conv) => {
790
872
  if (joinedConversationId.current === conv.id) return;
791
- socket.joinConversation(conv.id, {
873
+ channelSubscription.current?.release();
874
+ channelSubscription.current = null;
875
+ const subscription = socket.joinConversation(conv.id, {
792
876
  // Dedupe by id so an optimistic message (added client-side on send)
793
877
  // doesn't double up when the server's broadcast arrives.
794
878
  onMessage: (msg) => setMessages((prev) => {
@@ -838,6 +922,8 @@ function useConversation({
838
922
  onChannelDeleted: () => {
839
923
  setConversation(null);
840
924
  setMessages([]);
925
+ channelSubscription.current?.release();
926
+ channelSubscription.current = null;
841
927
  joinedConversationId.current = null;
842
928
  setIsConnected(false);
843
929
  },
@@ -863,6 +949,7 @@ function useConversation({
863
949
  );
864
950
  }
865
951
  });
952
+ channelSubscription.current = subscription;
866
953
  joinedConversationId.current = conv.id;
867
954
  setIsConnected(true);
868
955
  },
@@ -923,10 +1010,9 @@ function useConversation({
923
1010
  init();
924
1011
  return () => {
925
1012
  cancelled = true;
926
- if (joinedConversationId.current) {
927
- socket.leaveConversation(joinedConversationId.current);
928
- joinedConversationId.current = null;
929
- }
1013
+ channelSubscription.current?.release();
1014
+ channelSubscription.current = null;
1015
+ joinedConversationId.current = null;
930
1016
  typingTimers.current.forEach((timer) => clearTimeout(timer));
931
1017
  typingTimers.current.clear();
932
1018
  markedReadIdsRef.current.clear();
@@ -1342,17 +1428,26 @@ function DicomIcon({ size = 18, color = "currentColor" }) {
1342
1428
  }
1343
1429
  );
1344
1430
  }
1345
- function SettingsIcon({ size = 18, color = "currentColor" }) {
1346
- return /* @__PURE__ */ jsx(
1431
+ function PeopleIcon({ size = 18, color = "currentColor" }) {
1432
+ return /* @__PURE__ */ jsxs(
1347
1433
  "svg",
1348
1434
  {
1349
1435
  xmlns: "http://www.w3.org/2000/svg",
1350
1436
  width: size,
1351
1437
  height: size,
1352
1438
  viewBox: "0 0 24 24",
1353
- fill: color,
1439
+ fill: "none",
1440
+ stroke: color,
1441
+ strokeWidth: "2",
1442
+ strokeLinecap: "round",
1443
+ strokeLinejoin: "round",
1354
1444
  "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" })
1445
+ children: [
1446
+ /* @__PURE__ */ jsx("path", { d: "M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" }),
1447
+ /* @__PURE__ */ jsx("circle", { cx: "9", cy: "7", r: "4" }),
1448
+ /* @__PURE__ */ jsx("path", { d: "M22 21v-2a4 4 0 0 0-3-3.87" }),
1449
+ /* @__PURE__ */ jsx("path", { d: "M16 3.13a4 4 0 0 1 0 7.75" })
1450
+ ]
1356
1451
  }
1357
1452
  );
1358
1453
  }
@@ -1377,6 +1472,7 @@ function resolveDisplayName(patientData, displayName) {
1377
1472
  }
1378
1473
  function PatientHeader({
1379
1474
  patientData,
1475
+ participants,
1380
1476
  onOpenDicom,
1381
1477
  onOpenSettings,
1382
1478
  onBack,
@@ -1442,15 +1538,18 @@ function PatientHeader({
1442
1538
  ]
1443
1539
  }
1444
1540
  ),
1445
- onOpenSettings && /* @__PURE__ */ jsx(
1541
+ onOpenSettings && /* @__PURE__ */ jsxs(
1446
1542
  "button",
1447
1543
  {
1448
1544
  onClick: onOpenSettings,
1449
1545
  style: styles.settingsIconButton,
1450
1546
  type: "button",
1451
- "aria-label": "Open channel settings",
1452
- title: "Settings",
1453
- children: /* @__PURE__ */ jsx(SettingsIcon, { size: 18, color: COLOR.neutral600 })
1547
+ "aria-label": `Open channel settings (${participants.length} participants)`,
1548
+ title: "Channel participants",
1549
+ children: [
1550
+ /* @__PURE__ */ jsx(PeopleIcon, { size: 18, color: COLOR.neutral600 }),
1551
+ /* @__PURE__ */ jsx("span", { style: styles.participantCount, children: participants.length })
1552
+ ]
1454
1553
  }
1455
1554
  )
1456
1555
  ] })
@@ -1558,20 +1657,28 @@ var styles = {
1558
1657
  borderRadius: RADIUS.lg,
1559
1658
  cursor: "pointer"
1560
1659
  },
1561
- // Settings is icon-only (40x40 tile) since it's a secondary action
1562
- // and View DICOM already carries a label.
1660
+ // Channel-info button: people icon + participant count. Click opens the
1661
+ // channel settings overlay (kept on the same handler as before so hosts
1662
+ // don't have to re-wire — the affordance just looks like a "members"
1663
+ // pill now instead of a gear).
1563
1664
  settingsIconButton: {
1564
- width: SIZE.control,
1565
- height: SIZE.control,
1665
+ minHeight: SIZE.control,
1566
1666
  display: "inline-flex",
1567
1667
  alignItems: "center",
1568
- justifyContent: "center",
1668
+ gap: SPACE.S2,
1669
+ padding: `0 ${SPACE.S3}`,
1569
1670
  backgroundColor: COLOR.white,
1570
1671
  border: `1px solid ${COLOR.neutral200}`,
1571
1672
  borderRadius: RADIUS.lg,
1572
1673
  color: COLOR.neutral600,
1573
1674
  cursor: "pointer",
1574
1675
  flexShrink: 0
1676
+ },
1677
+ participantCount: {
1678
+ fontSize: FONT_SIZE.sm,
1679
+ fontWeight: FONT_WEIGHT.semibold,
1680
+ color: COLOR.neutral700,
1681
+ fontVariantNumeric: "tabular-nums"
1575
1682
  }
1576
1683
  };
1577
1684
  function ReplyQuoteBlock({
@@ -3992,6 +4099,20 @@ var styles12 = {
3992
4099
  }
3993
4100
  };
3994
4101
  ensureGlobalStyles();
4102
+ var DARK_THEME_OVERRIDES = {
4103
+ ["--natoe-colab-white"]: "#0b0b0c",
4104
+ ["--natoe-colab-neutral-50"]: "#18181c",
4105
+ ["--natoe-colab-neutral-100"]: "#1f1f23",
4106
+ ["--natoe-colab-neutral-200"]: "#2a2a2e",
4107
+ ["--natoe-colab-neutral-300"]: "#3f3f44",
4108
+ ["--natoe-colab-neutral-400"]: "#6b7280",
4109
+ ["--natoe-colab-neutral-500"]: "#9ca3af",
4110
+ ["--natoe-colab-neutral-600"]: "#cbd5e1",
4111
+ ["--natoe-colab-neutral-700"]: "#e5e7eb",
4112
+ ["--natoe-colab-neutral-800"]: "#f3f4f6",
4113
+ ["--natoe-colab-neutral-900"]: "#ffffff",
4114
+ ["--natoe-colab-primary-bg"]: "rgba(37, 99, 235, 0.18)"
4115
+ };
3995
4116
  function CollabPanel({
3996
4117
  orderId,
3997
4118
  patientData,
@@ -4000,6 +4121,7 @@ function CollabPanel({
4000
4121
  onBack,
4001
4122
  hidePatientName = false,
4002
4123
  onConversationChange,
4124
+ themeMode = "light",
4003
4125
  className,
4004
4126
  style
4005
4127
  }) {
@@ -4075,16 +4197,21 @@ function CollabPanel({
4075
4197
  }
4076
4198
  };
4077
4199
  const pinDisabled = pinnedMessages.length >= MAX_PINNED_MESSAGES;
4200
+ const containerStyle = {
4201
+ ...panelStyles.container,
4202
+ ...themeMode === "dark" ? DARK_THEME_OVERRIDES : {},
4203
+ ...style
4204
+ };
4078
4205
  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: [
4206
+ return /* @__PURE__ */ jsx("div", { className: [ROOT_CLASS, className].filter(Boolean).join(" "), style: containerStyle, children: /* @__PURE__ */ jsxs("div", { style: panelStyles.errorState, children: [
4080
4207
  /* @__PURE__ */ jsx("p", { style: panelStyles.errorTitle, children: "Unable to load conversation" }),
4081
4208
  /* @__PURE__ */ jsx("p", { style: panelStyles.errorMessage, children: error })
4082
4209
  ] }) });
4083
4210
  }
4084
4211
  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..." }) });
4212
+ return /* @__PURE__ */ jsx("div", { className: [ROOT_CLASS, className].filter(Boolean).join(" "), style: containerStyle, children: /* @__PURE__ */ jsx("div", { style: panelStyles.loadingState, children: "Loading conversation..." }) });
4086
4213
  }
4087
- return /* @__PURE__ */ jsxs("div", { className, style: { ...panelStyles.container, ...style }, children: [
4214
+ return /* @__PURE__ */ jsxs("div", { className: [ROOT_CLASS, className].filter(Boolean).join(" "), style: containerStyle, children: [
4088
4215
  showSettings && conversation ? /* @__PURE__ */ jsx(
4089
4216
  ChannelSettings,
4090
4217
  {
@@ -4520,6 +4647,7 @@ function useInlineCollab({
4520
4647
  const elementRef = useRef(null);
4521
4648
  const observerRef = useRef(null);
4522
4649
  const subscribedConversationIdRef = useRef(null);
4650
+ const channelSubscriptionRef = useRef(null);
4523
4651
  const trimToLimit = useCallback(
4524
4652
  (msgs) => msgs.length > messageLimit ? msgs.slice(msgs.length - messageLimit) : msgs,
4525
4653
  [messageLimit]
@@ -4557,7 +4685,9 @@ function useInlineCollab({
4557
4685
  const subscribe = useCallback(
4558
4686
  (conversationId) => {
4559
4687
  if (subscribedConversationIdRef.current === conversationId) return;
4560
- socket.joinConversation(conversationId, {
4688
+ channelSubscriptionRef.current?.release();
4689
+ channelSubscriptionRef.current = null;
4690
+ const subscription = socket.joinConversation(conversationId, {
4561
4691
  onMessage: (msg) => {
4562
4692
  setMessages((prev) => trimToLimit([...prev, msg]));
4563
4693
  if (msg.senderId !== config.userId) {
@@ -4573,19 +4703,18 @@ function useInlineCollab({
4573
4703
  setParticipants((prev) => prev.filter((p) => p.userId !== participant.userId));
4574
4704
  }
4575
4705
  });
4706
+ channelSubscriptionRef.current = subscription;
4576
4707
  subscribedConversationIdRef.current = conversationId;
4577
4708
  setIsSubscribed(true);
4578
4709
  },
4579
4710
  [socket, trimToLimit, config.userId]
4580
4711
  );
4581
4712
  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]);
4713
+ channelSubscriptionRef.current?.release();
4714
+ channelSubscriptionRef.current = null;
4715
+ subscribedConversationIdRef.current = null;
4716
+ setIsSubscribed(false);
4717
+ }, []);
4589
4718
  const containerRef = useCallback(
4590
4719
  (element) => {
4591
4720
  if (observerRef.current) {
@@ -4727,16 +4856,52 @@ function useInlineCollab({
4727
4856
  };
4728
4857
  }
4729
4858
  ensureGlobalStyles();
4859
+ function palette(mode) {
4860
+ if (mode === "dark") {
4861
+ return {
4862
+ bg: "transparent",
4863
+ border: "transparent",
4864
+ previewBorder: "#1f1f23",
4865
+ senderName: "#f3f4f6",
4866
+ messageText: "#cbd5e1",
4867
+ systemText: "#6b7280",
4868
+ inputBg: "#18181c",
4869
+ inputBorder: "#2a2a2e",
4870
+ inputText: "#e5e7eb",
4871
+ audioBg: "rgba(37, 99, 235, 0.18)",
4872
+ audioFg: "#93c5fd",
4873
+ expandBorder: "#2a2a2e",
4874
+ expandColor: "#9ca3af"
4875
+ };
4876
+ }
4877
+ return {
4878
+ bg: COLOR.white,
4879
+ border: COLOR.neutral200,
4880
+ previewBorder: COLOR.neutral100,
4881
+ senderName: COLOR.neutral700,
4882
+ messageText: COLOR.neutral600,
4883
+ systemText: COLOR.neutral400,
4884
+ inputBg: "transparent",
4885
+ inputBorder: COLOR.neutral200,
4886
+ inputText: COLOR.neutral900,
4887
+ audioBg: COLOR.primaryBg,
4888
+ audioFg: COLOR.primary,
4889
+ expandBorder: COLOR.neutral200,
4890
+ expandColor: COLOR.neutral500
4891
+ };
4892
+ }
4730
4893
  function CollabInline({
4731
4894
  orderId,
4732
4895
  patientData,
4733
4896
  participantIds,
4734
4897
  onExpand,
4735
- messageLimit = 5,
4898
+ messageLimit = 1,
4736
4899
  placeholder = "Type a message about this case...",
4900
+ mode = "light",
4737
4901
  className,
4738
4902
  style
4739
4903
  }) {
4904
+ const pal = palette(mode);
4740
4905
  const {
4741
4906
  hasConversation,
4742
4907
  messages,
@@ -4748,11 +4913,17 @@ function CollabInline({
4748
4913
  sendAudioMessage,
4749
4914
  containerRef
4750
4915
  } = useInlineCollab({ orderId, patientData, participantIds, messageLimit });
4916
+ const containerStyle = {
4917
+ ...styles14.container,
4918
+ backgroundColor: pal.bg,
4919
+ borderColor: pal.border,
4920
+ ...style
4921
+ };
4751
4922
  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..." }) });
4923
+ return /* @__PURE__ */ jsx("div", { className: [ROOT_CLASS, className].filter(Boolean).join(" "), style: containerStyle, children: /* @__PURE__ */ jsx("div", { style: styles14.loadingState, children: "Loading..." }) });
4753
4924
  }
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)) }),
4925
+ return /* @__PURE__ */ jsxs("div", { ref: containerRef, className: [ROOT_CLASS, className].filter(Boolean).join(" "), style: containerStyle, children: [
4926
+ 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
4927
  error && /* @__PURE__ */ jsx("div", { style: styles14.error, children: error }),
4757
4928
  /* @__PURE__ */ jsx(
4758
4929
  InlineInputBar,
@@ -4763,33 +4934,34 @@ function CollabInline({
4763
4934
  unreadCount: hasConversation ? unreadCount : 0,
4764
4935
  isLive: isSubscribed,
4765
4936
  showExpand: hasConversation && !!onExpand,
4766
- onExpand
4937
+ onExpand,
4938
+ pal
4767
4939
  }
4768
4940
  )
4769
4941
  ] });
4770
4942
  }
4771
- function InlineMessageRow({ message }) {
4943
+ function InlineMessageRow({ message, pal }) {
4772
4944
  if (message.type === "system") {
4773
- return /* @__PURE__ */ jsx("div", { style: styles14.systemRow, children: /* @__PURE__ */ jsx("span", { style: styles14.systemText, children: message.body }) });
4945
+ return /* @__PURE__ */ jsx("div", { style: styles14.systemRow, children: /* @__PURE__ */ jsx("span", { style: { ...styles14.systemText, color: pal.systemText }, children: message.body }) });
4774
4946
  }
4775
4947
  if (message.type === "audio" && message.mediaUrl) {
4776
4948
  return /* @__PURE__ */ jsxs("div", { style: styles14.messageRow, children: [
4777
4949
  /* @__PURE__ */ jsx(RoleDot, { role: message.senderRole }),
4778
- /* @__PURE__ */ jsxs("span", { style: styles14.senderName, children: [
4950
+ /* @__PURE__ */ jsxs("span", { style: { ...styles14.senderName, color: pal.senderName }, children: [
4779
4951
  message.senderName,
4780
4952
  ":"
4781
4953
  ] }),
4782
- /* @__PURE__ */ jsx(InlineAudioPlayer, { url: message.mediaUrl, duration: message.mediaDuration })
4954
+ /* @__PURE__ */ jsx(InlineAudioPlayer, { url: message.mediaUrl, duration: message.mediaDuration, pal })
4783
4955
  ] });
4784
4956
  }
4785
4957
  const preview = renderMessagePreview(message);
4786
4958
  return /* @__PURE__ */ jsxs("div", { style: styles14.messageRow, children: [
4787
4959
  /* @__PURE__ */ jsx(RoleDot, { role: message.senderRole }),
4788
- /* @__PURE__ */ jsxs("span", { style: styles14.senderName, children: [
4960
+ /* @__PURE__ */ jsxs("span", { style: { ...styles14.senderName, color: pal.senderName }, children: [
4789
4961
  message.senderName,
4790
4962
  ":"
4791
4963
  ] }),
4792
- /* @__PURE__ */ jsx("span", { style: styles14.messageText, children: preview })
4964
+ /* @__PURE__ */ jsx("span", { style: { ...styles14.messageText, color: pal.messageText }, children: preview })
4793
4965
  ] });
4794
4966
  }
4795
4967
  function RoleDot({ role }) {
@@ -4805,7 +4977,11 @@ function RoleDot({ role }) {
4805
4977
  };
4806
4978
  return /* @__PURE__ */ jsx("span", { style: { ...styles14.roleDot, backgroundColor: colors[role] } });
4807
4979
  }
4808
- function InlineAudioPlayer({ url, duration }) {
4980
+ function InlineAudioPlayer({
4981
+ url,
4982
+ duration,
4983
+ pal
4984
+ }) {
4809
4985
  const audioRef = useRef(null);
4810
4986
  const [isPlaying, setIsPlaying] = useState(false);
4811
4987
  const [currentTime, setCurrentTime] = useState(0);
@@ -4847,7 +5023,7 @@ function InlineAudioPlayer({ url, duration }) {
4847
5023
  const total = duration ?? 0;
4848
5024
  const remaining = Math.max(0, total - Math.floor(currentTime));
4849
5025
  const displayTime = isPlaying ? remaining : total;
4850
- return /* @__PURE__ */ jsxs("span", { style: styles14.audioPlayer, children: [
5026
+ return /* @__PURE__ */ jsxs("span", { style: { ...styles14.audioPlayer, backgroundColor: pal.audioBg, borderColor: pal.audioBg }, children: [
4851
5027
  /* @__PURE__ */ jsx(
4852
5028
  "button",
4853
5029
  {
@@ -4859,15 +5035,15 @@ function InlineAudioPlayer({ url, duration }) {
4859
5035
  }
4860
5036
  ),
4861
5037
  /* @__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%" } })
5038
+ /* @__PURE__ */ jsx("span", { style: { ...styles14.waveBar, height: "40%", backgroundColor: pal.audioFg } }),
5039
+ /* @__PURE__ */ jsx("span", { style: { ...styles14.waveBar, height: "80%", backgroundColor: pal.audioFg } }),
5040
+ /* @__PURE__ */ jsx("span", { style: { ...styles14.waveBar, height: "60%", backgroundColor: pal.audioFg } }),
5041
+ /* @__PURE__ */ jsx("span", { style: { ...styles14.waveBar, height: "90%", backgroundColor: pal.audioFg } }),
5042
+ /* @__PURE__ */ jsx("span", { style: { ...styles14.waveBar, height: "50%", backgroundColor: pal.audioFg } }),
5043
+ /* @__PURE__ */ jsx("span", { style: { ...styles14.waveBar, height: "70%", backgroundColor: pal.audioFg } }),
5044
+ /* @__PURE__ */ jsx("span", { style: { ...styles14.waveBar, height: "40%", backgroundColor: pal.audioFg } })
4869
5045
  ] }),
4870
- /* @__PURE__ */ jsx("span", { style: styles14.audioDuration, children: formatDuration2(displayTime) }),
5046
+ /* @__PURE__ */ jsx("span", { style: { ...styles14.audioDuration, color: pal.audioFg }, children: formatDuration2(displayTime) }),
4871
5047
  /* @__PURE__ */ jsx("audio", { ref: audioRef, src: url, preload: "metadata" })
4872
5048
  ] });
4873
5049
  }
@@ -4897,7 +5073,8 @@ function InlineInputBar({
4897
5073
  unreadCount,
4898
5074
  isLive,
4899
5075
  showExpand,
4900
- onExpand
5076
+ onExpand,
5077
+ pal
4901
5078
  }) {
4902
5079
  const [text, setText] = useState("");
4903
5080
  const [isSending, setIsSending] = useState(false);
@@ -4934,7 +5111,12 @@ function InlineInputBar({
4934
5111
  onKeyDown: handleKeyDown,
4935
5112
  placeholder,
4936
5113
  disabled: isSending,
4937
- style: styles14.input
5114
+ style: {
5115
+ ...styles14.input,
5116
+ backgroundColor: pal.inputBg,
5117
+ borderColor: pal.inputBorder,
5118
+ color: pal.inputText
5119
+ }
4938
5120
  }
4939
5121
  ),
4940
5122
  unreadCount > 0 && /* @__PURE__ */ jsx("span", { style: styles14.unreadBadge, title: `${unreadCount} unread`, children: unreadCount > 99 ? "99+" : unreadCount }),
@@ -4954,7 +5136,11 @@ function InlineInputBar({
4954
5136
  "button",
4955
5137
  {
4956
5138
  onClick: onExpand,
4957
- style: styles14.expandButton,
5139
+ style: {
5140
+ ...styles14.expandButton,
5141
+ borderColor: pal.expandBorder,
5142
+ color: pal.expandColor
5143
+ },
4958
5144
  title: "Expand to full chat",
4959
5145
  type: "button",
4960
5146
  children: "\u26F6"
@@ -5501,8 +5687,11 @@ function ConversationList({
5501
5687
  onSelect,
5502
5688
  className
5503
5689
  }) {
5690
+ const { totalUnread } = useCollab();
5504
5691
  const [query, setQuery] = useState("");
5505
- const [filter, setFilter] = useState("all");
5692
+ const [filter, setFilter] = useState(
5693
+ () => totalUnread > 0 ? "unread" : "all"
5694
+ );
5506
5695
  const [searchFocused, setSearchFocused] = useState(false);
5507
5696
  const unreadCount = useMemo(
5508
5697
  () => conversations.filter((c) => c.unreadCount > 0).length,