@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.mjs CHANGED
@@ -576,6 +576,16 @@ function applyThemeOverrides(theme) {
576
576
  }
577
577
  });
578
578
  }
579
+ function previewKey(orderId, kind) {
580
+ return `${kind}|${orderId}`;
581
+ }
582
+ function parsePreviewKey(key) {
583
+ const separator = key.indexOf("|");
584
+ return {
585
+ kind: key.slice(0, separator),
586
+ orderId: key.slice(separator + 1)
587
+ };
588
+ }
579
589
  var CollabContext = createContext(null);
580
590
  function useCollab() {
581
591
  const context = useContext(CollabContext);
@@ -591,7 +601,7 @@ function CollabProvider({ config, apiBaseUrl, children }) {
591
601
  });
592
602
  const [unreadCounts, setUnreadCounts] = useState({});
593
603
  const [unreadCountsByOrder, setUnreadCountsByOrder] = useState({});
594
- const pendingOrderIds = useRef(/* @__PURE__ */ new Set());
604
+ const pendingKeys = useRef(/* @__PURE__ */ new Set());
595
605
  const pendingResolvers = useRef(/* @__PURE__ */ new Map());
596
606
  const previewCache = useRef(/* @__PURE__ */ new Map());
597
607
  const batchScheduled = useRef(false);
@@ -622,47 +632,61 @@ function CollabProvider({ config, apiBaseUrl, children }) {
622
632
  [config]
623
633
  );
624
634
  const flushPreviewBatch = useCallback(async () => {
625
- const orderIds = Array.from(pendingOrderIds.current);
635
+ const keys = Array.from(pendingKeys.current);
626
636
  const resolvers = new Map(pendingResolvers.current);
627
- pendingOrderIds.current.clear();
637
+ pendingKeys.current.clear();
628
638
  pendingResolvers.current.clear();
629
639
  batchScheduled.current = false;
630
- if (orderIds.length === 0) return;
631
- try {
632
- const query = orderIds.map((id) => `order_ids[]=${encodeURIComponent(id)}`).join("&");
633
- const response = await fetch(`${apiBaseUrl}/api/natoe-colab/conversations/previews?${query}`, {
634
- headers: authHeaders()
635
- });
636
- if (!response.ok) throw new Error(`Preview batch failed: ${response.status}`);
637
- const data = snakeToCamel(await response.json());
638
- orderIds.forEach((orderId) => {
639
- const preview = data.previews[orderId] ?? null;
640
- if (preview !== null) {
641
- previewCache.current.set(orderId, preview);
642
- }
643
- resolvers.get(orderId)?.forEach((resolve) => resolve(preview));
644
- });
645
- } catch (error) {
646
- config.onError?.({
647
- code: "PREVIEW_BATCH_ERROR",
648
- message: "Failed to fetch conversation previews",
649
- details: error
650
- });
651
- orderIds.forEach((orderId) => {
652
- resolvers.get(orderId)?.forEach((resolve) => resolve(null));
653
- });
640
+ if (keys.length === 0) return;
641
+ const byKind = /* @__PURE__ */ new Map();
642
+ for (const key of keys) {
643
+ const { orderId, kind } = parsePreviewKey(key);
644
+ const list = byKind.get(kind) ?? [];
645
+ list.push(orderId);
646
+ byKind.set(kind, list);
654
647
  }
648
+ await Promise.all(
649
+ Array.from(byKind.entries()).map(async ([kind, orderIds]) => {
650
+ try {
651
+ const query = orderIds.map((id) => `order_ids[]=${encodeURIComponent(id)}`).join("&");
652
+ const response = await fetch(
653
+ `${apiBaseUrl}/api/natoe-colab/conversations/previews?${query}&kind=${encodeURIComponent(kind)}`,
654
+ { headers: authHeaders() }
655
+ );
656
+ if (!response.ok) throw new Error(`Preview batch failed: ${response.status}`);
657
+ const data = snakeToCamel(await response.json());
658
+ orderIds.forEach((orderId) => {
659
+ const key = previewKey(orderId, kind);
660
+ const preview = data.previews[orderId] ?? null;
661
+ if (preview !== null) {
662
+ previewCache.current.set(key, preview);
663
+ }
664
+ resolvers.get(key)?.forEach((resolve) => resolve(preview));
665
+ });
666
+ } catch (error) {
667
+ config.onError?.({
668
+ code: "PREVIEW_BATCH_ERROR",
669
+ message: "Failed to fetch conversation previews",
670
+ details: error
671
+ });
672
+ orderIds.forEach((orderId) => {
673
+ resolvers.get(previewKey(orderId, kind))?.forEach((resolve) => resolve(null));
674
+ });
675
+ }
676
+ })
677
+ );
655
678
  }, [apiBaseUrl, authHeaders, config]);
656
679
  const requestPreview = useCallback(
657
- (orderId) => {
658
- if (previewCache.current.has(orderId)) {
659
- return Promise.resolve(previewCache.current.get(orderId) ?? null);
680
+ (orderId, kind = "case") => {
681
+ const key = previewKey(orderId, kind);
682
+ if (previewCache.current.has(key)) {
683
+ return Promise.resolve(previewCache.current.get(key) ?? null);
660
684
  }
661
685
  return new Promise((resolve) => {
662
- pendingOrderIds.current.add(orderId);
663
- const existing = pendingResolvers.current.get(orderId) || [];
686
+ pendingKeys.current.add(key);
687
+ const existing = pendingResolvers.current.get(key) || [];
664
688
  existing.push(resolve);
665
- pendingResolvers.current.set(orderId, existing);
689
+ pendingResolvers.current.set(key, existing);
666
690
  if (!batchScheduled.current) {
667
691
  batchScheduled.current = true;
668
692
  queueMicrotask(flushPreviewBatch);
@@ -671,8 +695,16 @@ function CollabProvider({ config, apiBaseUrl, children }) {
671
695
  },
672
696
  [flushPreviewBatch]
673
697
  );
674
- const invalidatePreview = useCallback((orderId) => {
675
- previewCache.current.delete(orderId);
698
+ const invalidatePreview = useCallback((orderId, kind) => {
699
+ if (kind) {
700
+ previewCache.current.delete(previewKey(orderId, kind));
701
+ return;
702
+ }
703
+ for (const key of Array.from(previewCache.current.keys())) {
704
+ if (parsePreviewKey(key).orderId === orderId) {
705
+ previewCache.current.delete(key);
706
+ }
707
+ }
676
708
  }, []);
677
709
  const fetchMessages = useCallback(
678
710
  async (conversationId, options = {}) => {
@@ -699,11 +731,17 @@ function CollabProvider({ config, apiBaseUrl, children }) {
699
731
  message: "Cannot create conversation: orderId is empty. Host app passed an order with no orderId/mainOrderId set."
700
732
  };
701
733
  }
734
+ const kind = options.kind ?? "case";
702
735
  const response = await fetch(`${apiBaseUrl}/api/natoe-colab/orders/${orderId}/messages`, {
703
736
  method: "POST",
704
737
  headers: authHeaders(),
705
738
  body: JSON.stringify({
706
739
  message,
740
+ // Top-level, matching the backend's `params["kind"]`. It selects
741
+ // BOTH which thread is addressed and which authorization path runs
742
+ // — help kinds go through Access.can_access_help?/5, not the
743
+ // broader case-chat check.
744
+ kind,
707
745
  conversation: {
708
746
  name: options.name,
709
747
  participant_ids: options.participantIds ?? []
@@ -715,7 +753,7 @@ function CollabProvider({ config, apiBaseUrl, children }) {
715
753
  throw { code: "CREATE_ERROR", message: "Failed to create conversation", details: error };
716
754
  }
717
755
  const data = snakeToCamel(await response.json());
718
- previewCache.current.delete(orderId);
756
+ previewCache.current.delete(previewKey(orderId, kind));
719
757
  return data;
720
758
  },
721
759
  [apiBaseUrl, authHeaders]
@@ -847,9 +885,11 @@ function cleanChannelName(name) {
847
885
  // src/hooks/useConversation.ts
848
886
  function useConversation({
849
887
  orderId,
888
+ conversation: providedConversation,
850
889
  patientData,
851
890
  participantIds = [],
852
- loadHistory = true
891
+ loadHistory = true,
892
+ kind = "case"
853
893
  }) {
854
894
  const {
855
895
  socket,
@@ -878,6 +918,7 @@ function useConversation({
878
918
  const markedReadIdsRef = useRef(/* @__PURE__ */ new Set());
879
919
  const buildName = useCallback(
880
920
  () => {
921
+ if (!patientData) return "";
881
922
  const labName = patientData.labName ?? (config.userRole === "lab" ? config.userName : void 0);
882
923
  return buildChannelName({ ...patientData, labName });
883
924
  },
@@ -977,7 +1018,29 @@ function useConversation({
977
1018
  setIsLoading(true);
978
1019
  setError(null);
979
1020
  try {
980
- const preview = await requestPreview(orderId);
1021
+ if (providedConversation) {
1022
+ setConversation(providedConversation);
1023
+ setParticipants(providedConversation.participants ?? []);
1024
+ joinChannel(providedConversation);
1025
+ if (loadHistory) {
1026
+ const history = await fetchMessages(providedConversation.id);
1027
+ if (cancelled) return;
1028
+ setMessages(history);
1029
+ setHasMore(history.length >= MESSAGES_PAGE_SIZE);
1030
+ setPinnedMessages(history.filter((m) => m.isPinned));
1031
+ }
1032
+ if (!cancelled) setIsLoading(false);
1033
+ return;
1034
+ }
1035
+ if (!orderId) {
1036
+ setConversation(null);
1037
+ setMessages([]);
1038
+ setParticipants([]);
1039
+ setHasMore(false);
1040
+ setIsLoading(false);
1041
+ return;
1042
+ }
1043
+ const preview = await requestPreview(orderId, kind);
981
1044
  if (cancelled) return;
982
1045
  if (!preview) {
983
1046
  setConversation(null);
@@ -1034,10 +1097,16 @@ function useConversation({
1034
1097
  markedReadIdsRef.current.clear();
1035
1098
  setIsConnected(false);
1036
1099
  };
1037
- }, [orderId]);
1100
+ }, [orderId, kind]);
1038
1101
  const ensureConversation = useCallback(
1039
1102
  async (payload) => {
1040
1103
  if (conversation) return { conv: conversation, persistedByCreate: false };
1104
+ if (!orderId) {
1105
+ throw {
1106
+ code: "NO_CONVERSATION",
1107
+ message: "This conversation is not available yet. Please reopen it and try again."
1108
+ };
1109
+ }
1041
1110
  if (ensureConversationInFlight.current) {
1042
1111
  const conv = await ensureConversationInFlight.current;
1043
1112
  return { conv, persistedByCreate: false };
@@ -1045,13 +1114,14 @@ function useConversation({
1045
1114
  const inflight = (async () => {
1046
1115
  const result = await createConversationWithMessage(orderId, payload, {
1047
1116
  name: buildName(),
1048
- participantIds
1117
+ participantIds,
1118
+ kind
1049
1119
  });
1050
1120
  setConversation(result.conversation);
1051
1121
  setParticipants(result.conversation.participants);
1052
1122
  setMessages([result.message]);
1053
1123
  joinChannel(result.conversation);
1054
- invalidatePreview(orderId);
1124
+ invalidatePreview(orderId, kind);
1055
1125
  if (loadHistory) {
1056
1126
  fetchMessages(result.conversation.id).then((history) => {
1057
1127
  setMessages((prev) => {
@@ -1081,6 +1151,7 @@ function useConversation({
1081
1151
  conversation,
1082
1152
  createConversationWithMessage,
1083
1153
  orderId,
1154
+ kind,
1084
1155
  buildName,
1085
1156
  participantIds,
1086
1157
  joinChannel,
@@ -1523,15 +1594,19 @@ function PatientHeader({
1523
1594
  onBack,
1524
1595
  hideName = false,
1525
1596
  displayName,
1597
+ isSupportChannel = false,
1526
1598
  className
1527
1599
  }) {
1528
- const hasDicom = !!(patientData.studyId && patientData.storageId);
1529
- const resolvedName = resolveDisplayName(patientData, displayName);
1600
+ const hasDicom = !isSupportChannel && !!(patientData.studyId && patientData.storageId);
1601
+ const resolvedName = isSupportChannel ? displayName || patientData.patientName || "Natoe Support" : resolveDisplayName(patientData, displayName);
1530
1602
  const metaParts = [];
1531
- if (patientData.patientAge) metaParts.push(String(patientData.patientAge));
1532
- if (patientData.patientSex) metaParts.push(String(patientData.patientSex));
1533
- if (patientData.studyType) metaParts.push(patientData.studyType);
1534
- if (patientData.bodyParts && patientData.bodyParts.length > 0) {
1603
+ if (isSupportChannel) {
1604
+ metaParts.push("Help and support");
1605
+ }
1606
+ if (!isSupportChannel && patientData.patientAge) metaParts.push(String(patientData.patientAge));
1607
+ if (!isSupportChannel && patientData.patientSex) metaParts.push(String(patientData.patientSex));
1608
+ if (!isSupportChannel && patientData.studyType) metaParts.push(patientData.studyType);
1609
+ if (!isSupportChannel && patientData.bodyParts && patientData.bodyParts.length > 0) {
1535
1610
  metaParts.push(patientData.bodyParts.join(", "));
1536
1611
  }
1537
1612
  const hasPrimaryActions = hasDicom && onOpenDicom || onOpenCase;
@@ -4488,8 +4563,10 @@ var DARK_THEME_OVERRIDES = {
4488
4563
  };
4489
4564
  function CollabPanel({
4490
4565
  orderId,
4566
+ conversation: providedConversation,
4491
4567
  patientData,
4492
4568
  participantIds,
4569
+ kind,
4493
4570
  showSeenBy = true,
4494
4571
  onBack,
4495
4572
  hidePatientName = false,
@@ -4528,7 +4605,13 @@ function CollabPanel({
4528
4605
  loadMoreMessages,
4529
4606
  pinMessage,
4530
4607
  unpinMessage
4531
- } = useConversation({ orderId, patientData, participantIds });
4608
+ } = useConversation({
4609
+ orderId,
4610
+ conversation: providedConversation,
4611
+ patientData,
4612
+ participantIds,
4613
+ kind
4614
+ });
4532
4615
  useEffect(() => {
4533
4616
  onConversationChange?.(conversation);
4534
4617
  }, [conversation, onConversationChange]);
@@ -4537,11 +4620,11 @@ function CollabPanel({
4537
4620
  });
4538
4621
  const { handleDeepLink } = useDeepLinks();
4539
4622
  const handleOpenDicom = () => {
4540
- if (patientData.studyId && patientData.storageId && config.onOpenDicom) {
4623
+ if (patientData?.studyId && patientData.storageId && config.onOpenDicom) {
4541
4624
  config.onOpenDicom(patientData.studyId, patientData.storageId);
4542
4625
  }
4543
4626
  };
4544
- const handleOpenCase = !hideOpenCase && config.onOpenCase ? () => config.onOpenCase?.(patientData.orderId, patientData.displayOrderId) : void 0;
4627
+ const handleOpenCase = !hideOpenCase && config.onOpenCase && patientData?.orderId ? () => config.onOpenCase?.(patientData.orderId, patientData.displayOrderId) : void 0;
4545
4628
  const handleJumpToMessage = (messageId) => {
4546
4629
  messageListRef.current?.scrollToMessage(messageId);
4547
4630
  };
@@ -4614,7 +4697,8 @@ function CollabPanel({
4614
4697
  /* @__PURE__ */ jsx(
4615
4698
  PatientHeader,
4616
4699
  {
4617
- patientData,
4700
+ patientData: patientData ?? { orderId: "", patientName: conversation?.name ?? "Natoe Support" },
4701
+ isSupportChannel: !patientData,
4618
4702
  participants,
4619
4703
  onOpenDicom: handleOpenDicom,
4620
4704
  onOpenCase: handleOpenCase,
@@ -4747,6 +4831,7 @@ function CollabPopup({
4747
4831
  orderId,
4748
4832
  patientData,
4749
4833
  participantIds,
4834
+ kind,
4750
4835
  isOpen,
4751
4836
  onClose,
4752
4837
  onBack,
@@ -4935,6 +5020,7 @@ function CollabPopup({
4935
5020
  orderId,
4936
5021
  patientData,
4937
5022
  participantIds,
5023
+ kind,
4938
5024
  hidePatientName: true,
4939
5025
  onConversationChange: setLoadedConversation,
4940
5026
  showSettings,
@@ -5052,7 +5138,8 @@ function useInlineCollab({
5052
5138
  orderId,
5053
5139
  patientData,
5054
5140
  participantIds = [],
5055
- messageLimit = 5
5141
+ messageLimit = 5,
5142
+ kind = "case"
5056
5143
  }) {
5057
5144
  const {
5058
5145
  socket,
@@ -5100,7 +5187,7 @@ function useInlineCollab({
5100
5187
  setIsLoading(true);
5101
5188
  setError(null);
5102
5189
  try {
5103
- const result = await requestPreview(orderId);
5190
+ const result = await requestPreview(orderId, kind);
5104
5191
  if (cancelled) return;
5105
5192
  setPreview(result);
5106
5193
  if (result) {
@@ -5196,7 +5283,8 @@ function useInlineCollab({
5196
5283
  try {
5197
5284
  const result = await createConversationWithMessage(orderId, payload, {
5198
5285
  name: buildName(),
5199
- participantIds
5286
+ participantIds,
5287
+ kind
5200
5288
  });
5201
5289
  const newPreview = {
5202
5290
  conversationId: result.conversation.id,
@@ -5210,7 +5298,7 @@ function useInlineCollab({
5210
5298
  setPreview(newPreview);
5211
5299
  setMessages([result.message]);
5212
5300
  setParticipants(result.conversation.participants);
5213
- invalidatePreview(orderId);
5301
+ invalidatePreview(orderId, kind);
5214
5302
  if (elementRef.current) {
5215
5303
  subscribe(result.conversation.id);
5216
5304
  }
@@ -5335,6 +5423,7 @@ function CollabInline({
5335
5423
  orderId,
5336
5424
  patientData,
5337
5425
  participantIds,
5426
+ kind,
5338
5427
  onExpand,
5339
5428
  messageLimit = 1,
5340
5429
  placeholder = "Discuss this case\u2026",
@@ -5353,7 +5442,7 @@ function CollabInline({
5353
5442
  sendMessage,
5354
5443
  sendAudioMessage,
5355
5444
  containerRef
5356
- } = useInlineCollab({ orderId, patientData, participantIds, messageLimit });
5445
+ } = useInlineCollab({ orderId, patientData, participantIds, messageLimit, kind });
5357
5446
  const containerStyle = {
5358
5447
  ...styles14.container,
5359
5448
  backgroundColor: pal.bg,
@@ -5845,6 +5934,14 @@ function useConversationList(options) {
5845
5934
  totalUnread
5846
5935
  };
5847
5936
  }
5937
+
5938
+ // src/core/types.ts
5939
+ function isHelpKind(kind) {
5940
+ return kind === "help_lab";
5941
+ }
5942
+ function isCaseKind(kind) {
5943
+ return kind === void 0 || kind === "case";
5944
+ }
5848
5945
  function ConversationListItem({
5849
5946
  item,
5850
5947
  isSelected,
@@ -5881,6 +5978,10 @@ function ConversationListItem({
5881
5978
  children: displayName
5882
5979
  }
5883
5980
  ),
5981
+ isHelpKind(item.kind) && /* A case can show two rows with the same patient name — the shared
5982
+ case thread and a private help thread. Without this the two are
5983
+ indistinguishable in the inbox. */
5984
+ /* @__PURE__ */ jsx("span", { style: styles15.helpPill, "aria-label": "Help thread", children: "Help" }),
5884
5985
  /* @__PURE__ */ jsx(
5885
5986
  "span",
5886
5987
  {
@@ -6027,6 +6128,17 @@ var styles15 = {
6027
6128
  nameUnread: {
6028
6129
  fontWeight: FONT_WEIGHT.bold
6029
6130
  },
6131
+ helpPill: {
6132
+ fontSize: FONT_SIZE.xs,
6133
+ fontWeight: FONT_WEIGHT.semibold,
6134
+ color: COLOR.primary,
6135
+ background: COLOR.primaryBg,
6136
+ borderRadius: RADIUS.sm,
6137
+ padding: `0 ${SPACE.S2}`,
6138
+ lineHeight: "16px",
6139
+ flexShrink: 0,
6140
+ whiteSpace: "nowrap"
6141
+ },
6030
6142
  time: {
6031
6143
  fontSize: FONT_SIZE.xs,
6032
6144
  color: COLOR.neutral500,
@@ -6464,6 +6576,7 @@ function CollabInbox({
6464
6576
  );
6465
6577
  }
6466
6578
  function buildPatientData(item) {
6579
+ if (isHelpKind(item.kind) || !item.orderId) return void 0;
6467
6580
  const parsed = parseChannelName(item.name, item.orderId);
6468
6581
  if (item.patientSnapshot) {
6469
6582
  return {
@@ -6770,6 +6883,6 @@ function usePinnedMessages({
6770
6883
  };
6771
6884
  }
6772
6885
 
6773
- export { AUDIO_MIME_TYPE, ChannelSettings, CollabInbox, CollabInline, CollabPanel, CollabPopup, CollabProvider, CollabSocket, ConversationList, ConversationListItem, DEEP_LINK_PREFIX, EVENTS, MAX_FILE_SIZE, MAX_PINNED_MESSAGES, MESSAGES_PAGE_SIZE, MESSAGE_TYPES, MessageActionsMenu, MessageBubble, MessageInput, MessageList, ParticipantsList, PatientHeader, PinnedMessagesBar, ReplyPreview, ReplyQuoteBlock, SUPPORTED_IMAGE_TYPES, SeenByIndicator, THEME_DEFAULTS, THEME_VAR, TYPING_DEBOUNCE_MS, applyThemeOverrides, useAudioRecorder, useChannelSettings, useCollab, useConversation, useConversationList, useDeepLinks, useInlineCollab, useMessages, usePinnedMessages, useUnreadCount };
6886
+ export { AUDIO_MIME_TYPE, ChannelSettings, CollabInbox, CollabInline, CollabPanel, CollabPopup, CollabProvider, CollabSocket, ConversationList, ConversationListItem, DEEP_LINK_PREFIX, EVENTS, MAX_FILE_SIZE, MAX_PINNED_MESSAGES, MESSAGES_PAGE_SIZE, MESSAGE_TYPES, MessageActionsMenu, MessageBubble, MessageInput, MessageList, ParticipantsList, PatientHeader, PinnedMessagesBar, ReplyPreview, ReplyQuoteBlock, SUPPORTED_IMAGE_TYPES, SeenByIndicator, THEME_DEFAULTS, THEME_VAR, TYPING_DEBOUNCE_MS, applyThemeOverrides, isCaseKind, isHelpKind, useAudioRecorder, useChannelSettings, useCollab, useConversation, useConversationList, useDeepLinks, useInlineCollab, useMessages, usePinnedMessages, useUnreadCount };
6774
6887
  //# sourceMappingURL=index.mjs.map
6775
6888
  //# sourceMappingURL=index.mjs.map