@natoe/colab 0.1.27 → 0.1.30

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
@@ -582,6 +582,16 @@ function applyThemeOverrides(theme) {
582
582
  }
583
583
  });
584
584
  }
585
+ function previewKey(orderId, kind) {
586
+ return `${kind}|${orderId}`;
587
+ }
588
+ function parsePreviewKey(key) {
589
+ const separator = key.indexOf("|");
590
+ return {
591
+ kind: key.slice(0, separator),
592
+ orderId: key.slice(separator + 1)
593
+ };
594
+ }
585
595
  var CollabContext = React4.createContext(null);
586
596
  function useCollab() {
587
597
  const context = React4.useContext(CollabContext);
@@ -597,7 +607,7 @@ function CollabProvider({ config, apiBaseUrl, children }) {
597
607
  });
598
608
  const [unreadCounts, setUnreadCounts] = React4.useState({});
599
609
  const [unreadCountsByOrder, setUnreadCountsByOrder] = React4.useState({});
600
- const pendingOrderIds = React4.useRef(/* @__PURE__ */ new Set());
610
+ const pendingKeys = React4.useRef(/* @__PURE__ */ new Set());
601
611
  const pendingResolvers = React4.useRef(/* @__PURE__ */ new Map());
602
612
  const previewCache = React4.useRef(/* @__PURE__ */ new Map());
603
613
  const batchScheduled = React4.useRef(false);
@@ -628,47 +638,61 @@ function CollabProvider({ config, apiBaseUrl, children }) {
628
638
  [config]
629
639
  );
630
640
  const flushPreviewBatch = React4.useCallback(async () => {
631
- const orderIds = Array.from(pendingOrderIds.current);
641
+ const keys = Array.from(pendingKeys.current);
632
642
  const resolvers = new Map(pendingResolvers.current);
633
- pendingOrderIds.current.clear();
643
+ pendingKeys.current.clear();
634
644
  pendingResolvers.current.clear();
635
645
  batchScheduled.current = false;
636
- if (orderIds.length === 0) return;
637
- try {
638
- const query = orderIds.map((id) => `order_ids[]=${encodeURIComponent(id)}`).join("&");
639
- const response = await fetch(`${apiBaseUrl}/api/natoe-colab/conversations/previews?${query}`, {
640
- headers: authHeaders()
641
- });
642
- if (!response.ok) throw new Error(`Preview batch failed: ${response.status}`);
643
- const data = snakeToCamel(await response.json());
644
- orderIds.forEach((orderId) => {
645
- const preview = data.previews[orderId] ?? null;
646
- if (preview !== null) {
647
- previewCache.current.set(orderId, preview);
648
- }
649
- resolvers.get(orderId)?.forEach((resolve) => resolve(preview));
650
- });
651
- } catch (error) {
652
- config.onError?.({
653
- code: "PREVIEW_BATCH_ERROR",
654
- message: "Failed to fetch conversation previews",
655
- details: error
656
- });
657
- orderIds.forEach((orderId) => {
658
- resolvers.get(orderId)?.forEach((resolve) => resolve(null));
659
- });
646
+ if (keys.length === 0) return;
647
+ const byKind = /* @__PURE__ */ new Map();
648
+ for (const key of keys) {
649
+ const { orderId, kind } = parsePreviewKey(key);
650
+ const list = byKind.get(kind) ?? [];
651
+ list.push(orderId);
652
+ byKind.set(kind, list);
660
653
  }
654
+ await Promise.all(
655
+ Array.from(byKind.entries()).map(async ([kind, orderIds]) => {
656
+ try {
657
+ const query = orderIds.map((id) => `order_ids[]=${encodeURIComponent(id)}`).join("&");
658
+ const response = await fetch(
659
+ `${apiBaseUrl}/api/natoe-colab/conversations/previews?${query}&kind=${encodeURIComponent(kind)}`,
660
+ { headers: authHeaders() }
661
+ );
662
+ if (!response.ok) throw new Error(`Preview batch failed: ${response.status}`);
663
+ const data = snakeToCamel(await response.json());
664
+ orderIds.forEach((orderId) => {
665
+ const key = previewKey(orderId, kind);
666
+ const preview = data.previews[orderId] ?? null;
667
+ if (preview !== null) {
668
+ previewCache.current.set(key, preview);
669
+ }
670
+ resolvers.get(key)?.forEach((resolve) => resolve(preview));
671
+ });
672
+ } catch (error) {
673
+ config.onError?.({
674
+ code: "PREVIEW_BATCH_ERROR",
675
+ message: "Failed to fetch conversation previews",
676
+ details: error
677
+ });
678
+ orderIds.forEach((orderId) => {
679
+ resolvers.get(previewKey(orderId, kind))?.forEach((resolve) => resolve(null));
680
+ });
681
+ }
682
+ })
683
+ );
661
684
  }, [apiBaseUrl, authHeaders, config]);
662
685
  const requestPreview = React4.useCallback(
663
- (orderId) => {
664
- if (previewCache.current.has(orderId)) {
665
- return Promise.resolve(previewCache.current.get(orderId) ?? null);
686
+ (orderId, kind = "case") => {
687
+ const key = previewKey(orderId, kind);
688
+ if (previewCache.current.has(key)) {
689
+ return Promise.resolve(previewCache.current.get(key) ?? null);
666
690
  }
667
691
  return new Promise((resolve) => {
668
- pendingOrderIds.current.add(orderId);
669
- const existing = pendingResolvers.current.get(orderId) || [];
692
+ pendingKeys.current.add(key);
693
+ const existing = pendingResolvers.current.get(key) || [];
670
694
  existing.push(resolve);
671
- pendingResolvers.current.set(orderId, existing);
695
+ pendingResolvers.current.set(key, existing);
672
696
  if (!batchScheduled.current) {
673
697
  batchScheduled.current = true;
674
698
  queueMicrotask(flushPreviewBatch);
@@ -677,8 +701,16 @@ function CollabProvider({ config, apiBaseUrl, children }) {
677
701
  },
678
702
  [flushPreviewBatch]
679
703
  );
680
- const invalidatePreview = React4.useCallback((orderId) => {
681
- previewCache.current.delete(orderId);
704
+ const invalidatePreview = React4.useCallback((orderId, kind) => {
705
+ if (kind) {
706
+ previewCache.current.delete(previewKey(orderId, kind));
707
+ return;
708
+ }
709
+ for (const key of Array.from(previewCache.current.keys())) {
710
+ if (parsePreviewKey(key).orderId === orderId) {
711
+ previewCache.current.delete(key);
712
+ }
713
+ }
682
714
  }, []);
683
715
  const fetchMessages = React4.useCallback(
684
716
  async (conversationId, options = {}) => {
@@ -705,11 +737,17 @@ function CollabProvider({ config, apiBaseUrl, children }) {
705
737
  message: "Cannot create conversation: orderId is empty. Host app passed an order with no orderId/mainOrderId set."
706
738
  };
707
739
  }
740
+ const kind = options.kind ?? "case";
708
741
  const response = await fetch(`${apiBaseUrl}/api/natoe-colab/orders/${orderId}/messages`, {
709
742
  method: "POST",
710
743
  headers: authHeaders(),
711
744
  body: JSON.stringify({
712
745
  message,
746
+ // Top-level, matching the backend's `params["kind"]`. It selects
747
+ // BOTH which thread is addressed and which authorization path runs
748
+ // — help kinds go through Access.can_access_help?/5, not the
749
+ // broader case-chat check.
750
+ kind,
713
751
  conversation: {
714
752
  name: options.name,
715
753
  participant_ids: options.participantIds ?? []
@@ -721,7 +759,7 @@ function CollabProvider({ config, apiBaseUrl, children }) {
721
759
  throw { code: "CREATE_ERROR", message: "Failed to create conversation", details: error };
722
760
  }
723
761
  const data = snakeToCamel(await response.json());
724
- previewCache.current.delete(orderId);
762
+ previewCache.current.delete(previewKey(orderId, kind));
725
763
  return data;
726
764
  },
727
765
  [apiBaseUrl, authHeaders]
@@ -853,9 +891,11 @@ function cleanChannelName(name) {
853
891
  // src/hooks/useConversation.ts
854
892
  function useConversation({
855
893
  orderId,
894
+ conversation: providedConversation,
856
895
  patientData,
857
896
  participantIds = [],
858
- loadHistory = true
897
+ loadHistory = true,
898
+ kind = "case"
859
899
  }) {
860
900
  const {
861
901
  socket,
@@ -884,6 +924,7 @@ function useConversation({
884
924
  const markedReadIdsRef = React4.useRef(/* @__PURE__ */ new Set());
885
925
  const buildName = React4.useCallback(
886
926
  () => {
927
+ if (!patientData) return "";
887
928
  const labName = patientData.labName ?? (config.userRole === "lab" ? config.userName : void 0);
888
929
  return buildChannelName({ ...patientData, labName });
889
930
  },
@@ -983,7 +1024,29 @@ function useConversation({
983
1024
  setIsLoading(true);
984
1025
  setError(null);
985
1026
  try {
986
- const preview = await requestPreview(orderId);
1027
+ if (providedConversation) {
1028
+ setConversation(providedConversation);
1029
+ setParticipants(providedConversation.participants ?? []);
1030
+ joinChannel(providedConversation);
1031
+ if (loadHistory) {
1032
+ const history = await fetchMessages(providedConversation.id);
1033
+ if (cancelled) return;
1034
+ setMessages(history);
1035
+ setHasMore(history.length >= MESSAGES_PAGE_SIZE);
1036
+ setPinnedMessages(history.filter((m) => m.isPinned));
1037
+ }
1038
+ if (!cancelled) setIsLoading(false);
1039
+ return;
1040
+ }
1041
+ if (!orderId) {
1042
+ setConversation(null);
1043
+ setMessages([]);
1044
+ setParticipants([]);
1045
+ setHasMore(false);
1046
+ setIsLoading(false);
1047
+ return;
1048
+ }
1049
+ const preview = await requestPreview(orderId, kind);
987
1050
  if (cancelled) return;
988
1051
  if (!preview) {
989
1052
  setConversation(null);
@@ -1040,10 +1103,16 @@ function useConversation({
1040
1103
  markedReadIdsRef.current.clear();
1041
1104
  setIsConnected(false);
1042
1105
  };
1043
- }, [orderId]);
1106
+ }, [orderId, kind]);
1044
1107
  const ensureConversation = React4.useCallback(
1045
1108
  async (payload) => {
1046
1109
  if (conversation) return { conv: conversation, persistedByCreate: false };
1110
+ if (!orderId) {
1111
+ throw {
1112
+ code: "NO_CONVERSATION",
1113
+ message: "This conversation is not available yet. Please reopen it and try again."
1114
+ };
1115
+ }
1047
1116
  if (ensureConversationInFlight.current) {
1048
1117
  const conv = await ensureConversationInFlight.current;
1049
1118
  return { conv, persistedByCreate: false };
@@ -1051,13 +1120,14 @@ function useConversation({
1051
1120
  const inflight = (async () => {
1052
1121
  const result = await createConversationWithMessage(orderId, payload, {
1053
1122
  name: buildName(),
1054
- participantIds
1123
+ participantIds,
1124
+ kind
1055
1125
  });
1056
1126
  setConversation(result.conversation);
1057
1127
  setParticipants(result.conversation.participants);
1058
1128
  setMessages([result.message]);
1059
1129
  joinChannel(result.conversation);
1060
- invalidatePreview(orderId);
1130
+ invalidatePreview(orderId, kind);
1061
1131
  if (loadHistory) {
1062
1132
  fetchMessages(result.conversation.id).then((history) => {
1063
1133
  setMessages((prev) => {
@@ -1087,6 +1157,7 @@ function useConversation({
1087
1157
  conversation,
1088
1158
  createConversationWithMessage,
1089
1159
  orderId,
1160
+ kind,
1090
1161
  buildName,
1091
1162
  participantIds,
1092
1163
  joinChannel,
@@ -1529,15 +1600,19 @@ function PatientHeader({
1529
1600
  onBack,
1530
1601
  hideName = false,
1531
1602
  displayName,
1603
+ isSupportChannel = false,
1532
1604
  className
1533
1605
  }) {
1534
- const hasDicom = !!(patientData.studyId && patientData.storageId);
1535
- const resolvedName = resolveDisplayName(patientData, displayName);
1606
+ const hasDicom = !isSupportChannel && !!(patientData.studyId && patientData.storageId);
1607
+ const resolvedName = isSupportChannel ? displayName || patientData.patientName || "Natoe Support" : resolveDisplayName(patientData, displayName);
1536
1608
  const metaParts = [];
1537
- if (patientData.patientAge) metaParts.push(String(patientData.patientAge));
1538
- if (patientData.patientSex) metaParts.push(String(patientData.patientSex));
1539
- if (patientData.studyType) metaParts.push(patientData.studyType);
1540
- if (patientData.bodyParts && patientData.bodyParts.length > 0) {
1609
+ if (isSupportChannel) {
1610
+ metaParts.push("Help and support");
1611
+ }
1612
+ if (!isSupportChannel && patientData.patientAge) metaParts.push(String(patientData.patientAge));
1613
+ if (!isSupportChannel && patientData.patientSex) metaParts.push(String(patientData.patientSex));
1614
+ if (!isSupportChannel && patientData.studyType) metaParts.push(patientData.studyType);
1615
+ if (!isSupportChannel && patientData.bodyParts && patientData.bodyParts.length > 0) {
1541
1616
  metaParts.push(patientData.bodyParts.join(", "));
1542
1617
  }
1543
1618
  const hasPrimaryActions = hasDicom && onOpenDicom || onOpenCase;
@@ -4494,8 +4569,10 @@ var DARK_THEME_OVERRIDES = {
4494
4569
  };
4495
4570
  function CollabPanel({
4496
4571
  orderId,
4572
+ conversation: providedConversation,
4497
4573
  patientData,
4498
4574
  participantIds,
4575
+ kind,
4499
4576
  showSeenBy = true,
4500
4577
  onBack,
4501
4578
  hidePatientName = false,
@@ -4534,7 +4611,13 @@ function CollabPanel({
4534
4611
  loadMoreMessages,
4535
4612
  pinMessage,
4536
4613
  unpinMessage
4537
- } = useConversation({ orderId, patientData, participantIds });
4614
+ } = useConversation({
4615
+ orderId,
4616
+ conversation: providedConversation,
4617
+ patientData,
4618
+ participantIds,
4619
+ kind
4620
+ });
4538
4621
  React4.useEffect(() => {
4539
4622
  onConversationChange?.(conversation);
4540
4623
  }, [conversation, onConversationChange]);
@@ -4543,11 +4626,11 @@ function CollabPanel({
4543
4626
  });
4544
4627
  const { handleDeepLink } = useDeepLinks();
4545
4628
  const handleOpenDicom = () => {
4546
- if (patientData.studyId && patientData.storageId && config.onOpenDicom) {
4629
+ if (patientData?.studyId && patientData.storageId && config.onOpenDicom) {
4547
4630
  config.onOpenDicom(patientData.studyId, patientData.storageId);
4548
4631
  }
4549
4632
  };
4550
- const handleOpenCase = !hideOpenCase && config.onOpenCase ? () => config.onOpenCase?.(patientData.orderId, patientData.displayOrderId) : void 0;
4633
+ const handleOpenCase = !hideOpenCase && config.onOpenCase && patientData?.orderId ? () => config.onOpenCase?.(patientData.orderId, patientData.displayOrderId) : void 0;
4551
4634
  const handleJumpToMessage = (messageId) => {
4552
4635
  messageListRef.current?.scrollToMessage(messageId);
4553
4636
  };
@@ -4620,7 +4703,8 @@ function CollabPanel({
4620
4703
  /* @__PURE__ */ jsxRuntime.jsx(
4621
4704
  PatientHeader,
4622
4705
  {
4623
- patientData,
4706
+ patientData: patientData ?? { orderId: "", patientName: conversation?.name ?? "Natoe Support" },
4707
+ isSupportChannel: !patientData,
4624
4708
  participants,
4625
4709
  onOpenDicom: handleOpenDicom,
4626
4710
  onOpenCase: handleOpenCase,
@@ -4753,6 +4837,7 @@ function CollabPopup({
4753
4837
  orderId,
4754
4838
  patientData,
4755
4839
  participantIds,
4840
+ kind,
4756
4841
  isOpen,
4757
4842
  onClose,
4758
4843
  onBack,
@@ -4941,6 +5026,7 @@ function CollabPopup({
4941
5026
  orderId,
4942
5027
  patientData,
4943
5028
  participantIds,
5029
+ kind,
4944
5030
  hidePatientName: true,
4945
5031
  onConversationChange: setLoadedConversation,
4946
5032
  showSettings,
@@ -5058,7 +5144,8 @@ function useInlineCollab({
5058
5144
  orderId,
5059
5145
  patientData,
5060
5146
  participantIds = [],
5061
- messageLimit = 5
5147
+ messageLimit = 5,
5148
+ kind = "case"
5062
5149
  }) {
5063
5150
  const {
5064
5151
  socket,
@@ -5106,7 +5193,7 @@ function useInlineCollab({
5106
5193
  setIsLoading(true);
5107
5194
  setError(null);
5108
5195
  try {
5109
- const result = await requestPreview(orderId);
5196
+ const result = await requestPreview(orderId, kind);
5110
5197
  if (cancelled) return;
5111
5198
  setPreview(result);
5112
5199
  if (result) {
@@ -5202,7 +5289,8 @@ function useInlineCollab({
5202
5289
  try {
5203
5290
  const result = await createConversationWithMessage(orderId, payload, {
5204
5291
  name: buildName(),
5205
- participantIds
5292
+ participantIds,
5293
+ kind
5206
5294
  });
5207
5295
  const newPreview = {
5208
5296
  conversationId: result.conversation.id,
@@ -5216,7 +5304,7 @@ function useInlineCollab({
5216
5304
  setPreview(newPreview);
5217
5305
  setMessages([result.message]);
5218
5306
  setParticipants(result.conversation.participants);
5219
- invalidatePreview(orderId);
5307
+ invalidatePreview(orderId, kind);
5220
5308
  if (elementRef.current) {
5221
5309
  subscribe(result.conversation.id);
5222
5310
  }
@@ -5341,6 +5429,7 @@ function CollabInline({
5341
5429
  orderId,
5342
5430
  patientData,
5343
5431
  participantIds,
5432
+ kind,
5344
5433
  onExpand,
5345
5434
  messageLimit = 1,
5346
5435
  placeholder = "Discuss this case\u2026",
@@ -5359,7 +5448,7 @@ function CollabInline({
5359
5448
  sendMessage,
5360
5449
  sendAudioMessage,
5361
5450
  containerRef
5362
- } = useInlineCollab({ orderId, patientData, participantIds, messageLimit });
5451
+ } = useInlineCollab({ orderId, patientData, participantIds, messageLimit, kind });
5363
5452
  const containerStyle = {
5364
5453
  ...styles14.container,
5365
5454
  backgroundColor: pal.bg,
@@ -5851,6 +5940,14 @@ function useConversationList(options) {
5851
5940
  totalUnread
5852
5941
  };
5853
5942
  }
5943
+
5944
+ // src/core/types.ts
5945
+ function isHelpKind(kind) {
5946
+ return kind === "help_lab";
5947
+ }
5948
+ function isCaseKind(kind) {
5949
+ return kind === void 0 || kind === "case";
5950
+ }
5854
5951
  function ConversationListItem({
5855
5952
  item,
5856
5953
  isSelected,
@@ -5887,6 +5984,10 @@ function ConversationListItem({
5887
5984
  children: displayName
5888
5985
  }
5889
5986
  ),
5987
+ isHelpKind(item.kind) && /* A case can show two rows with the same patient name — the shared
5988
+ case thread and a private help thread. Without this the two are
5989
+ indistinguishable in the inbox. */
5990
+ /* @__PURE__ */ jsxRuntime.jsx("span", { style: styles15.helpPill, "aria-label": "Help thread", children: "Help" }),
5890
5991
  /* @__PURE__ */ jsxRuntime.jsx(
5891
5992
  "span",
5892
5993
  {
@@ -6033,6 +6134,17 @@ var styles15 = {
6033
6134
  nameUnread: {
6034
6135
  fontWeight: FONT_WEIGHT.bold
6035
6136
  },
6137
+ helpPill: {
6138
+ fontSize: FONT_SIZE.xs,
6139
+ fontWeight: FONT_WEIGHT.semibold,
6140
+ color: COLOR.primary,
6141
+ background: COLOR.primaryBg,
6142
+ borderRadius: RADIUS.sm,
6143
+ padding: `0 ${SPACE.S2}`,
6144
+ lineHeight: "16px",
6145
+ flexShrink: 0,
6146
+ whiteSpace: "nowrap"
6147
+ },
6036
6148
  time: {
6037
6149
  fontSize: FONT_SIZE.xs,
6038
6150
  color: COLOR.neutral500,
@@ -6470,6 +6582,7 @@ function CollabInbox({
6470
6582
  );
6471
6583
  }
6472
6584
  function buildPatientData(item) {
6585
+ if (isHelpKind(item.kind) || !item.orderId) return void 0;
6473
6586
  const parsed = parseChannelName(item.name, item.orderId);
6474
6587
  if (item.patientSnapshot) {
6475
6588
  return {
@@ -6807,6 +6920,8 @@ exports.THEME_DEFAULTS = THEME_DEFAULTS;
6807
6920
  exports.THEME_VAR = THEME_VAR;
6808
6921
  exports.TYPING_DEBOUNCE_MS = TYPING_DEBOUNCE_MS;
6809
6922
  exports.applyThemeOverrides = applyThemeOverrides;
6923
+ exports.isCaseKind = isCaseKind;
6924
+ exports.isHelpKind = isHelpKind;
6810
6925
  exports.useAudioRecorder = useAudioRecorder;
6811
6926
  exports.useChannelSettings = useChannelSettings;
6812
6927
  exports.useCollab = useCollab;