@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.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]
@@ -855,7 +893,8 @@ function useConversation({
855
893
  orderId,
856
894
  patientData,
857
895
  participantIds = [],
858
- loadHistory = true
896
+ loadHistory = true,
897
+ kind = "case"
859
898
  }) {
860
899
  const {
861
900
  socket,
@@ -983,7 +1022,7 @@ function useConversation({
983
1022
  setIsLoading(true);
984
1023
  setError(null);
985
1024
  try {
986
- const preview = await requestPreview(orderId);
1025
+ const preview = await requestPreview(orderId, kind);
987
1026
  if (cancelled) return;
988
1027
  if (!preview) {
989
1028
  setConversation(null);
@@ -1040,7 +1079,7 @@ function useConversation({
1040
1079
  markedReadIdsRef.current.clear();
1041
1080
  setIsConnected(false);
1042
1081
  };
1043
- }, [orderId]);
1082
+ }, [orderId, kind]);
1044
1083
  const ensureConversation = React4.useCallback(
1045
1084
  async (payload) => {
1046
1085
  if (conversation) return { conv: conversation, persistedByCreate: false };
@@ -1051,13 +1090,14 @@ function useConversation({
1051
1090
  const inflight = (async () => {
1052
1091
  const result = await createConversationWithMessage(orderId, payload, {
1053
1092
  name: buildName(),
1054
- participantIds
1093
+ participantIds,
1094
+ kind
1055
1095
  });
1056
1096
  setConversation(result.conversation);
1057
1097
  setParticipants(result.conversation.participants);
1058
1098
  setMessages([result.message]);
1059
1099
  joinChannel(result.conversation);
1060
- invalidatePreview(orderId);
1100
+ invalidatePreview(orderId, kind);
1061
1101
  if (loadHistory) {
1062
1102
  fetchMessages(result.conversation.id).then((history) => {
1063
1103
  setMessages((prev) => {
@@ -1087,6 +1127,7 @@ function useConversation({
1087
1127
  conversation,
1088
1128
  createConversationWithMessage,
1089
1129
  orderId,
1130
+ kind,
1090
1131
  buildName,
1091
1132
  participantIds,
1092
1133
  joinChannel,
@@ -4496,6 +4537,7 @@ function CollabPanel({
4496
4537
  orderId,
4497
4538
  patientData,
4498
4539
  participantIds,
4540
+ kind,
4499
4541
  showSeenBy = true,
4500
4542
  onBack,
4501
4543
  hidePatientName = false,
@@ -4534,7 +4576,7 @@ function CollabPanel({
4534
4576
  loadMoreMessages,
4535
4577
  pinMessage,
4536
4578
  unpinMessage
4537
- } = useConversation({ orderId, patientData, participantIds });
4579
+ } = useConversation({ orderId, patientData, participantIds, kind });
4538
4580
  React4.useEffect(() => {
4539
4581
  onConversationChange?.(conversation);
4540
4582
  }, [conversation, onConversationChange]);
@@ -4753,6 +4795,7 @@ function CollabPopup({
4753
4795
  orderId,
4754
4796
  patientData,
4755
4797
  participantIds,
4798
+ kind,
4756
4799
  isOpen,
4757
4800
  onClose,
4758
4801
  onBack,
@@ -4941,6 +4984,7 @@ function CollabPopup({
4941
4984
  orderId,
4942
4985
  patientData,
4943
4986
  participantIds,
4987
+ kind,
4944
4988
  hidePatientName: true,
4945
4989
  onConversationChange: setLoadedConversation,
4946
4990
  showSettings,
@@ -5058,7 +5102,8 @@ function useInlineCollab({
5058
5102
  orderId,
5059
5103
  patientData,
5060
5104
  participantIds = [],
5061
- messageLimit = 5
5105
+ messageLimit = 5,
5106
+ kind = "case"
5062
5107
  }) {
5063
5108
  const {
5064
5109
  socket,
@@ -5077,6 +5122,8 @@ function useInlineCollab({
5077
5122
  const elementRef = React4.useRef(null);
5078
5123
  const observerRef = React4.useRef(null);
5079
5124
  const subscribedConversationIdRef = React4.useRef(null);
5125
+ const latestMessageIdRef = React4.useRef(null);
5126
+ const lastMarkedReadIdRef = React4.useRef(null);
5080
5127
  const channelSubscriptionRef = React4.useRef(null);
5081
5128
  const trimToLimit = React4.useCallback(
5082
5129
  (msgs) => msgs.length > messageLimit ? msgs.slice(msgs.length - messageLimit) : msgs,
@@ -5086,13 +5133,25 @@ function useInlineCollab({
5086
5133
  () => buildChannelName(patientData),
5087
5134
  [patientData]
5088
5135
  );
5136
+ React4.useEffect(() => {
5137
+ latestMessageIdRef.current = messages[messages.length - 1]?.id ?? null;
5138
+ }, [messages]);
5139
+ const markLatestRead = React4.useCallback(
5140
+ (conversationId) => {
5141
+ const messageId = latestMessageIdRef.current;
5142
+ if (!messageId || lastMarkedReadIdRef.current === messageId) return;
5143
+ lastMarkedReadIdRef.current = messageId;
5144
+ socket.markAsRead(conversationId, messageId);
5145
+ },
5146
+ [socket]
5147
+ );
5089
5148
  React4.useEffect(() => {
5090
5149
  let cancelled = false;
5091
5150
  const load = async () => {
5092
5151
  setIsLoading(true);
5093
5152
  setError(null);
5094
5153
  try {
5095
- const result = await requestPreview(orderId);
5154
+ const result = await requestPreview(orderId, kind);
5096
5155
  if (cancelled) return;
5097
5156
  setPreview(result);
5098
5157
  if (result) {
@@ -5188,7 +5247,8 @@ function useInlineCollab({
5188
5247
  try {
5189
5248
  const result = await createConversationWithMessage(orderId, payload, {
5190
5249
  name: buildName(),
5191
- participantIds
5250
+ participantIds,
5251
+ kind
5192
5252
  });
5193
5253
  const newPreview = {
5194
5254
  conversationId: result.conversation.id,
@@ -5202,7 +5262,7 @@ function useInlineCollab({
5202
5262
  setPreview(newPreview);
5203
5263
  setMessages([result.message]);
5204
5264
  setParticipants(result.conversation.participants);
5205
- invalidatePreview(orderId);
5265
+ invalidatePreview(orderId, kind);
5206
5266
  if (elementRef.current) {
5207
5267
  subscribe(result.conversation.id);
5208
5268
  }
@@ -5219,6 +5279,7 @@ function useInlineCollab({
5219
5279
  try {
5220
5280
  await socket.sendMessage(preview.conversationId, payload);
5221
5281
  setUnreadCount(0);
5282
+ markLatestRead(preview.conversationId);
5222
5283
  } catch (err) {
5223
5284
  setError(err instanceof Error ? err.message : "Failed to send message");
5224
5285
  config.onError?.({
@@ -5237,7 +5298,8 @@ function useInlineCollab({
5237
5298
  invalidatePreview,
5238
5299
  subscribe,
5239
5300
  socket,
5240
- config
5301
+ config,
5302
+ markLatestRead
5241
5303
  ]
5242
5304
  );
5243
5305
  const sendMessage = React4.useCallback(
@@ -5325,6 +5387,7 @@ function CollabInline({
5325
5387
  orderId,
5326
5388
  patientData,
5327
5389
  participantIds,
5390
+ kind,
5328
5391
  onExpand,
5329
5392
  messageLimit = 1,
5330
5393
  placeholder = "Discuss this case\u2026",
@@ -5343,7 +5406,7 @@ function CollabInline({
5343
5406
  sendMessage,
5344
5407
  sendAudioMessage,
5345
5408
  containerRef
5346
- } = useInlineCollab({ orderId, patientData, participantIds, messageLimit });
5409
+ } = useInlineCollab({ orderId, patientData, participantIds, messageLimit, kind });
5347
5410
  const containerStyle = {
5348
5411
  ...styles14.container,
5349
5412
  backgroundColor: pal.bg,
@@ -5835,6 +5898,11 @@ function useConversationList(options) {
5835
5898
  totalUnread
5836
5899
  };
5837
5900
  }
5901
+
5902
+ // src/core/types.ts
5903
+ function isHelpKind(kind) {
5904
+ return kind === "help_lab" || kind === "help_radiologist";
5905
+ }
5838
5906
  function ConversationListItem({
5839
5907
  item,
5840
5908
  isSelected,
@@ -5871,6 +5939,10 @@ function ConversationListItem({
5871
5939
  children: displayName
5872
5940
  }
5873
5941
  ),
5942
+ isHelpKind(item.kind) && /* A case can show two rows with the same patient name — the shared
5943
+ case thread and a private help thread. Without this the two are
5944
+ indistinguishable in the inbox. */
5945
+ /* @__PURE__ */ jsxRuntime.jsx("span", { style: styles15.helpPill, "aria-label": "Help thread", children: "Help" }),
5874
5946
  /* @__PURE__ */ jsxRuntime.jsx(
5875
5947
  "span",
5876
5948
  {
@@ -6017,6 +6089,17 @@ var styles15 = {
6017
6089
  nameUnread: {
6018
6090
  fontWeight: FONT_WEIGHT.bold
6019
6091
  },
6092
+ helpPill: {
6093
+ fontSize: FONT_SIZE.xs,
6094
+ fontWeight: FONT_WEIGHT.semibold,
6095
+ color: COLOR.primary,
6096
+ background: COLOR.primaryBg,
6097
+ borderRadius: RADIUS.sm,
6098
+ padding: `0 ${SPACE.S2}`,
6099
+ lineHeight: "16px",
6100
+ flexShrink: 0,
6101
+ whiteSpace: "nowrap"
6102
+ },
6020
6103
  time: {
6021
6104
  fontSize: FONT_SIZE.xs,
6022
6105
  color: COLOR.neutral500,
@@ -6791,6 +6874,7 @@ exports.THEME_DEFAULTS = THEME_DEFAULTS;
6791
6874
  exports.THEME_VAR = THEME_VAR;
6792
6875
  exports.TYPING_DEBOUNCE_MS = TYPING_DEBOUNCE_MS;
6793
6876
  exports.applyThemeOverrides = applyThemeOverrides;
6877
+ exports.isHelpKind = isHelpKind;
6794
6878
  exports.useAudioRecorder = useAudioRecorder;
6795
6879
  exports.useChannelSettings = useChannelSettings;
6796
6880
  exports.useCollab = useCollab;