@natoe/colab 0.1.26 → 0.1.28

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]
@@ -849,7 +887,8 @@ function useConversation({
849
887
  orderId,
850
888
  patientData,
851
889
  participantIds = [],
852
- loadHistory = true
890
+ loadHistory = true,
891
+ kind = "case"
853
892
  }) {
854
893
  const {
855
894
  socket,
@@ -977,7 +1016,7 @@ function useConversation({
977
1016
  setIsLoading(true);
978
1017
  setError(null);
979
1018
  try {
980
- const preview = await requestPreview(orderId);
1019
+ const preview = await requestPreview(orderId, kind);
981
1020
  if (cancelled) return;
982
1021
  if (!preview) {
983
1022
  setConversation(null);
@@ -1034,7 +1073,7 @@ function useConversation({
1034
1073
  markedReadIdsRef.current.clear();
1035
1074
  setIsConnected(false);
1036
1075
  };
1037
- }, [orderId]);
1076
+ }, [orderId, kind]);
1038
1077
  const ensureConversation = useCallback(
1039
1078
  async (payload) => {
1040
1079
  if (conversation) return { conv: conversation, persistedByCreate: false };
@@ -1045,13 +1084,14 @@ function useConversation({
1045
1084
  const inflight = (async () => {
1046
1085
  const result = await createConversationWithMessage(orderId, payload, {
1047
1086
  name: buildName(),
1048
- participantIds
1087
+ participantIds,
1088
+ kind
1049
1089
  });
1050
1090
  setConversation(result.conversation);
1051
1091
  setParticipants(result.conversation.participants);
1052
1092
  setMessages([result.message]);
1053
1093
  joinChannel(result.conversation);
1054
- invalidatePreview(orderId);
1094
+ invalidatePreview(orderId, kind);
1055
1095
  if (loadHistory) {
1056
1096
  fetchMessages(result.conversation.id).then((history) => {
1057
1097
  setMessages((prev) => {
@@ -1081,6 +1121,7 @@ function useConversation({
1081
1121
  conversation,
1082
1122
  createConversationWithMessage,
1083
1123
  orderId,
1124
+ kind,
1084
1125
  buildName,
1085
1126
  participantIds,
1086
1127
  joinChannel,
@@ -4490,6 +4531,7 @@ function CollabPanel({
4490
4531
  orderId,
4491
4532
  patientData,
4492
4533
  participantIds,
4534
+ kind,
4493
4535
  showSeenBy = true,
4494
4536
  onBack,
4495
4537
  hidePatientName = false,
@@ -4528,7 +4570,7 @@ function CollabPanel({
4528
4570
  loadMoreMessages,
4529
4571
  pinMessage,
4530
4572
  unpinMessage
4531
- } = useConversation({ orderId, patientData, participantIds });
4573
+ } = useConversation({ orderId, patientData, participantIds, kind });
4532
4574
  useEffect(() => {
4533
4575
  onConversationChange?.(conversation);
4534
4576
  }, [conversation, onConversationChange]);
@@ -4747,6 +4789,7 @@ function CollabPopup({
4747
4789
  orderId,
4748
4790
  patientData,
4749
4791
  participantIds,
4792
+ kind,
4750
4793
  isOpen,
4751
4794
  onClose,
4752
4795
  onBack,
@@ -4935,6 +4978,7 @@ function CollabPopup({
4935
4978
  orderId,
4936
4979
  patientData,
4937
4980
  participantIds,
4981
+ kind,
4938
4982
  hidePatientName: true,
4939
4983
  onConversationChange: setLoadedConversation,
4940
4984
  showSettings,
@@ -5052,7 +5096,8 @@ function useInlineCollab({
5052
5096
  orderId,
5053
5097
  patientData,
5054
5098
  participantIds = [],
5055
- messageLimit = 5
5099
+ messageLimit = 5,
5100
+ kind = "case"
5056
5101
  }) {
5057
5102
  const {
5058
5103
  socket,
@@ -5071,6 +5116,8 @@ function useInlineCollab({
5071
5116
  const elementRef = useRef(null);
5072
5117
  const observerRef = useRef(null);
5073
5118
  const subscribedConversationIdRef = useRef(null);
5119
+ const latestMessageIdRef = useRef(null);
5120
+ const lastMarkedReadIdRef = useRef(null);
5074
5121
  const channelSubscriptionRef = useRef(null);
5075
5122
  const trimToLimit = useCallback(
5076
5123
  (msgs) => msgs.length > messageLimit ? msgs.slice(msgs.length - messageLimit) : msgs,
@@ -5080,13 +5127,25 @@ function useInlineCollab({
5080
5127
  () => buildChannelName(patientData),
5081
5128
  [patientData]
5082
5129
  );
5130
+ useEffect(() => {
5131
+ latestMessageIdRef.current = messages[messages.length - 1]?.id ?? null;
5132
+ }, [messages]);
5133
+ const markLatestRead = useCallback(
5134
+ (conversationId) => {
5135
+ const messageId = latestMessageIdRef.current;
5136
+ if (!messageId || lastMarkedReadIdRef.current === messageId) return;
5137
+ lastMarkedReadIdRef.current = messageId;
5138
+ socket.markAsRead(conversationId, messageId);
5139
+ },
5140
+ [socket]
5141
+ );
5083
5142
  useEffect(() => {
5084
5143
  let cancelled = false;
5085
5144
  const load = async () => {
5086
5145
  setIsLoading(true);
5087
5146
  setError(null);
5088
5147
  try {
5089
- const result = await requestPreview(orderId);
5148
+ const result = await requestPreview(orderId, kind);
5090
5149
  if (cancelled) return;
5091
5150
  setPreview(result);
5092
5151
  if (result) {
@@ -5182,7 +5241,8 @@ function useInlineCollab({
5182
5241
  try {
5183
5242
  const result = await createConversationWithMessage(orderId, payload, {
5184
5243
  name: buildName(),
5185
- participantIds
5244
+ participantIds,
5245
+ kind
5186
5246
  });
5187
5247
  const newPreview = {
5188
5248
  conversationId: result.conversation.id,
@@ -5196,7 +5256,7 @@ function useInlineCollab({
5196
5256
  setPreview(newPreview);
5197
5257
  setMessages([result.message]);
5198
5258
  setParticipants(result.conversation.participants);
5199
- invalidatePreview(orderId);
5259
+ invalidatePreview(orderId, kind);
5200
5260
  if (elementRef.current) {
5201
5261
  subscribe(result.conversation.id);
5202
5262
  }
@@ -5213,6 +5273,7 @@ function useInlineCollab({
5213
5273
  try {
5214
5274
  await socket.sendMessage(preview.conversationId, payload);
5215
5275
  setUnreadCount(0);
5276
+ markLatestRead(preview.conversationId);
5216
5277
  } catch (err) {
5217
5278
  setError(err instanceof Error ? err.message : "Failed to send message");
5218
5279
  config.onError?.({
@@ -5231,7 +5292,8 @@ function useInlineCollab({
5231
5292
  invalidatePreview,
5232
5293
  subscribe,
5233
5294
  socket,
5234
- config
5295
+ config,
5296
+ markLatestRead
5235
5297
  ]
5236
5298
  );
5237
5299
  const sendMessage = useCallback(
@@ -5319,6 +5381,7 @@ function CollabInline({
5319
5381
  orderId,
5320
5382
  patientData,
5321
5383
  participantIds,
5384
+ kind,
5322
5385
  onExpand,
5323
5386
  messageLimit = 1,
5324
5387
  placeholder = "Discuss this case\u2026",
@@ -5337,7 +5400,7 @@ function CollabInline({
5337
5400
  sendMessage,
5338
5401
  sendAudioMessage,
5339
5402
  containerRef
5340
- } = useInlineCollab({ orderId, patientData, participantIds, messageLimit });
5403
+ } = useInlineCollab({ orderId, patientData, participantIds, messageLimit, kind });
5341
5404
  const containerStyle = {
5342
5405
  ...styles14.container,
5343
5406
  backgroundColor: pal.bg,
@@ -5829,6 +5892,11 @@ function useConversationList(options) {
5829
5892
  totalUnread
5830
5893
  };
5831
5894
  }
5895
+
5896
+ // src/core/types.ts
5897
+ function isHelpKind(kind) {
5898
+ return kind === "help_lab" || kind === "help_radiologist";
5899
+ }
5832
5900
  function ConversationListItem({
5833
5901
  item,
5834
5902
  isSelected,
@@ -5865,6 +5933,10 @@ function ConversationListItem({
5865
5933
  children: displayName
5866
5934
  }
5867
5935
  ),
5936
+ isHelpKind(item.kind) && /* A case can show two rows with the same patient name — the shared
5937
+ case thread and a private help thread. Without this the two are
5938
+ indistinguishable in the inbox. */
5939
+ /* @__PURE__ */ jsx("span", { style: styles15.helpPill, "aria-label": "Help thread", children: "Help" }),
5868
5940
  /* @__PURE__ */ jsx(
5869
5941
  "span",
5870
5942
  {
@@ -6011,6 +6083,17 @@ var styles15 = {
6011
6083
  nameUnread: {
6012
6084
  fontWeight: FONT_WEIGHT.bold
6013
6085
  },
6086
+ helpPill: {
6087
+ fontSize: FONT_SIZE.xs,
6088
+ fontWeight: FONT_WEIGHT.semibold,
6089
+ color: COLOR.primary,
6090
+ background: COLOR.primaryBg,
6091
+ borderRadius: RADIUS.sm,
6092
+ padding: `0 ${SPACE.S2}`,
6093
+ lineHeight: "16px",
6094
+ flexShrink: 0,
6095
+ whiteSpace: "nowrap"
6096
+ },
6014
6097
  time: {
6015
6098
  fontSize: FONT_SIZE.xs,
6016
6099
  color: COLOR.neutral500,
@@ -6754,6 +6837,6 @@ function usePinnedMessages({
6754
6837
  };
6755
6838
  }
6756
6839
 
6757
- 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 };
6840
+ 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, isHelpKind, useAudioRecorder, useChannelSettings, useCollab, useConversation, useConversationList, useDeepLinks, useInlineCollab, useMessages, usePinnedMessages, useUnreadCount };
6758
6841
  //# sourceMappingURL=index.mjs.map
6759
6842
  //# sourceMappingURL=index.mjs.map