@adatechnology/conversations-ui 0.7.0 → 0.8.0

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.
Files changed (47) hide show
  1. package/README.md +96 -1
  2. package/dist/{ConversationSimulatorPanel-CrfFjiWh.d.ts → ConversationSimulatorPanel-q-wVgBCN.d.ts} +1 -1
  3. package/dist/index.d.ts +4 -4
  4. package/dist/participant/index.d.ts +45 -3
  5. package/dist/participant/index.js +287 -112
  6. package/dist/preview/index.d.ts +3 -3
  7. package/dist/{quickReply.types-B4gmZsSr.d.ts → quickReply.types-DzWH3NYV.d.ts} +1 -1
  8. package/dist/styles.css +74 -29
  9. package/package.json +1 -1
  10. package/src/participant/ConversationThread.test.tsx +165 -0
  11. package/src/participant/ConversationThread.tsx +117 -0
  12. package/src/participant/ParticipantMessageBubble.tsx +7 -3
  13. package/src/participant/ParticipantThread.tsx +9 -0
  14. package/src/participant/ParticipantThreadHeader.tsx +10 -0
  15. package/src/participant/ParticipantThreadScreen.tsx +6 -46
  16. package/src/participant/ParticipantTimeline.tsx +6 -3
  17. package/src/participant/conversationThreadApi.test.ts +27 -0
  18. package/src/participant/conversationThreadApi.ts +24 -0
  19. package/src/participant/conversationThreadCss.test.ts +116 -0
  20. package/src/participant/conversationThreadSummary.ts +34 -0
  21. package/src/participant/index.ts +3 -0
  22. package/src/participant/participantContrast.test-helper.ts +116 -0
  23. package/src/participant/participantGolden.test.tsx +46 -0
  24. package/src/participant/participantGoldenScenarios.test-helper.tsx +57 -0
  25. package/src/participant/participantMessages.ts +4 -3
  26. package/src/participant/participantNewMessages.test.ts +21 -0
  27. package/src/participant/participantNewMessages.ts +8 -3
  28. package/src/participant/participantPerspective.test.ts +67 -0
  29. package/src/participant/participantPerspective.ts +50 -0
  30. package/src/participant/participantPerspectiveRender.test.tsx +94 -0
  31. package/src/participant/participantThemeContrast.test.ts +98 -0
  32. package/src/participant/test-fixtures/conversations-list.golden.html +1 -0
  33. package/src/participant/test-fixtures/conversations-selected.golden.html +1 -0
  34. package/src/participant/test-fixtures/thread-avatars-initials.golden.html +1 -0
  35. package/src/participant/test-fixtures/thread-closed.golden.html +1 -0
  36. package/src/participant/test-fixtures/thread-default.golden.html +1 -0
  37. package/src/participant/test-fixtures/thread-error.golden.html +1 -0
  38. package/src/participant/test-fixtures/thread-loading.golden.html +1 -0
  39. package/src/participant/test-fixtures/thread-plain.golden.html +1 -0
  40. package/src/participant/test-fixtures/thread-tail-off.golden.html +1 -0
  41. package/src/participant/useParticipantConversationView.ts +3 -0
  42. package/src/participant/useParticipantThread.ts +13 -2
  43. package/src/participant/useParticipantThreadController.ts +112 -0
  44. package/src/participant/useParticipantThreadScroll.ts +3 -1
  45. package/src/participant/useParticipantUnread.test.tsx +40 -0
  46. package/src/participant/useParticipantUnread.ts +38 -0
  47. package/src/styles.css +24 -12
@@ -7,7 +7,7 @@ import {
7
7
  } from "../chunk-O6457XHK.js";
8
8
 
9
9
  // src/participant/ParticipantConversationsScreen.tsx
10
- import { useMemo as useMemo5, useReducer as useReducer5, useRef as useRef9, useState as useState7 } from "react";
10
+ import { useMemo as useMemo5, useReducer as useReducer5, useRef as useRef10, useState as useState8 } from "react";
11
11
 
12
12
  // src/participant/participantLoadView.ts
13
13
  function resolveLoadView({ status, hasItems }) {
@@ -361,7 +361,7 @@ function ParticipantInbox(props) {
361
361
  }
362
362
 
363
363
  // src/participant/ParticipantThreadScreen.tsx
364
- import { useEffect as useEffect11, useMemo as useMemo4, useState as useState6 } from "react";
364
+ import { useEffect as useEffect11, useState as useState7 } from "react";
365
365
 
366
366
  // src/participant/ParticipantBackButton.tsx
367
367
  import { ArrowLeft } from "lucide-react";
@@ -1314,6 +1314,30 @@ function ParticipantAttachmentItem({ attachment, resolveAttachmentUrl, labels })
1314
1314
  );
1315
1315
  }
1316
1316
 
1317
+ // src/participant/participantPerspective.ts
1318
+ var DEFAULT_PARTICIPANT_PERSPECTIVE = "participant";
1319
+ var OWN_DIRECTION = {
1320
+ participant: "inbound",
1321
+ operator: "outbound"
1322
+ };
1323
+ function isMessageMine(message, perspective = DEFAULT_PARTICIPANT_PERSPECTIVE) {
1324
+ return message.direction === OWN_DIRECTION[perspective];
1325
+ }
1326
+ function isUnreadByViewer(message, perspective) {
1327
+ if (isMessageMine(message, perspective)) return false;
1328
+ return !message.readAt && message.status !== "read";
1329
+ }
1330
+ function countOtherSideMessages(messages, perspective) {
1331
+ return messages.filter((message) => !isMessageMine(message, perspective)).length;
1332
+ }
1333
+ function countUnreadByViewer(messages, perspective) {
1334
+ return messages.filter((message) => isUnreadByViewer(message, perspective)).length;
1335
+ }
1336
+ function resolveViewerUnread({ messages, perspective, acknowledgedCount }) {
1337
+ if (countOtherSideMessages(messages, perspective) <= acknowledgedCount) return 0;
1338
+ return countUnreadByViewer(messages, perspective);
1339
+ }
1340
+
1317
1341
  // src/participant/participantMessages.ts
1318
1342
  function isSameSubject(left, right) {
1319
1343
  return left.subjectType === right.subjectType && left.subjectId === right.subjectId;
@@ -1339,8 +1363,8 @@ function mergeParticipantMessages(params) {
1339
1363
  }
1340
1364
  return items.sort((left, right) => createdAtOf(left) - createdAtOf(right));
1341
1365
  }
1342
- function isOwnMessage(message) {
1343
- return message.direction === "inbound";
1366
+ function isOwnMessage(message, perspective) {
1367
+ return isMessageMine(message, perspective);
1344
1368
  }
1345
1369
  function resolveOwnMessageStatus(params) {
1346
1370
  const { displayState, serverStatus, confirmsRead } = params;
@@ -1353,11 +1377,11 @@ function resolveOwnMessageStatus(params) {
1353
1377
 
1354
1378
  // src/participant/ParticipantMessageBubble.tsx
1355
1379
  import { jsx as jsx18, jsxs as jsxs13 } from "react/jsx-runtime";
1356
- function describeItem(item) {
1380
+ function describeItem(item, perspective) {
1357
1381
  if (item.kind === "server") {
1358
1382
  const { message } = item;
1359
1383
  return {
1360
- isMine: isOwnMessage(message),
1384
+ isMine: isOwnMessage(message, perspective),
1361
1385
  authorName: message.authorName ?? void 0,
1362
1386
  text: message.text ?? void 0,
1363
1387
  createdAt: message.createdAt,
@@ -1390,9 +1414,10 @@ function ParticipantMessageBubble({
1390
1414
  onDiscard,
1391
1415
  onEdit,
1392
1416
  avatar,
1393
- tail = true
1417
+ tail = true,
1418
+ perspective
1394
1419
  }) {
1395
- const content = describeItem(item);
1420
+ const content = describeItem(item, perspective);
1396
1421
  const { isMine } = content;
1397
1422
  const ownStatus = isMine ? resolveOwnMessageStatus({ displayState: content.displayState, serverStatus: content.serverStatus, confirmsRead }) : void 0;
1398
1423
  const bubble = /* @__PURE__ */ jsxs13("div", { className: bubbleClassName(isMine, tail), children: [
@@ -1445,9 +1470,9 @@ function createdAtOf2(item) {
1445
1470
  function keyOf(item) {
1446
1471
  return item.kind === "server" ? item.message.id : `pending:${item.pending.clientMessageId}`;
1447
1472
  }
1448
- function authorOf(item) {
1473
+ function authorOf(item, perspective) {
1449
1474
  if (item.kind === "pending") return { isMine: true, author: null };
1450
- return { isMine: isOwnMessage(item.message), author: item.message.authorName ?? null };
1475
+ return { isMine: isOwnMessage(item.message, perspective), author: item.message.authorName ?? null };
1451
1476
  }
1452
1477
  function resolveActions(item, pendingActions) {
1453
1478
  if (!pendingActions || item.kind === "server") return {};
@@ -1471,7 +1496,7 @@ function ParticipantTimeline(props) {
1471
1496
  previousAuthor = void 0;
1472
1497
  }
1473
1498
  previousDay = day;
1474
- const { isMine, author } = authorOf(item);
1499
+ const { isMine, author } = authorOf(item, props.perspective);
1475
1500
  const showAvatar = shouldShowAvatar({ previousAuthor, author, isMine });
1476
1501
  previousAuthor = isMine ? void 0 : author;
1477
1502
  const avatar = resolveAvatarSlot({
@@ -1490,6 +1515,7 @@ function ParticipantTimeline(props) {
1490
1515
  confirmsRead,
1491
1516
  resolveAttachmentUrl,
1492
1517
  tail: props.tail,
1518
+ perspective: props.perspective,
1493
1519
  avatar,
1494
1520
  ...resolveActions(item, pendingActions)
1495
1521
  },
@@ -1588,7 +1614,9 @@ function ParticipantThreadHeader({
1588
1614
  subjectGroups,
1589
1615
  renderSubjectIcon,
1590
1616
  onBack,
1591
- onOpenSubject
1617
+ onOpenSubject,
1618
+ counterpartLabel,
1619
+ actions
1592
1620
  }) {
1593
1621
  const group = subjectGroups?.find((candidate) => candidate.subjectType === conversation.subjectType);
1594
1622
  const icon = resolveConversationIcon({ conversation, group, renderSubjectIcon });
@@ -1599,11 +1627,13 @@ function ParticipantThreadHeader({
1599
1627
  /* @__PURE__ */ jsxs15("div", { className: "cv-p-thread__heading", children: [
1600
1628
  icon === void 0 && group ? /* @__PURE__ */ jsx22("p", { className: "cv-p-thread__eyebrow", children: group.label }) : null,
1601
1629
  /* @__PURE__ */ jsx22(Title, { conversation, labels, onOpenSubject }),
1630
+ counterpartLabel ? /* @__PURE__ */ jsx22("p", { className: "cv-p-thread__counterpart", children: counterpartLabel }) : null,
1602
1631
  conversation.protocol || hasChannels ? /* @__PURE__ */ jsxs15("div", { className: "cv-p-thread__meta", children: [
1603
1632
  conversation.protocol ? /* @__PURE__ */ jsx22(ParticipantProtocolBadge, { protocol: conversation.protocol, labels }) : null,
1604
1633
  /* @__PURE__ */ jsx22(ParticipantChannelBadges, { channels: conversation.channels, labels, variant: "inline", iconSize: 16 })
1605
1634
  ] }) : null
1606
- ] })
1635
+ ] }),
1636
+ actions ? /* @__PURE__ */ jsx22("div", { className: "cv-p-thread__actions", children: actions }) : null
1607
1637
  ] });
1608
1638
  }
1609
1639
 
@@ -1644,7 +1674,9 @@ function ThreadBody(props) {
1644
1674
  subjectGroups: props.subjectGroups,
1645
1675
  renderSubjectIcon: props.renderSubjectIcon,
1646
1676
  onBack: props.onBack,
1647
- onOpenSubject: props.onOpenSubject
1677
+ onOpenSubject: props.onOpenSubject,
1678
+ counterpartLabel: props.counterpartLabel,
1679
+ actions: props.headerActions
1648
1680
  }
1649
1681
  ),
1650
1682
  subjectCard ? /* @__PURE__ */ jsx23("div", { className: "cv-p-thread__subject-card", children: subjectCard }) : null,
@@ -1672,6 +1704,9 @@ function ParticipantThread(props) {
1672
1704
  return /* @__PURE__ */ jsx23(ParticipantCopyAnnouncer, { children: /* @__PURE__ */ jsx23(ThreadBody, { ...props }) });
1673
1705
  }
1674
1706
 
1707
+ // src/participant/useParticipantThreadController.ts
1708
+ import { useMemo as useMemo4 } from "react";
1709
+
1675
1710
  // src/participant/bindAttachmentUrlResolver.ts
1676
1711
  function bindAttachmentUrlResolver(api) {
1677
1712
  return (attachment, disposition) => api.resolveAttachmentUrl(attachment, disposition);
@@ -1753,8 +1788,8 @@ function appendEntryToDraft(entry, current) {
1753
1788
  return { text, files: [...entry.files, ...current.files] };
1754
1789
  }
1755
1790
 
1756
- // src/participant/useParticipantDraft.ts
1757
- import { useState as useState3 } from "react";
1791
+ // src/participant/useParticipantConversationView.ts
1792
+ import { useCallback as useCallback5, useMemo as useMemo3 } from "react";
1758
1793
 
1759
1794
  // src/participant/participantDrafts.ts
1760
1795
  var EMPTY_PARTICIPANT_DRAFT = { text: "", files: [] };
@@ -1782,30 +1817,6 @@ function takeDraftForSend(drafts, subject) {
1782
1817
  };
1783
1818
  }
1784
1819
 
1785
- // src/participant/useParticipantDraft.ts
1786
- function useParticipantDraft(draftsRef, subject) {
1787
- const [draft, setDraftState] = useState3(() => getDraft(draftsRef.current, subject));
1788
- function replaceDrafts(next) {
1789
- draftsRef.current = next;
1790
- setDraftState(getDraft(next, subject));
1791
- }
1792
- function update(change) {
1793
- replaceDrafts(setDraft(draftsRef.current, subject, { ...getDraft(draftsRef.current, subject), ...change }));
1794
- }
1795
- function takeForSend() {
1796
- const taken = takeDraftForSend(draftsRef.current, subject);
1797
- if (taken.content) replaceDrafts(taken.drafts);
1798
- return taken.content;
1799
- }
1800
- function restoreFromEntry(entry) {
1801
- update(appendEntryToDraft(entry, getDraft(draftsRef.current, subject)));
1802
- }
1803
- return { draft, setText: (text) => update({ text }), setFiles: (files) => update({ files }), takeForSend, restoreFromEntry };
1804
- }
1805
-
1806
- // src/participant/useParticipantConversationView.ts
1807
- import { useCallback as useCallback4, useMemo as useMemo3 } from "react";
1808
-
1809
1820
  // src/participant/participantSendStates.ts
1810
1821
  var NO_ENTRIES = [];
1811
1822
  function selectSendEntries(states, subject) {
@@ -1822,7 +1833,7 @@ function participantSendStatesReducer(states, { subject, action }) {
1822
1833
  }
1823
1834
 
1824
1835
  // src/participant/useParticipantThread.ts
1825
- import { useCallback as useCallback3, useEffect as useEffect9, useMemo as useMemo2, useReducer as useReducer4, useRef as useRef6 } from "react";
1836
+ import { useCallback as useCallback4, useEffect as useEffect9, useMemo as useMemo2, useReducer as useReducer4, useRef as useRef7 } from "react";
1826
1837
 
1827
1838
  // src/participant/participantThreadState.ts
1828
1839
  var INITIAL_PARTICIPANT_THREAD_STATE = {
@@ -1870,9 +1881,9 @@ function shouldMarkParticipantRead(params) {
1870
1881
  }
1871
1882
 
1872
1883
  // src/participant/useDocumentVisibility.ts
1873
- import { useEffect as useEffect6, useState as useState4 } from "react";
1884
+ import { useEffect as useEffect6, useState as useState3 } from "react";
1874
1885
  function useDocumentVisibility() {
1875
- const [visibilityState, setVisibilityState] = useState4(
1886
+ const [visibilityState, setVisibilityState] = useState3(
1876
1887
  () => typeof document === "undefined" ? "visible" : document.visibilityState
1877
1888
  );
1878
1889
  useEffect6(() => {
@@ -1982,6 +1993,24 @@ function useParticipantSend(params) {
1982
1993
  return { send, retry };
1983
1994
  }
1984
1995
 
1996
+ // src/participant/useParticipantUnread.ts
1997
+ import { useCallback as useCallback3, useRef as useRef6, useState as useState4 } from "react";
1998
+ function useParticipantUnread(params) {
1999
+ const { messages, perspective, reportedUnreadCount, onMarkedRead } = params;
2000
+ const [acknowledgedCount, setAcknowledgedCount] = useState4(0);
2001
+ const messagesRef = useRef6(messages);
2002
+ messagesRef.current = messages;
2003
+ const handleMarkedRead = useCallback3(
2004
+ (subject) => {
2005
+ if (perspective) setAcknowledgedCount(countOtherSideMessages(messagesRef.current, perspective));
2006
+ onMarkedRead?.(subject);
2007
+ },
2008
+ [perspective, onMarkedRead]
2009
+ );
2010
+ const unreadCount = perspective ? resolveViewerUnread({ messages, perspective, acknowledgedCount }) : reportedUnreadCount;
2011
+ return { unreadCount, handleMarkedRead };
2012
+ }
2013
+
1985
2014
  // src/participant/useParticipantThread.ts
1986
2015
  var THREAD_PAGE_SIZE = 30;
1987
2016
  function describeError(error) {
@@ -1992,15 +2021,15 @@ function isForSubject(event, subject) {
1992
2021
  return event.subject.subjectType === subject.subjectType && event.subject.subjectId === subject.subjectId;
1993
2022
  }
1994
2023
  function useParticipantThread(params) {
1995
- const { api, subject, channel, unreadCount = 0, onMarkedRead, sendEntries, dispatchSend, hostPending = [] } = params;
2024
+ const { api, subject, channel, unreadCount = 0, onMarkedRead, sendEntries, dispatchSend, hostPending = [], perspective } = params;
1996
2025
  const { subjectType, subjectId } = subject;
1997
2026
  const stableSubject = useMemo2(() => ({ subjectType, subjectId }), [subjectType, subjectId]);
1998
2027
  const [state, dispatch] = useReducer4(participantThreadReducer, INITIAL_PARTICIPANT_THREAD_STATE);
1999
- const apiRef = useRef6(api);
2028
+ const apiRef = useRef7(api);
2000
2029
  apiRef.current = api;
2001
- const activeSubjectKey = useRef6("");
2030
+ const activeSubjectKey = useRef7("");
2002
2031
  const confirmsRead = channelCapabilityFor(channel).confirmsRead;
2003
- const refresh = useCallback3(async () => {
2032
+ const refresh = useCallback4(async () => {
2004
2033
  const requestKey = `${subjectType}:${subjectId}`;
2005
2034
  dispatch({ type: "started" });
2006
2035
  try {
@@ -2013,7 +2042,7 @@ function useParticipantThread(params) {
2013
2042
  }
2014
2043
  }, [stableSubject, subjectType, subjectId]);
2015
2044
  const oldestMessageId = state.messages[0]?.id;
2016
- const loadOlder = useCallback3(async () => {
2045
+ const loadOlder = useCallback4(async () => {
2017
2046
  if (!oldestMessageId) return;
2018
2047
  const requestKey = `${subjectType}:${subjectId}`;
2019
2048
  try {
@@ -2025,7 +2054,7 @@ function useParticipantThread(params) {
2025
2054
  if (activeSubjectKey.current === requestKey) dispatch({ type: "failed", error: describeError(error) });
2026
2055
  }
2027
2056
  }, [stableSubject, subjectType, subjectId, oldestMessageId]);
2028
- const handleSentMessage = useCallback3((message) => dispatch({ type: "sendConfirmed", message }), []);
2057
+ const handleSentMessage = useCallback4((message) => dispatch({ type: "sendConfirmed", message }), []);
2029
2058
  const sending = useParticipantSend({ apiRef, subject: stableSubject, entries: sendEntries, dispatchSend, onSentMessage: handleSentMessage });
2030
2059
  useEffect9(() => {
2031
2060
  activeSubjectKey.current = `${subjectType}:${subjectId}`;
@@ -2039,7 +2068,14 @@ function useParticipantThread(params) {
2039
2068
  if (sendEntries.length === 0) return;
2040
2069
  dispatchSend({ type: "reflected", knownClientMessageIds: collectKnownClientMessageIds({ messages: state.messages, hostPending }) });
2041
2070
  }, [sendEntries, state.messages, hostPending, dispatchSend]);
2042
- useParticipantMarkRead({ apiRef, subject: stableSubject, unreadCount, messageCount: state.messages.length, onMarkedRead });
2071
+ const unread = useParticipantUnread({ messages: state.messages, perspective, reportedUnreadCount: unreadCount, onMarkedRead });
2072
+ useParticipantMarkRead({
2073
+ apiRef,
2074
+ subject: stableSubject,
2075
+ unreadCount: unread.unreadCount,
2076
+ messageCount: state.messages.length,
2077
+ onMarkedRead: unread.handleMarkedRead
2078
+ });
2043
2079
  const localPending = useMemo2(() => toLocalPending(sendEntries), [sendEntries]);
2044
2080
  return { ...state, localPending, confirmsRead, refresh, loadOlder, ...sending };
2045
2081
  }
@@ -2049,7 +2085,7 @@ function useParticipantConversationView(params) {
2049
2085
  const { api, subject, conversation, pendingMessages, sendStates, dispatchSendStates } = params;
2050
2086
  const { subjectType, subjectId } = subject;
2051
2087
  const sendEntries = selectSendEntries(sendStates, subject);
2052
- const dispatchSend = useCallback4(
2088
+ const dispatchSend = useCallback5(
2053
2089
  (action) => dispatchSendStates({ subject: { subjectType, subjectId }, action }),
2054
2090
  [dispatchSendStates, subjectType, subjectId]
2055
2091
  );
@@ -2061,7 +2097,8 @@ function useParticipantConversationView(params) {
2061
2097
  onMarkedRead: params.onMarkedRead,
2062
2098
  sendEntries,
2063
2099
  dispatchSend,
2064
- hostPending: pendingMessages
2100
+ hostPending: pendingMessages,
2101
+ perspective: params.perspective
2065
2102
  });
2066
2103
  const items = useMemo3(
2067
2104
  () => mergeParticipantMessages({
@@ -2075,8 +2112,30 @@ function useParticipantConversationView(params) {
2075
2112
  return { thread, items, sendEntries, dispatchSend };
2076
2113
  }
2077
2114
 
2115
+ // src/participant/useParticipantDraft.ts
2116
+ import { useState as useState5 } from "react";
2117
+ function useParticipantDraft(draftsRef, subject) {
2118
+ const [draft, setDraftState] = useState5(() => getDraft(draftsRef.current, subject));
2119
+ function replaceDrafts(next) {
2120
+ draftsRef.current = next;
2121
+ setDraftState(getDraft(next, subject));
2122
+ }
2123
+ function update(change) {
2124
+ replaceDrafts(setDraft(draftsRef.current, subject, { ...getDraft(draftsRef.current, subject), ...change }));
2125
+ }
2126
+ function takeForSend() {
2127
+ const taken = takeDraftForSend(draftsRef.current, subject);
2128
+ if (taken.content) replaceDrafts(taken.drafts);
2129
+ return taken.content;
2130
+ }
2131
+ function restoreFromEntry(entry) {
2132
+ update(appendEntryToDraft(entry, getDraft(draftsRef.current, subject)));
2133
+ }
2134
+ return { draft, setText: (text) => update({ text }), setFiles: (files) => update({ files }), takeForSend, restoreFromEntry };
2135
+ }
2136
+
2078
2137
  // src/participant/useParticipantThreadScroll.ts
2079
- import { useEffect as useEffect10, useRef as useRef8 } from "react";
2138
+ import { useEffect as useEffect10, useRef as useRef9 } from "react";
2080
2139
 
2081
2140
  // src/participant/participantNewMessages.ts
2082
2141
  function collectServerMessageIds(items) {
@@ -2093,17 +2152,17 @@ function latestSeenTime(previousIds, items) {
2093
2152
  }
2094
2153
  return latest;
2095
2154
  }
2096
- function countNewIncomingMessages(previousIds, currentItems) {
2155
+ function countNewIncomingMessages(previousIds, currentItems, perspective) {
2097
2156
  const latestSeen = latestSeenTime(previousIds, currentItems);
2098
2157
  return currentItems.filter(
2099
- (item) => item.kind === "server" && item.message.direction === "outbound" && !previousIds.has(item.message.id) && Date.parse(item.message.createdAt) > latestSeen
2158
+ (item) => item.kind === "server" && !isMessageMine(item.message, perspective) && !previousIds.has(item.message.id) && Date.parse(item.message.createdAt) > latestSeen
2100
2159
  ).length;
2101
2160
  }
2102
2161
 
2103
2162
  // src/participant/useScrollAwayState.ts
2104
- import { useCallback as useCallback5 } from "react";
2163
+ import { useCallback as useCallback6 } from "react";
2105
2164
  function useScrollAwayState(ref, isNearBottom) {
2106
- const scrollToLatest = useCallback5(() => {
2165
+ const scrollToLatest = useCallback6(() => {
2107
2166
  const element = ref.current;
2108
2167
  if (!element) return;
2109
2168
  element.scrollTo(
@@ -2114,7 +2173,7 @@ function useScrollAwayState(ref, isNearBottom) {
2114
2173
  }
2115
2174
 
2116
2175
  // src/participant/useStickToBottom.ts
2117
- import { useCallback as useCallback6, useLayoutEffect as useLayoutEffect2, useRef as useRef7, useState as useState5 } from "react";
2176
+ import { useCallback as useCallback7, useLayoutEffect as useLayoutEffect2, useRef as useRef8, useState as useState6 } from "react";
2118
2177
 
2119
2178
  // src/participant/participantScroll.ts
2120
2179
  var NEAR_BOTTOM_THRESHOLD_PX = 80;
@@ -2136,17 +2195,17 @@ function resolveScrollAction(params) {
2136
2195
 
2137
2196
  // src/participant/useStickToBottom.ts
2138
2197
  function useStickToBottom({ firstKey, lastKey }) {
2139
- const ref = useRef7(null);
2140
- const snapshot = useRef7({
2198
+ const ref = useRef8(null);
2199
+ const snapshot = useRef8({
2141
2200
  hasPositioned: false,
2142
2201
  firstKey: void 0,
2143
2202
  lastKey: void 0,
2144
2203
  scrollTop: 0,
2145
2204
  scrollHeight: 0
2146
2205
  });
2147
- const [isNearBottom, setIsNearBottom] = useState5(true);
2148
- const isNearBottomRef = useRef7(true);
2149
- const onScroll = useCallback6((event) => {
2206
+ const [isNearBottom, setIsNearBottom] = useState6(true);
2207
+ const isNearBottomRef = useRef8(true);
2208
+ const onScroll = useCallback7((event) => {
2150
2209
  const { scrollTop, clientHeight, scrollHeight } = event.currentTarget;
2151
2210
  const isNear = shouldStickToBottom({ scrollTop, clientHeight, scrollHeight, threshold: NEAR_BOTTOM_THRESHOLD_PX });
2152
2211
  snapshot.current.scrollTop = scrollTop;
@@ -2194,24 +2253,70 @@ function keyOf2(item) {
2194
2253
  if (!item) return void 0;
2195
2254
  return item.kind === "server" ? item.message.id : `pending:${item.pending.clientMessageId}`;
2196
2255
  }
2197
- function useParticipantThreadScroll(items) {
2256
+ function useParticipantThreadScroll(items, perspective) {
2198
2257
  const { scroll: base, isNearBottom } = useStickToBottom({
2199
2258
  firstKey: keyOf2(items[0]),
2200
2259
  lastKey: keyOf2(items[items.length - 1])
2201
2260
  });
2202
2261
  const { isAwayFromBottom, scrollToLatest } = useScrollAwayState(base.ref, isNearBottom);
2203
2262
  const scroll = { ...base, isAwayFromBottom, scrollToLatest };
2204
- const seenIds = useRef8(/* @__PURE__ */ new Set());
2263
+ const seenIds = useRef9(/* @__PURE__ */ new Set());
2205
2264
  useEffect10(() => {
2206
2265
  if (isNearBottom) seenIds.current = collectServerMessageIds(items);
2207
2266
  }, [isNearBottom, items]);
2208
- return { scroll, newMessagesCount: isNearBottom ? 0 : countNewIncomingMessages(seenIds.current, items) };
2267
+ return { scroll, newMessagesCount: isNearBottom ? 0 : countNewIncomingMessages(seenIds.current, items, perspective) };
2268
+ }
2269
+
2270
+ // src/participant/useParticipantThreadController.ts
2271
+ function buildPendingActions({ thread, dispatchSend, handleEdit, onRetryPending }) {
2272
+ return {
2273
+ retryLocal: (clientMessageId) => void thread.retry(clientMessageId),
2274
+ discardLocal: (clientMessageId) => dispatchSend({ type: "discarded", clientMessageId }),
2275
+ editLocal: handleEdit,
2276
+ ...onRetryPending ? { retryHost: onRetryPending } : {}
2277
+ };
2278
+ }
2279
+ function useParticipantThreadController(params) {
2280
+ const { api, subject, conversation, draftsRef } = params;
2281
+ const { draft, setText, setFiles, takeForSend, restoreFromEntry } = useParticipantDraft(draftsRef, subject);
2282
+ const { thread, items, sendEntries, dispatchSend } = useParticipantConversationView(params);
2283
+ const resolveAttachmentUrl = useMemo4(() => bindAttachmentUrlResolver(api), [api]);
2284
+ const { scroll, newMessagesCount } = useParticipantThreadScroll(items, params.perspective);
2285
+ function handleSend() {
2286
+ const content = takeForSend();
2287
+ if (content) void thread.send(content);
2288
+ }
2289
+ function handleEdit(clientMessageId) {
2290
+ const entry = findFailedEntry(sendEntries, clientMessageId);
2291
+ if (!entry) return;
2292
+ restoreFromEntry(entry);
2293
+ dispatchSend({ type: "discarded", clientMessageId });
2294
+ }
2295
+ return {
2296
+ conversation,
2297
+ items,
2298
+ hasMore: thread.hasMore,
2299
+ labels: params.labels,
2300
+ resolveAttachmentUrl,
2301
+ draft: { value: draft.text, onChange: setText, files: draft.files, onFilesChange: setFiles },
2302
+ onSend: handleSend,
2303
+ status: thread.status,
2304
+ refresh: () => void thread.refresh(),
2305
+ scroll,
2306
+ newMessagesCount,
2307
+ isSending: hasSendingEntry(sendEntries),
2308
+ channel: params.channel,
2309
+ locale: params.locale,
2310
+ onLoadOlder: () => void thread.loadOlder(),
2311
+ pendingActions: buildPendingActions({ thread, dispatchSend, handleEdit, onRetryPending: params.onRetryPending }),
2312
+ perspective: params.perspective
2313
+ };
2209
2314
  }
2210
2315
 
2211
2316
  // src/participant/ParticipantThreadScreen.tsx
2212
2317
  import { jsx as jsx24, jsxs as jsxs17 } from "react/jsx-runtime";
2213
2318
  function useOpenedConversation(api, subject, isNeeded) {
2214
- const [opened, setOpened] = useState6({ status: "idle" });
2319
+ const [opened, setOpened] = useState7({ status: "idle" });
2215
2320
  const { subjectType, subjectId } = subject;
2216
2321
  const canOpen = api.openConversation !== void 0;
2217
2322
  useEffect11(() => {
@@ -2252,51 +2357,17 @@ function ParticipantThreadScreen(props) {
2252
2357
  return /* @__PURE__ */ jsx24(NotFound, { labels, onBack: props.onBack });
2253
2358
  }
2254
2359
  function LoadedThread(props) {
2255
- const { api, selected, conversation, draftsRef, inbox } = props;
2256
- const { draft, setText, setFiles, takeForSend, restoreFromEntry } = useParticipantDraft(draftsRef, selected);
2257
- const { thread, items, sendEntries, dispatchSend } = useParticipantConversationView({
2360
+ const core = useParticipantThreadController({
2258
2361
  ...props,
2259
- subject: selected,
2260
- onMarkedRead: inbox.markSubjectRead
2362
+ subject: props.selected,
2363
+ onMarkedRead: props.inbox.markSubjectRead
2261
2364
  });
2262
- const resolveAttachmentUrl = useMemo4(() => bindAttachmentUrlResolver(api), [api]);
2263
- const { scroll, newMessagesCount } = useParticipantThreadScroll(items);
2264
- function handleSend() {
2265
- const content = takeForSend();
2266
- if (content) void thread.send(content);
2267
- }
2268
- function handleEdit(clientMessageId) {
2269
- const entry = findFailedEntry(sendEntries, clientMessageId);
2270
- if (!entry) return;
2271
- restoreFromEntry(entry);
2272
- dispatchSend({ type: "discarded", clientMessageId });
2273
- }
2274
2365
  return /* @__PURE__ */ jsx24(
2275
2366
  ParticipantThread,
2276
2367
  {
2277
- conversation,
2278
- items,
2279
- hasMore: thread.hasMore,
2280
- labels: props.labels,
2281
- resolveAttachmentUrl,
2282
- draft: { value: draft.text, onChange: setText, files: draft.files, onFilesChange: setFiles },
2283
- onSend: handleSend,
2284
- status: thread.status,
2285
- refresh: () => void thread.refresh(),
2286
- scroll,
2287
- newMessagesCount,
2288
- isSending: hasSendingEntry(sendEntries),
2289
- channel: props.channel,
2290
- locale: props.locale,
2368
+ ...core,
2291
2369
  onBack: props.onBack,
2292
2370
  onOpenSubject: props.onOpenSubject,
2293
- onLoadOlder: () => void thread.loadOlder(),
2294
- pendingActions: {
2295
- retryLocal: (clientMessageId) => void thread.retry(clientMessageId),
2296
- discardLocal: (clientMessageId) => dispatchSend({ type: "discarded", clientMessageId }),
2297
- editLocal: handleEdit,
2298
- ...props.onRetryPending ? { retryHost: props.onRetryPending } : {}
2299
- },
2300
2371
  quickReplies: props.quickReplies,
2301
2372
  renderSubjectCard: props.renderSubjectCard,
2302
2373
  subjectGroups: props.subjectGroups,
@@ -2433,9 +2504,9 @@ import { jsx as jsx25 } from "react/jsx-runtime";
2433
2504
  var ALL_FILTER3 = "all";
2434
2505
  function ParticipantConversationsScreen(props) {
2435
2506
  const { selected, inbox, subjectGroups, classNames } = props;
2436
- const [filter, setFilter] = useState7(ALL_FILTER3);
2437
- const [searchQuery, setSearchQuery] = useState7("");
2438
- const draftsRef = useRef9(/* @__PURE__ */ new Map());
2507
+ const [filter, setFilter] = useState8(ALL_FILTER3);
2508
+ const [searchQuery, setSearchQuery] = useState8("");
2509
+ const draftsRef = useRef10(/* @__PURE__ */ new Map());
2439
2510
  const [sendStates, dispatchSendStates] = useReducer5(participantSendStatesReducer, /* @__PURE__ */ new Map());
2440
2511
  const labels = useMemo5(() => ({ ...DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS, ...props.labels }), [props.labels]);
2441
2512
  const view = useMemo5(
@@ -2481,7 +2552,7 @@ function ParticipantConversationsScreen(props) {
2481
2552
  }
2482
2553
 
2483
2554
  // src/participant/useParticipantInbox.ts
2484
- import { useCallback as useCallback7, useEffect as useEffect12, useReducer as useReducer6 } from "react";
2555
+ import { useCallback as useCallback8, useEffect as useEffect12, useReducer as useReducer6 } from "react";
2485
2556
 
2486
2557
  // src/participant/participantInboxState.ts
2487
2558
  var INITIAL_PARTICIPANT_INBOX_STATE = { status: "idle", conversations: [] };
@@ -2532,7 +2603,7 @@ function describeError2(error) {
2532
2603
  }
2533
2604
  function useParticipantInbox(api) {
2534
2605
  const [state, dispatch] = useReducer6(participantInboxReducer, INITIAL_PARTICIPANT_INBOX_STATE);
2535
- const refresh = useCallback7(async () => {
2606
+ const refresh = useCallback8(async () => {
2536
2607
  dispatch({ type: "started" });
2537
2608
  try {
2538
2609
  const page = await api.listConversations();
@@ -2542,7 +2613,7 @@ function useParticipantInbox(api) {
2542
2613
  }
2543
2614
  }, [api]);
2544
2615
  const { nextCursor } = state;
2545
- const loadMore = useCallback7(async () => {
2616
+ const loadMore = useCallback8(async () => {
2546
2617
  if (!nextCursor) return;
2547
2618
  try {
2548
2619
  const page = await api.listConversations({ cursor: nextCursor });
@@ -2551,7 +2622,7 @@ function useParticipantInbox(api) {
2551
2622
  dispatch({ type: "failed", error: describeError2(error) });
2552
2623
  }
2553
2624
  }, [api, nextCursor]);
2554
- const markSubjectRead = useCallback7((subject) => {
2625
+ const markSubjectRead = useCallback8((subject) => {
2555
2626
  dispatch({ type: "markedRead", subject });
2556
2627
  }, []);
2557
2628
  useEffect12(() => {
@@ -2576,7 +2647,111 @@ function ParticipantConversations(props) {
2576
2647
  }
2577
2648
  );
2578
2649
  }
2650
+
2651
+ // src/participant/ConversationThread.tsx
2652
+ import { useCallback as useCallback9, useMemo as useMemo6, useReducer as useReducer7, useRef as useRef11 } from "react";
2653
+
2654
+ // src/participant/conversationThreadApi.ts
2655
+ function toParticipantApi(api) {
2656
+ const { subscribe } = api;
2657
+ return {
2658
+ listConversations: async () => ({ data: [] }),
2659
+ fetchMessages: (subject, params) => api.fetchMessages(subject, params),
2660
+ sendMessage: (input) => api.sendMessage(input),
2661
+ markRead: async (subject) => {
2662
+ if (!api.markRead) throw new Error("The host api has no markRead");
2663
+ await api.markRead(subject);
2664
+ },
2665
+ resolveAttachmentUrl: (attachment, disposition) => api.resolveAttachmentUrl(attachment, disposition),
2666
+ ...subscribe ? { subscribe: (listener) => api.subscribe?.(listener) ?? (() => void 0) } : {}
2667
+ };
2668
+ }
2669
+
2670
+ // src/participant/conversationThreadSummary.ts
2671
+ function buildThreadSummary({
2672
+ subject,
2673
+ title,
2674
+ protocol,
2675
+ channels,
2676
+ status
2677
+ }) {
2678
+ return {
2679
+ subjectType: subject.subjectType,
2680
+ subjectId: subject.subjectId,
2681
+ subjectLabel: title,
2682
+ lastMessageAt: null,
2683
+ unreadCount: 0,
2684
+ awaitingParticipant: false,
2685
+ status: status ?? "open",
2686
+ ...protocol ? { protocol } : {},
2687
+ ...channels ? { channels } : {}
2688
+ };
2689
+ }
2690
+
2691
+ // src/participant/ConversationThread.tsx
2692
+ import { jsx as jsx27 } from "react/jsx-runtime";
2693
+ function useConversationThreadCore(props) {
2694
+ const { perspective = "participant" } = props;
2695
+ const { subjectType, subjectId } = props.subject;
2696
+ const subject = useMemo6(() => ({ subjectType, subjectId }), [subjectType, subjectId]);
2697
+ const onMarkedReadRef = useRef11(props.onMarkedRead);
2698
+ onMarkedReadRef.current = props.onMarkedRead;
2699
+ const handleMarkedRead = useCallback9((marked) => onMarkedReadRef.current?.(marked), []);
2700
+ const api = useMemo6(() => toParticipantApi(props.api), [props.api]);
2701
+ const draftsRef = useRef11(/* @__PURE__ */ new Map());
2702
+ const [sendStates, dispatchSendStates] = useReducer7(participantSendStatesReducer, /* @__PURE__ */ new Map());
2703
+ const labels = useMemo6(() => ({ ...DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS, ...props.labels }), [props.labels]);
2704
+ const { title, protocol, channels, status } = props;
2705
+ const conversation = useMemo6(
2706
+ () => buildThreadSummary({ subject, title, protocol, channels, status }),
2707
+ [subject, title, protocol, channels, status]
2708
+ );
2709
+ return useParticipantThreadController({
2710
+ api,
2711
+ subject,
2712
+ conversation,
2713
+ labels,
2714
+ draftsRef,
2715
+ sendStates,
2716
+ dispatchSendStates,
2717
+ onMarkedRead: handleMarkedRead,
2718
+ channel: props.channel,
2719
+ locale: props.locale,
2720
+ pendingMessages: props.pendingMessages,
2721
+ onRetryPending: props.onRetryPending,
2722
+ perspective
2723
+ });
2724
+ }
2725
+ function ConversationThreadBody(props) {
2726
+ const core = useConversationThreadCore(props);
2727
+ return /* @__PURE__ */ jsx27(
2728
+ "div",
2729
+ {
2730
+ className: ["cv-p-root", props.className].filter(Boolean).join(" "),
2731
+ style: buildParticipantThemeStyle(props.theme),
2732
+ children: /* @__PURE__ */ jsx27("div", { className: "cv-p-root__screen", children: /* @__PURE__ */ jsx27(
2733
+ ParticipantThread,
2734
+ {
2735
+ ...core,
2736
+ onBack: props.onBack,
2737
+ quickReplies: props.quickReplies,
2738
+ subjectGroups: props.subjectGroups,
2739
+ renderSubjectIcon: props.renderSubjectIcon,
2740
+ avatars: props.avatars,
2741
+ renderAuthorAvatar: props.renderAuthorAvatar,
2742
+ tail: props.tail,
2743
+ counterpartLabel: props.counterpartLabel,
2744
+ headerActions: props.headerActions
2745
+ }
2746
+ ) })
2747
+ }
2748
+ );
2749
+ }
2750
+ function ConversationThread(props) {
2751
+ return /* @__PURE__ */ jsx27(ConversationThreadBody, { ...props }, draftKey(props.subject));
2752
+ }
2579
2753
  export {
2754
+ ConversationThread,
2580
2755
  DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS,
2581
2756
  ParticipantConversations,
2582
2757
  groupParticipantConversations