@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.js CHANGED
@@ -66,6 +66,13 @@ function toCamelKey(key) {
66
66
  var CollabSocket = class {
67
67
  constructor() {
68
68
  this.socket = null;
69
+ /**
70
+ * Conversation channels keyed by id. Each entry is reference-counted so
71
+ * multiple surfaces (e.g. inline chat + expanded panel mounted at once
72
+ * for the same conversation) can coexist without one's `leaveConversation`
73
+ * tearing the channel out from under the other. See `joinConversation`
74
+ * and the returned `ChannelSubscription.release`.
75
+ */
69
76
  this.channels = /* @__PURE__ */ new Map();
70
77
  this.presences = /* @__PURE__ */ new Map();
71
78
  this.userChannel = null;
@@ -136,59 +143,85 @@ var CollabSocket = class {
136
143
  onUnreadCountUpdate(callback) {
137
144
  this.onUnreadUpdate = callback;
138
145
  }
139
- /** Join a conversation channel and subscribe to events */
146
+ /**
147
+ * Join a conversation channel and subscribe to events.
148
+ *
149
+ * Reference-counted: multiple callers can join the same conversation
150
+ * (e.g. inline preview + expanded panel mounted side-by-side). Each
151
+ * call binds its own listeners and gets back a `ChannelSubscription`.
152
+ * The underlying channel only `.leave()`s the server when the LAST
153
+ * subscriber calls `release()`.
154
+ */
140
155
  joinConversation(conversationId, callbacks) {
141
156
  if (!this.socket) return null;
142
- if (this.channels.has(conversationId)) {
143
- return this.channels.get(conversationId);
157
+ let entry = this.channels.get(conversationId);
158
+ if (!entry) {
159
+ const channel2 = this.socket.channel(`conversation:${conversationId}`, {});
160
+ channel2.join().receive("ok", () => {
161
+ }).receive("error", (reason) => {
162
+ this.config?.onError?.({
163
+ code: "CHANNEL_JOIN_ERROR",
164
+ message: `Failed to join conversation ${conversationId}`,
165
+ details: reason
166
+ });
167
+ });
168
+ entry = { channel: channel2, subscribers: 0, presence: null };
169
+ this.channels.set(conversationId, entry);
144
170
  }
145
- const channel = this.socket.channel(`conversation:${conversationId}`, {});
171
+ const channel = entry.channel;
172
+ const refs = [];
173
+ const bind = (event, fn) => {
174
+ const ref = channel.on(event, fn);
175
+ refs.push({ event, ref });
176
+ };
146
177
  if (callbacks.onMessage) {
147
- channel.on(EVENTS.MESSAGE_NEW, (payload) => {
178
+ bind(EVENTS.MESSAGE_NEW, (payload) => {
148
179
  callbacks.onMessage(snakeToCamel(payload));
149
180
  });
150
181
  }
151
182
  if (callbacks.onTyping) {
152
- channel.on(EVENTS.USER_TYPING, (payload) => {
183
+ bind(EVENTS.USER_TYPING, (payload) => {
153
184
  callbacks.onTyping(snakeToCamel(payload));
154
185
  });
155
186
  }
156
187
  if (callbacks.onUserJoined) {
157
- channel.on(EVENTS.USER_JOINED, (payload) => {
188
+ bind(EVENTS.USER_JOINED, (payload) => {
158
189
  callbacks.onUserJoined(snakeToCamel(payload));
159
190
  });
160
191
  }
161
192
  if (callbacks.onUserLeft) {
162
- channel.on(EVENTS.USER_LEFT, (payload) => {
193
+ bind(EVENTS.USER_LEFT, (payload) => {
163
194
  callbacks.onUserLeft(snakeToCamel(payload));
164
195
  });
165
196
  }
166
197
  if (callbacks.onChannelUpdated) {
167
- channel.on(EVENTS.CHANNEL_UPDATED, (payload) => {
198
+ bind(EVENTS.CHANNEL_UPDATED, (payload) => {
168
199
  callbacks.onChannelUpdated(snakeToCamel(payload));
169
200
  });
170
201
  }
171
202
  if (callbacks.onChannelDeleted) {
172
- channel.on(EVENTS.CHANNEL_DELETED, () => {
203
+ bind(EVENTS.CHANNEL_DELETED, () => {
173
204
  callbacks.onChannelDeleted();
174
205
  });
175
206
  }
176
207
  if (callbacks.onMessageRead) {
177
- channel.on(EVENTS.MESSAGE_READ, (payload) => {
178
- callbacks.onMessageRead(snakeToCamel(payload));
208
+ bind(EVENTS.MESSAGE_READ, (payload) => {
209
+ callbacks.onMessageRead(
210
+ snakeToCamel(payload)
211
+ );
179
212
  });
180
213
  }
181
214
  if (callbacks.onMessagePinned) {
182
- channel.on(EVENTS.MESSAGE_PINNED, (payload) => {
215
+ bind(EVENTS.MESSAGE_PINNED, (payload) => {
183
216
  callbacks.onMessagePinned(snakeToCamel(payload));
184
217
  });
185
218
  }
186
219
  if (callbacks.onMessageUnpinned) {
187
- channel.on(EVENTS.MESSAGE_UNPINNED, (payload) => {
220
+ bind(EVENTS.MESSAGE_UNPINNED, (payload) => {
188
221
  callbacks.onMessageUnpinned(snakeToCamel(payload));
189
222
  });
190
223
  }
191
- if (callbacks.onPresence) {
224
+ if (callbacks.onPresence && !entry.presence) {
192
225
  const presence = new phoenix.Presence(channel);
193
226
  presence.onSync(() => {
194
227
  const online = {};
@@ -197,27 +230,45 @@ var CollabSocket = class {
197
230
  });
198
231
  callbacks.onPresence(online);
199
232
  });
233
+ entry.presence = presence;
200
234
  this.presences.set(conversationId, presence);
201
235
  }
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;
236
+ entry.subscribers += 1;
237
+ let released = false;
238
+ const release = () => {
239
+ if (released) return;
240
+ released = true;
241
+ const current = this.channels.get(conversationId);
242
+ if (!current) return;
243
+ for (const { event, ref } of refs) {
244
+ current.channel.off(event, ref);
245
+ }
246
+ current.subscribers -= 1;
247
+ if (current.subscribers <= 0) {
248
+ current.channel.leave();
249
+ this.channels.delete(conversationId);
250
+ this.presences.delete(conversationId);
251
+ }
252
+ };
253
+ return { channel, release };
212
254
  }
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
- }
255
+ /**
256
+ * @deprecated Use the `release()` method returned by `joinConversation()`.
257
+ * Kept as a no-op so older callers don't throw — but it cannot identify
258
+ * which subscriber should leave, so it silently does nothing. Any code
259
+ * still calling this will leak listeners and prevent the channel from
260
+ * ever being torn down. Migrate to the subscription handle.
261
+ */
262
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
263
+ leaveConversation(_conversationId) {
264
+ }
265
+ /**
266
+ * Look up the underlying Phoenix Channel for a conversation, if any
267
+ * subscriber is still holding it. All send/push paths go through this
268
+ * helper so the refcounted entry shape is contained to joinConversation.
269
+ */
270
+ getChannel(conversationId) {
271
+ return this.channels.get(conversationId)?.channel ?? null;
221
272
  }
222
273
  /** Send a message to a conversation.
223
274
  *
@@ -229,7 +280,7 @@ var CollabSocket = class {
229
280
  */
230
281
  sendMessage(conversationId, payload) {
231
282
  return new Promise((resolve, reject) => {
232
- const channel = this.channels.get(conversationId);
283
+ const channel = this.getChannel(conversationId);
233
284
  if (!channel) {
234
285
  reject({ code: "NOT_JOINED", message: "Not joined to this conversation" });
235
286
  return;
@@ -252,7 +303,7 @@ var CollabSocket = class {
252
303
  }
253
304
  /** Broadcast typing indicator */
254
305
  sendTyping(conversationId, isTyping) {
255
- const channel = this.channels.get(conversationId);
306
+ const channel = this.getChannel(conversationId);
256
307
  channel?.push(EVENTS.USER_TYPING, {
257
308
  userId: this.config?.userId,
258
309
  userName: this.config?.userName,
@@ -261,7 +312,7 @@ var CollabSocket = class {
261
312
  }
262
313
  /** Mark messages as read */
263
314
  markAsRead(conversationId, messageId) {
264
- const channel = this.channels.get(conversationId);
315
+ const channel = this.getChannel(conversationId);
265
316
  channel?.push(EVENTS.MESSAGE_READ, {
266
317
  messageId,
267
318
  userId: this.config?.userId
@@ -295,7 +346,7 @@ var CollabSocket = class {
295
346
  /** Generic push with promise wrapper */
296
347
  channelPush(conversationId, event, payload) {
297
348
  return new Promise((resolve, reject) => {
298
- const channel = this.channels.get(conversationId);
349
+ const channel = this.getChannel(conversationId);
299
350
  if (!channel) {
300
351
  reject({ code: "NOT_JOINED", message: "Not joined to this conversation" });
301
352
  return;
@@ -312,7 +363,7 @@ var CollabSocket = class {
312
363
  }
313
364
  /** Disconnect socket and leave all channels */
314
365
  disconnect() {
315
- this.channels.forEach((channel) => channel.leave());
366
+ this.channels.forEach((entry) => entry.channel.leave());
316
367
  this.channels.clear();
317
368
  this.presences.clear();
318
369
  this.userChannel?.leave();
@@ -367,7 +418,21 @@ var THEME_VAR = {
367
418
  dangerBg: "--natoe-colab-danger-bg",
368
419
  dangerBorder: "--natoe-colab-danger-border",
369
420
  dangerFg: "--natoe-colab-danger-fg",
370
- fontStack: "--natoe-colab-font-stack"
421
+ fontStack: "--natoe-colab-font-stack",
422
+ // Neutral surface palette — promoted to CSS vars so consumers (e.g.
423
+ // CollabPanel themeMode='dark') can flip the whole grayscale at a subtree
424
+ // level without re-themeing every component.
425
+ white: "--natoe-colab-white",
426
+ neutral50: "--natoe-colab-neutral-50",
427
+ neutral100: "--natoe-colab-neutral-100",
428
+ neutral200: "--natoe-colab-neutral-200",
429
+ neutral300: "--natoe-colab-neutral-300",
430
+ neutral400: "--natoe-colab-neutral-400",
431
+ neutral500: "--natoe-colab-neutral-500",
432
+ neutral600: "--natoe-colab-neutral-600",
433
+ neutral700: "--natoe-colab-neutral-700",
434
+ neutral800: "--natoe-colab-neutral-800",
435
+ neutral900: "--natoe-colab-neutral-900"
371
436
  };
372
437
  var THEME_DEFAULTS = {
373
438
  primary: "#2563eb",
@@ -380,18 +445,9 @@ var THEME_DEFAULTS = {
380
445
  dangerBg: "#fef2f2",
381
446
  dangerBorder: "#fecaca",
382
447
  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.
448
+ fontStack: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
449
+ // Neutral grayscale — these defaults are the same hex literals the
450
+ // package shipped with; they're now overridable per-subtree.
395
451
  white: "#ffffff",
396
452
  neutral50: "#f9fafb",
397
453
  neutral100: "#f3f4f6",
@@ -402,7 +458,32 @@ var COLOR = {
402
458
  neutral600: "#4b5563",
403
459
  neutral700: "#374151",
404
460
  neutral800: "#1f2937",
405
- neutral900: "#111827",
461
+ neutral900: "#111827"
462
+ };
463
+ var cssVar = (name, fallback) => `var(${name}, ${fallback})`;
464
+ var COLOR = {
465
+ // Brand — themeable
466
+ primary: cssVar(THEME_VAR.primary, THEME_DEFAULTS.primary),
467
+ primaryHover: cssVar(THEME_VAR.primaryHover, THEME_DEFAULTS.primaryHover),
468
+ /** Tinted surface for chips/cards on brand-coloured states. */
469
+ primaryBg: cssVar(THEME_VAR.primaryBg, THEME_DEFAULTS.primaryBg),
470
+ primaryFg: cssVar(THEME_VAR.primaryFg, THEME_DEFAULTS.primaryFg),
471
+ // Neutral grayscale — themeable via CSS variables. Light-mode defaults
472
+ // taken from Tailwind's zinc-leaning slate to match the package's
473
+ // existing tonal balance; a host can override the entire palette by
474
+ // setting --natoe-colab-* values on any ancestor element (used by
475
+ // CollabPanel themeMode='dark' for the viewer's left-panel surface).
476
+ white: cssVar(THEME_VAR.white, THEME_DEFAULTS.white),
477
+ neutral50: cssVar(THEME_VAR.neutral50, THEME_DEFAULTS.neutral50),
478
+ neutral100: cssVar(THEME_VAR.neutral100, THEME_DEFAULTS.neutral100),
479
+ neutral200: cssVar(THEME_VAR.neutral200, THEME_DEFAULTS.neutral200),
480
+ neutral300: cssVar(THEME_VAR.neutral300, THEME_DEFAULTS.neutral300),
481
+ neutral400: cssVar(THEME_VAR.neutral400, THEME_DEFAULTS.neutral400),
482
+ neutral500: cssVar(THEME_VAR.neutral500, THEME_DEFAULTS.neutral500),
483
+ neutral600: cssVar(THEME_VAR.neutral600, THEME_DEFAULTS.neutral600),
484
+ neutral700: cssVar(THEME_VAR.neutral700, THEME_DEFAULTS.neutral700),
485
+ neutral800: cssVar(THEME_VAR.neutral800, THEME_DEFAULTS.neutral800),
486
+ neutral900: cssVar(THEME_VAR.neutral900, THEME_DEFAULTS.neutral900),
406
487
  slate800: "#1e293b",
407
488
  // Semantic — themeable
408
489
  success: cssVar(THEME_VAR.success, THEME_DEFAULTS.success),
@@ -781,6 +862,7 @@ function useConversation({
781
862
  const [isConnected, setIsConnected] = React4.useState(false);
782
863
  const [replyTo, setReplyTo] = React4.useState(null);
783
864
  const joinedConversationId = React4.useRef(null);
865
+ const channelSubscription = React4.useRef(null);
784
866
  const typingTimers = React4.useRef(/* @__PURE__ */ new Map());
785
867
  const ensureConversationInFlight = React4.useRef(null);
786
868
  const markedReadIdsRef = React4.useRef(/* @__PURE__ */ new Set());
@@ -794,7 +876,9 @@ function useConversation({
794
876
  const joinChannel = React4.useCallback(
795
877
  (conv) => {
796
878
  if (joinedConversationId.current === conv.id) return;
797
- socket.joinConversation(conv.id, {
879
+ channelSubscription.current?.release();
880
+ channelSubscription.current = null;
881
+ const subscription = socket.joinConversation(conv.id, {
798
882
  // Dedupe by id so an optimistic message (added client-side on send)
799
883
  // doesn't double up when the server's broadcast arrives.
800
884
  onMessage: (msg) => setMessages((prev) => {
@@ -844,6 +928,8 @@ function useConversation({
844
928
  onChannelDeleted: () => {
845
929
  setConversation(null);
846
930
  setMessages([]);
931
+ channelSubscription.current?.release();
932
+ channelSubscription.current = null;
847
933
  joinedConversationId.current = null;
848
934
  setIsConnected(false);
849
935
  },
@@ -869,6 +955,7 @@ function useConversation({
869
955
  );
870
956
  }
871
957
  });
958
+ channelSubscription.current = subscription;
872
959
  joinedConversationId.current = conv.id;
873
960
  setIsConnected(true);
874
961
  },
@@ -929,10 +1016,9 @@ function useConversation({
929
1016
  init();
930
1017
  return () => {
931
1018
  cancelled = true;
932
- if (joinedConversationId.current) {
933
- socket.leaveConversation(joinedConversationId.current);
934
- joinedConversationId.current = null;
935
- }
1019
+ channelSubscription.current?.release();
1020
+ channelSubscription.current = null;
1021
+ joinedConversationId.current = null;
936
1022
  typingTimers.current.forEach((timer) => clearTimeout(timer));
937
1023
  typingTimers.current.clear();
938
1024
  markedReadIdsRef.current.clear();
@@ -1348,17 +1434,26 @@ function DicomIcon({ size = 18, color = "currentColor" }) {
1348
1434
  }
1349
1435
  );
1350
1436
  }
1351
- function SettingsIcon({ size = 18, color = "currentColor" }) {
1352
- return /* @__PURE__ */ jsxRuntime.jsx(
1437
+ function PeopleIcon({ size = 18, color = "currentColor" }) {
1438
+ return /* @__PURE__ */ jsxRuntime.jsxs(
1353
1439
  "svg",
1354
1440
  {
1355
1441
  xmlns: "http://www.w3.org/2000/svg",
1356
1442
  width: size,
1357
1443
  height: size,
1358
1444
  viewBox: "0 0 24 24",
1359
- fill: color,
1445
+ fill: "none",
1446
+ stroke: color,
1447
+ strokeWidth: "2",
1448
+ strokeLinecap: "round",
1449
+ strokeLinejoin: "round",
1360
1450
  "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" })
1451
+ children: [
1452
+ /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" }),
1453
+ /* @__PURE__ */ jsxRuntime.jsx("circle", { cx: "9", cy: "7", r: "4" }),
1454
+ /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M22 21v-2a4 4 0 0 0-3-3.87" }),
1455
+ /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M16 3.13a4 4 0 0 1 0 7.75" })
1456
+ ]
1362
1457
  }
1363
1458
  );
1364
1459
  }
@@ -1383,6 +1478,7 @@ function resolveDisplayName(patientData, displayName) {
1383
1478
  }
1384
1479
  function PatientHeader({
1385
1480
  patientData,
1481
+ participants,
1386
1482
  onOpenDicom,
1387
1483
  onOpenSettings,
1388
1484
  onBack,
@@ -1448,15 +1544,18 @@ function PatientHeader({
1448
1544
  ]
1449
1545
  }
1450
1546
  ),
1451
- onOpenSettings && /* @__PURE__ */ jsxRuntime.jsx(
1547
+ onOpenSettings && /* @__PURE__ */ jsxRuntime.jsxs(
1452
1548
  "button",
1453
1549
  {
1454
1550
  onClick: onOpenSettings,
1455
1551
  style: styles.settingsIconButton,
1456
1552
  type: "button",
1457
- "aria-label": "Open channel settings",
1458
- title: "Settings",
1459
- children: /* @__PURE__ */ jsxRuntime.jsx(SettingsIcon, { size: 18, color: COLOR.neutral600 })
1553
+ "aria-label": `Open channel settings (${participants.length} participants)`,
1554
+ title: "Channel participants",
1555
+ children: [
1556
+ /* @__PURE__ */ jsxRuntime.jsx(PeopleIcon, { size: 18, color: COLOR.neutral600 }),
1557
+ /* @__PURE__ */ jsxRuntime.jsx("span", { style: styles.participantCount, children: participants.length })
1558
+ ]
1460
1559
  }
1461
1560
  )
1462
1561
  ] })
@@ -1564,20 +1663,28 @@ var styles = {
1564
1663
  borderRadius: RADIUS.lg,
1565
1664
  cursor: "pointer"
1566
1665
  },
1567
- // Settings is icon-only (40x40 tile) since it's a secondary action
1568
- // and View DICOM already carries a label.
1666
+ // Channel-info button: people icon + participant count. Click opens the
1667
+ // channel settings overlay (kept on the same handler as before so hosts
1668
+ // don't have to re-wire — the affordance just looks like a "members"
1669
+ // pill now instead of a gear).
1569
1670
  settingsIconButton: {
1570
- width: SIZE.control,
1571
- height: SIZE.control,
1671
+ minHeight: SIZE.control,
1572
1672
  display: "inline-flex",
1573
1673
  alignItems: "center",
1574
- justifyContent: "center",
1674
+ gap: SPACE.S2,
1675
+ padding: `0 ${SPACE.S3}`,
1575
1676
  backgroundColor: COLOR.white,
1576
1677
  border: `1px solid ${COLOR.neutral200}`,
1577
1678
  borderRadius: RADIUS.lg,
1578
1679
  color: COLOR.neutral600,
1579
1680
  cursor: "pointer",
1580
1681
  flexShrink: 0
1682
+ },
1683
+ participantCount: {
1684
+ fontSize: FONT_SIZE.sm,
1685
+ fontWeight: FONT_WEIGHT.semibold,
1686
+ color: COLOR.neutral700,
1687
+ fontVariantNumeric: "tabular-nums"
1581
1688
  }
1582
1689
  };
1583
1690
  function ReplyQuoteBlock({
@@ -3998,6 +4105,20 @@ var styles12 = {
3998
4105
  }
3999
4106
  };
4000
4107
  ensureGlobalStyles();
4108
+ var DARK_THEME_OVERRIDES = {
4109
+ ["--natoe-colab-white"]: "#0b0b0c",
4110
+ ["--natoe-colab-neutral-50"]: "#18181c",
4111
+ ["--natoe-colab-neutral-100"]: "#1f1f23",
4112
+ ["--natoe-colab-neutral-200"]: "#2a2a2e",
4113
+ ["--natoe-colab-neutral-300"]: "#3f3f44",
4114
+ ["--natoe-colab-neutral-400"]: "#6b7280",
4115
+ ["--natoe-colab-neutral-500"]: "#9ca3af",
4116
+ ["--natoe-colab-neutral-600"]: "#cbd5e1",
4117
+ ["--natoe-colab-neutral-700"]: "#e5e7eb",
4118
+ ["--natoe-colab-neutral-800"]: "#f3f4f6",
4119
+ ["--natoe-colab-neutral-900"]: "#ffffff",
4120
+ ["--natoe-colab-primary-bg"]: "rgba(37, 99, 235, 0.18)"
4121
+ };
4001
4122
  function CollabPanel({
4002
4123
  orderId,
4003
4124
  patientData,
@@ -4006,6 +4127,7 @@ function CollabPanel({
4006
4127
  onBack,
4007
4128
  hidePatientName = false,
4008
4129
  onConversationChange,
4130
+ themeMode = "light",
4009
4131
  className,
4010
4132
  style
4011
4133
  }) {
@@ -4081,16 +4203,21 @@ function CollabPanel({
4081
4203
  }
4082
4204
  };
4083
4205
  const pinDisabled = pinnedMessages.length >= MAX_PINNED_MESSAGES;
4206
+ const containerStyle = {
4207
+ ...panelStyles.container,
4208
+ ...themeMode === "dark" ? DARK_THEME_OVERRIDES : {},
4209
+ ...style
4210
+ };
4084
4211
  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: [
4212
+ return /* @__PURE__ */ jsxRuntime.jsx("div", { className: [ROOT_CLASS, className].filter(Boolean).join(" "), style: containerStyle, children: /* @__PURE__ */ jsxRuntime.jsxs("div", { style: panelStyles.errorState, children: [
4086
4213
  /* @__PURE__ */ jsxRuntime.jsx("p", { style: panelStyles.errorTitle, children: "Unable to load conversation" }),
4087
4214
  /* @__PURE__ */ jsxRuntime.jsx("p", { style: panelStyles.errorMessage, children: error })
4088
4215
  ] }) });
4089
4216
  }
4090
4217
  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..." }) });
4218
+ 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
4219
  }
4093
- return /* @__PURE__ */ jsxRuntime.jsxs("div", { className, style: { ...panelStyles.container, ...style }, children: [
4220
+ return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: [ROOT_CLASS, className].filter(Boolean).join(" "), style: containerStyle, children: [
4094
4221
  showSettings && conversation ? /* @__PURE__ */ jsxRuntime.jsx(
4095
4222
  ChannelSettings,
4096
4223
  {
@@ -4526,6 +4653,7 @@ function useInlineCollab({
4526
4653
  const elementRef = React4.useRef(null);
4527
4654
  const observerRef = React4.useRef(null);
4528
4655
  const subscribedConversationIdRef = React4.useRef(null);
4656
+ const channelSubscriptionRef = React4.useRef(null);
4529
4657
  const trimToLimit = React4.useCallback(
4530
4658
  (msgs) => msgs.length > messageLimit ? msgs.slice(msgs.length - messageLimit) : msgs,
4531
4659
  [messageLimit]
@@ -4563,7 +4691,9 @@ function useInlineCollab({
4563
4691
  const subscribe = React4.useCallback(
4564
4692
  (conversationId) => {
4565
4693
  if (subscribedConversationIdRef.current === conversationId) return;
4566
- socket.joinConversation(conversationId, {
4694
+ channelSubscriptionRef.current?.release();
4695
+ channelSubscriptionRef.current = null;
4696
+ const subscription = socket.joinConversation(conversationId, {
4567
4697
  onMessage: (msg) => {
4568
4698
  setMessages((prev) => trimToLimit([...prev, msg]));
4569
4699
  if (msg.senderId !== config.userId) {
@@ -4579,19 +4709,18 @@ function useInlineCollab({
4579
4709
  setParticipants((prev) => prev.filter((p) => p.userId !== participant.userId));
4580
4710
  }
4581
4711
  });
4712
+ channelSubscriptionRef.current = subscription;
4582
4713
  subscribedConversationIdRef.current = conversationId;
4583
4714
  setIsSubscribed(true);
4584
4715
  },
4585
4716
  [socket, trimToLimit, config.userId]
4586
4717
  );
4587
4718
  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]);
4719
+ channelSubscriptionRef.current?.release();
4720
+ channelSubscriptionRef.current = null;
4721
+ subscribedConversationIdRef.current = null;
4722
+ setIsSubscribed(false);
4723
+ }, []);
4595
4724
  const containerRef = React4.useCallback(
4596
4725
  (element) => {
4597
4726
  if (observerRef.current) {
@@ -4733,16 +4862,52 @@ function useInlineCollab({
4733
4862
  };
4734
4863
  }
4735
4864
  ensureGlobalStyles();
4865
+ function palette(mode) {
4866
+ if (mode === "dark") {
4867
+ return {
4868
+ bg: "transparent",
4869
+ border: "transparent",
4870
+ previewBorder: "#1f1f23",
4871
+ senderName: "#f3f4f6",
4872
+ messageText: "#cbd5e1",
4873
+ systemText: "#6b7280",
4874
+ inputBg: "#18181c",
4875
+ inputBorder: "#2a2a2e",
4876
+ inputText: "#e5e7eb",
4877
+ audioBg: "rgba(37, 99, 235, 0.18)",
4878
+ audioFg: "#93c5fd",
4879
+ expandBorder: "#2a2a2e",
4880
+ expandColor: "#9ca3af"
4881
+ };
4882
+ }
4883
+ return {
4884
+ bg: COLOR.white,
4885
+ border: COLOR.neutral200,
4886
+ previewBorder: COLOR.neutral100,
4887
+ senderName: COLOR.neutral700,
4888
+ messageText: COLOR.neutral600,
4889
+ systemText: COLOR.neutral400,
4890
+ inputBg: "transparent",
4891
+ inputBorder: COLOR.neutral200,
4892
+ inputText: COLOR.neutral900,
4893
+ audioBg: COLOR.primaryBg,
4894
+ audioFg: COLOR.primary,
4895
+ expandBorder: COLOR.neutral200,
4896
+ expandColor: COLOR.neutral500
4897
+ };
4898
+ }
4736
4899
  function CollabInline({
4737
4900
  orderId,
4738
4901
  patientData,
4739
4902
  participantIds,
4740
4903
  onExpand,
4741
- messageLimit = 5,
4904
+ messageLimit = 1,
4742
4905
  placeholder = "Type a message about this case...",
4906
+ mode = "light",
4743
4907
  className,
4744
4908
  style
4745
4909
  }) {
4910
+ const pal = palette(mode);
4746
4911
  const {
4747
4912
  hasConversation,
4748
4913
  messages,
@@ -4754,11 +4919,17 @@ function CollabInline({
4754
4919
  sendAudioMessage,
4755
4920
  containerRef
4756
4921
  } = useInlineCollab({ orderId, patientData, participantIds, messageLimit });
4922
+ const containerStyle = {
4923
+ ...styles14.container,
4924
+ backgroundColor: pal.bg,
4925
+ borderColor: pal.border,
4926
+ ...style
4927
+ };
4757
4928
  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..." }) });
4929
+ 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
4930
  }
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)) }),
4931
+ return /* @__PURE__ */ jsxRuntime.jsxs("div", { ref: containerRef, className: [ROOT_CLASS, className].filter(Boolean).join(" "), style: containerStyle, children: [
4932
+ 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
4933
  error && /* @__PURE__ */ jsxRuntime.jsx("div", { style: styles14.error, children: error }),
4763
4934
  /* @__PURE__ */ jsxRuntime.jsx(
4764
4935
  InlineInputBar,
@@ -4769,33 +4940,34 @@ function CollabInline({
4769
4940
  unreadCount: hasConversation ? unreadCount : 0,
4770
4941
  isLive: isSubscribed,
4771
4942
  showExpand: hasConversation && !!onExpand,
4772
- onExpand
4943
+ onExpand,
4944
+ pal
4773
4945
  }
4774
4946
  )
4775
4947
  ] });
4776
4948
  }
4777
- function InlineMessageRow({ message }) {
4949
+ function InlineMessageRow({ message, pal }) {
4778
4950
  if (message.type === "system") {
4779
- return /* @__PURE__ */ jsxRuntime.jsx("div", { style: styles14.systemRow, children: /* @__PURE__ */ jsxRuntime.jsx("span", { style: styles14.systemText, children: message.body }) });
4951
+ return /* @__PURE__ */ jsxRuntime.jsx("div", { style: styles14.systemRow, children: /* @__PURE__ */ jsxRuntime.jsx("span", { style: { ...styles14.systemText, color: pal.systemText }, children: message.body }) });
4780
4952
  }
4781
4953
  if (message.type === "audio" && message.mediaUrl) {
4782
4954
  return /* @__PURE__ */ jsxRuntime.jsxs("div", { style: styles14.messageRow, children: [
4783
4955
  /* @__PURE__ */ jsxRuntime.jsx(RoleDot, { role: message.senderRole }),
4784
- /* @__PURE__ */ jsxRuntime.jsxs("span", { style: styles14.senderName, children: [
4956
+ /* @__PURE__ */ jsxRuntime.jsxs("span", { style: { ...styles14.senderName, color: pal.senderName }, children: [
4785
4957
  message.senderName,
4786
4958
  ":"
4787
4959
  ] }),
4788
- /* @__PURE__ */ jsxRuntime.jsx(InlineAudioPlayer, { url: message.mediaUrl, duration: message.mediaDuration })
4960
+ /* @__PURE__ */ jsxRuntime.jsx(InlineAudioPlayer, { url: message.mediaUrl, duration: message.mediaDuration, pal })
4789
4961
  ] });
4790
4962
  }
4791
4963
  const preview = renderMessagePreview(message);
4792
4964
  return /* @__PURE__ */ jsxRuntime.jsxs("div", { style: styles14.messageRow, children: [
4793
4965
  /* @__PURE__ */ jsxRuntime.jsx(RoleDot, { role: message.senderRole }),
4794
- /* @__PURE__ */ jsxRuntime.jsxs("span", { style: styles14.senderName, children: [
4966
+ /* @__PURE__ */ jsxRuntime.jsxs("span", { style: { ...styles14.senderName, color: pal.senderName }, children: [
4795
4967
  message.senderName,
4796
4968
  ":"
4797
4969
  ] }),
4798
- /* @__PURE__ */ jsxRuntime.jsx("span", { style: styles14.messageText, children: preview })
4970
+ /* @__PURE__ */ jsxRuntime.jsx("span", { style: { ...styles14.messageText, color: pal.messageText }, children: preview })
4799
4971
  ] });
4800
4972
  }
4801
4973
  function RoleDot({ role }) {
@@ -4811,7 +4983,11 @@ function RoleDot({ role }) {
4811
4983
  };
4812
4984
  return /* @__PURE__ */ jsxRuntime.jsx("span", { style: { ...styles14.roleDot, backgroundColor: colors[role] } });
4813
4985
  }
4814
- function InlineAudioPlayer({ url, duration }) {
4986
+ function InlineAudioPlayer({
4987
+ url,
4988
+ duration,
4989
+ pal
4990
+ }) {
4815
4991
  const audioRef = React4.useRef(null);
4816
4992
  const [isPlaying, setIsPlaying] = React4.useState(false);
4817
4993
  const [currentTime, setCurrentTime] = React4.useState(0);
@@ -4853,7 +5029,7 @@ function InlineAudioPlayer({ url, duration }) {
4853
5029
  const total = duration ?? 0;
4854
5030
  const remaining = Math.max(0, total - Math.floor(currentTime));
4855
5031
  const displayTime = isPlaying ? remaining : total;
4856
- return /* @__PURE__ */ jsxRuntime.jsxs("span", { style: styles14.audioPlayer, children: [
5032
+ return /* @__PURE__ */ jsxRuntime.jsxs("span", { style: { ...styles14.audioPlayer, backgroundColor: pal.audioBg, borderColor: pal.audioBg }, children: [
4857
5033
  /* @__PURE__ */ jsxRuntime.jsx(
4858
5034
  "button",
4859
5035
  {
@@ -4865,15 +5041,15 @@ function InlineAudioPlayer({ url, duration }) {
4865
5041
  }
4866
5042
  ),
4867
5043
  /* @__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%" } })
5044
+ /* @__PURE__ */ jsxRuntime.jsx("span", { style: { ...styles14.waveBar, height: "40%", backgroundColor: pal.audioFg } }),
5045
+ /* @__PURE__ */ jsxRuntime.jsx("span", { style: { ...styles14.waveBar, height: "80%", backgroundColor: pal.audioFg } }),
5046
+ /* @__PURE__ */ jsxRuntime.jsx("span", { style: { ...styles14.waveBar, height: "60%", backgroundColor: pal.audioFg } }),
5047
+ /* @__PURE__ */ jsxRuntime.jsx("span", { style: { ...styles14.waveBar, height: "90%", backgroundColor: pal.audioFg } }),
5048
+ /* @__PURE__ */ jsxRuntime.jsx("span", { style: { ...styles14.waveBar, height: "50%", backgroundColor: pal.audioFg } }),
5049
+ /* @__PURE__ */ jsxRuntime.jsx("span", { style: { ...styles14.waveBar, height: "70%", backgroundColor: pal.audioFg } }),
5050
+ /* @__PURE__ */ jsxRuntime.jsx("span", { style: { ...styles14.waveBar, height: "40%", backgroundColor: pal.audioFg } })
4875
5051
  ] }),
4876
- /* @__PURE__ */ jsxRuntime.jsx("span", { style: styles14.audioDuration, children: formatDuration2(displayTime) }),
5052
+ /* @__PURE__ */ jsxRuntime.jsx("span", { style: { ...styles14.audioDuration, color: pal.audioFg }, children: formatDuration2(displayTime) }),
4877
5053
  /* @__PURE__ */ jsxRuntime.jsx("audio", { ref: audioRef, src: url, preload: "metadata" })
4878
5054
  ] });
4879
5055
  }
@@ -4903,7 +5079,8 @@ function InlineInputBar({
4903
5079
  unreadCount,
4904
5080
  isLive,
4905
5081
  showExpand,
4906
- onExpand
5082
+ onExpand,
5083
+ pal
4907
5084
  }) {
4908
5085
  const [text, setText] = React4.useState("");
4909
5086
  const [isSending, setIsSending] = React4.useState(false);
@@ -4940,7 +5117,12 @@ function InlineInputBar({
4940
5117
  onKeyDown: handleKeyDown,
4941
5118
  placeholder,
4942
5119
  disabled: isSending,
4943
- style: styles14.input
5120
+ style: {
5121
+ ...styles14.input,
5122
+ backgroundColor: pal.inputBg,
5123
+ borderColor: pal.inputBorder,
5124
+ color: pal.inputText
5125
+ }
4944
5126
  }
4945
5127
  ),
4946
5128
  unreadCount > 0 && /* @__PURE__ */ jsxRuntime.jsx("span", { style: styles14.unreadBadge, title: `${unreadCount} unread`, children: unreadCount > 99 ? "99+" : unreadCount }),
@@ -4960,7 +5142,11 @@ function InlineInputBar({
4960
5142
  "button",
4961
5143
  {
4962
5144
  onClick: onExpand,
4963
- style: styles14.expandButton,
5145
+ style: {
5146
+ ...styles14.expandButton,
5147
+ borderColor: pal.expandBorder,
5148
+ color: pal.expandColor
5149
+ },
4964
5150
  title: "Expand to full chat",
4965
5151
  type: "button",
4966
5152
  children: "\u26F6"
@@ -5507,8 +5693,11 @@ function ConversationList({
5507
5693
  onSelect,
5508
5694
  className
5509
5695
  }) {
5696
+ const { totalUnread } = useCollab();
5510
5697
  const [query, setQuery] = React4.useState("");
5511
- const [filter, setFilter] = React4.useState("all");
5698
+ const [filter, setFilter] = React4.useState(
5699
+ () => totalUnread > 0 ? "unread" : "all"
5700
+ );
5512
5701
  const [searchFocused, setSearchFocused] = React4.useState(false);
5513
5702
  const unreadCount = React4.useMemo(
5514
5703
  () => conversations.filter((c) => c.unreadCount > 0).length,