@adatechnology/conversations-ui 0.6.1 → 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 (119) hide show
  1. package/README.md +221 -19
  2. package/dist/{ConversationSimulatorPanel-CrfFjiWh.d.ts → ConversationSimulatorPanel-q-wVgBCN.d.ts} +1 -1
  3. package/dist/{chunk-HOKNKXIT.js → chunk-HMCOTZAX.js} +1 -1
  4. package/dist/{chunk-FAQLC3TX.js → chunk-L3OFAQPS.js} +2 -4
  5. package/dist/{chunk-CBYEHPX6.js → chunk-PJNR6UMN.js} +63 -1
  6. package/dist/flows/index.js +3 -5
  7. package/dist/index.d.ts +4 -4
  8. package/dist/index.js +834 -764
  9. package/dist/participant/index.d.ts +62 -3
  10. package/dist/participant/index.js +1444 -394
  11. package/dist/preview/index.d.ts +3 -3
  12. package/dist/preview/index.js +2 -3
  13. package/dist/{quickReply.types-B4gmZsSr.d.ts → quickReply.types-DzWH3NYV.d.ts} +1 -1
  14. package/dist/styles.css +629 -20
  15. package/package.json +3 -3
  16. package/src/participant/ConversationThread.test.tsx +165 -0
  17. package/src/participant/ConversationThread.tsx +117 -0
  18. package/src/participant/ParticipantAuthorAvatar.test.tsx +30 -0
  19. package/src/participant/ParticipantAuthorAvatar.tsx +24 -0
  20. package/src/participant/ParticipantBackButton.tsx +14 -0
  21. package/src/participant/ParticipantBubbleStatus.tsx +67 -0
  22. package/src/participant/ParticipantChannelBadges.tsx +13 -4
  23. package/src/participant/ParticipantComposer.tsx +8 -40
  24. package/src/participant/ParticipantComposerControls.tsx +83 -0
  25. package/src/participant/ParticipantConversationAvatar.tsx +13 -0
  26. package/src/participant/ParticipantConversations.tsx +7 -0
  27. package/src/participant/ParticipantConversationsOptions.test.tsx +87 -0
  28. package/src/participant/ParticipantCopyAnnouncer.tsx +48 -0
  29. package/src/participant/ParticipantCopyableToken.tsx +51 -0
  30. package/src/participant/ParticipantDayDivider.tsx +18 -0
  31. package/src/participant/ParticipantInbox.tsx +3 -1
  32. package/src/participant/ParticipantInboxRow.tsx +6 -4
  33. package/src/participant/ParticipantMessageBubble.test.tsx +52 -5
  34. package/src/participant/ParticipantMessageBubble.tsx +32 -84
  35. package/src/participant/ParticipantMessageNodes.tsx +54 -0
  36. package/src/participant/ParticipantMessageText.test.tsx +106 -0
  37. package/src/participant/ParticipantMessageText.tsx +52 -0
  38. package/src/participant/ParticipantProtocolBadge.tsx +15 -7
  39. package/src/participant/ParticipantProtocolView.test.tsx +4 -6
  40. package/src/participant/ParticipantScrollToLatest.test.tsx +153 -0
  41. package/src/participant/ParticipantScrollToLatest.tsx +40 -0
  42. package/src/participant/ParticipantThread.test.tsx +37 -0
  43. package/src/participant/ParticipantThread.tsx +54 -95
  44. package/src/participant/ParticipantThreadAvatarSequence.test.tsx +65 -0
  45. package/src/participant/ParticipantThreadHeader.test.tsx +181 -0
  46. package/src/participant/ParticipantThreadHeader.tsx +83 -0
  47. package/src/participant/ParticipantThreadScreen.tsx +30 -56
  48. package/src/participant/ParticipantTimeline.tsx +106 -0
  49. package/src/participant/conversationThreadApi.test.ts +27 -0
  50. package/src/participant/conversationThreadApi.ts +24 -0
  51. package/src/participant/conversationThreadCss.test.ts +116 -0
  52. package/src/participant/conversationThreadSummary.ts +34 -0
  53. package/src/participant/index.ts +4 -0
  54. package/src/participant/participantAutoGrow.test.ts +43 -0
  55. package/src/participant/participantAutoGrow.ts +12 -0
  56. package/src/participant/participantAvatar.test.ts +74 -0
  57. package/src/participant/participantAvatar.ts +48 -0
  58. package/src/participant/participantBubbleTail.test.tsx +107 -0
  59. package/src/participant/participantChatDesign.test.tsx +198 -0
  60. package/src/participant/participantContrast.test-helper.ts +116 -0
  61. package/src/participant/participantCopyAnnouncement.test.ts +52 -0
  62. package/src/participant/participantCopyAnnouncement.ts +37 -0
  63. package/src/participant/participantCss.test-helper.ts +34 -0
  64. package/src/participant/participantDay.test.ts +22 -0
  65. package/src/participant/participantDay.ts +8 -0
  66. package/src/participant/participantDocuments.ts +45 -0
  67. package/src/participant/participantGolden.test.tsx +46 -0
  68. package/src/participant/participantGoldenScenarios.test-helper.tsx +57 -0
  69. package/src/participant/participantHeaderA11y.test.tsx +85 -0
  70. package/src/participant/participantHeaderDesign.test.tsx +111 -0
  71. package/src/participant/participantLabels.ts +11 -0
  72. package/src/participant/participantMessageAtoms.ts +53 -0
  73. package/src/participant/participantMessageCopyables.test.ts +141 -0
  74. package/src/participant/participantMessageCopyables.ts +76 -0
  75. package/src/participant/participantMessageFormat.test-helper.ts +21 -0
  76. package/src/participant/participantMessageFormat.test.ts +177 -0
  77. package/src/participant/participantMessageFormat.ts +135 -0
  78. package/src/participant/participantMessageFormat.types.ts +22 -0
  79. package/src/participant/participantMessageInline.test.ts +82 -0
  80. package/src/participant/participantMessageInline.ts +99 -0
  81. package/src/participant/participantMessageLinks.test.ts +100 -0
  82. package/src/participant/participantMessageLinks.ts +54 -0
  83. package/src/participant/participantMessageTextStyle.test.ts +137 -0
  84. package/src/participant/participantMessages.ts +4 -3
  85. package/src/participant/participantNewMessages.test.ts +21 -0
  86. package/src/participant/participantNewMessages.ts +8 -3
  87. package/src/participant/participantPerspective.test.ts +67 -0
  88. package/src/participant/participantPerspective.ts +50 -0
  89. package/src/participant/participantPerspectiveRender.test.tsx +94 -0
  90. package/src/participant/participantScrollFocus.test.ts +22 -0
  91. package/src/participant/participantScrollFocus.ts +10 -0
  92. package/src/participant/participantScrollView.test.ts +74 -0
  93. package/src/participant/participantScrollView.ts +38 -0
  94. package/src/participant/participantScrollbars.test.ts +156 -0
  95. package/src/participant/participantSubjectIcon.test.tsx +27 -0
  96. package/src/participant/participantSubjectIcon.ts +26 -0
  97. package/src/participant/participantThemeContrast.test.ts +98 -0
  98. package/src/participant/test-fixtures/conversations-list.golden.html +1 -0
  99. package/src/participant/test-fixtures/conversations-selected.golden.html +1 -0
  100. package/src/participant/test-fixtures/thread-avatars-initials.golden.html +1 -0
  101. package/src/participant/test-fixtures/thread-closed.golden.html +1 -0
  102. package/src/participant/test-fixtures/thread-default.golden.html +1 -0
  103. package/src/participant/test-fixtures/thread-error.golden.html +1 -0
  104. package/src/participant/test-fixtures/thread-loading.golden.html +1 -0
  105. package/src/participant/test-fixtures/thread-plain.golden.html +1 -0
  106. package/src/participant/test-fixtures/thread-tail-off.golden.html +1 -0
  107. package/src/participant/useParticipantConversationView.ts +3 -0
  108. package/src/participant/useParticipantCopyFeedback.ts +33 -0
  109. package/src/participant/useParticipantThread.ts +13 -2
  110. package/src/participant/useParticipantThreadController.ts +112 -0
  111. package/src/participant/useParticipantThreadScroll.ts +13 -3
  112. package/src/participant/useParticipantUnread.test.tsx +40 -0
  113. package/src/participant/useParticipantUnread.ts +38 -0
  114. package/src/participant/useRevealActiveChip.ts +20 -0
  115. package/src/participant/useScrollAwayState.ts +24 -0
  116. package/src/participant/useStickToBottom.ts +16 -2
  117. package/src/styles.css +151 -23
  118. package/dist/chunk-MRVFFJEH.js +0 -64
  119. package/dist/chunk-OVFCLRQQ.js +0 -84
@@ -0,0 +1 @@
1
+ <div class="cv-p cv-p-thread" aria-busy="false"><header class="cv-p-thread__header"><button type="button" class="cv-p-thread__back" aria-label="Back"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-left" aria-hidden="true" focusable="false"><path d="m12 19-7-7 7-7"></path><path d="M19 12H5"></path></svg></button><div class="cv-p-thread__heading"><h2 class="cv-p-thread__title"><span class="cv-p-thread__title-text">Invoice 4521</span></h2><div class="cv-p-thread__meta"><span class="cv-p-protocol cv-p-protocol--header"><span class="cv-p-protocol__code"><span class="cv-p-sr-only">Protocol </span>P-1</span><button type="button" class="cv-p-protocol__copy" aria-label="Copy protocol P-1"><svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-copy" aria-hidden="true"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"></rect><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"></path></svg></button></span><div class="cv-p-sr-only" role="status" aria-live="polite"></div><span class="cv-p-channels"><span class="cv-p-sr-only">Channels in this conversation: </span><span class="cv-p-channel cv-p-channel--app"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-smartphone" aria-hidden="true"><rect width="14" height="20" x="5" y="2" rx="2" ry="2"></rect><path d="M12 18h.01"></path></svg><span class="cv-p-sr-only">App, </span></span><span class="cv-p-channel cv-p-channel--whatsapp"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-message-circle" aria-hidden="true"><path d="M2.992 16.342a2 2 0 0 1 .094 1.167l-1.065 3.29a1 1 0 0 0 1.236 1.168l3.413-.998a2 2 0 0 1 1.099.092 10 10 0 1 0-4.777-4.719"></path></svg><span class="cv-p-sr-only">WhatsApp</span></span></span></div></div></header><div class="cv-p-thread__scroll" tabindex="-1"><div class="cv-p-error" role="alert"><p class="cv-p-error__message">Could not load. Check your connection and try again.</p><button type="button" class="cv-p-button">Retry</button></div><button type="button" class="cv-p-thread__older">Load older messages</button><div class="cv-p-thread__messages"></div></div><div class="cv-p-thread__live" role="status" aria-live="polite">New messages</div><form class="cv-p-composer"><div class="cv-p-quick" role="group" aria-label="Quick replies"><button type="button" class="cv-p-chip">T</button></div><div class="cv-p-composer__row"><input class="cv-p-composer__file-input" type="file" multiple="" hidden=""/><button type="button" class="cv-p-composer__attach" aria-label="Attach"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-paperclip" aria-hidden="true" focusable="false"><path d="m16 6-8.414 8.586a2 2 0 0 0 2.829 2.829l8.414-8.586a4 4 0 1 0-5.657-5.657l-8.379 8.551a6 6 0 1 0 8.485 8.485l8.379-8.551"></path></svg></button><textarea class="cv-p-composer__input" aria-label="Message" placeholder="Write a message…" rows="1">abc</textarea><button type="submit" class="cv-p-button cv-p-button--primary cv-p-composer__send" aria-label="Send"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-send" aria-hidden="true" focusable="false"><path d="M14.536 21.686a.5.5 0 0 0 .937-.024l6.5-19a.496.496 0 0 0-.635-.635l-19 6.5a.5.5 0 0 0-.024.937l7.93 3.18a2 2 0 0 1 1.112 1.11z"></path><path d="m21.854 2.147-10.94 10.939"></path></svg></button></div></form></div>
@@ -0,0 +1 @@
1
+ <div class="cv-p cv-p-thread" aria-busy="true"><header class="cv-p-thread__header"><button type="button" class="cv-p-thread__back" aria-label="Back"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-left" aria-hidden="true" focusable="false"><path d="m12 19-7-7 7-7"></path><path d="M19 12H5"></path></svg></button><div class="cv-p-thread__heading"><h2 class="cv-p-thread__title"><span class="cv-p-thread__title-text">Invoice 4521</span></h2><div class="cv-p-thread__meta"><span class="cv-p-protocol cv-p-protocol--header"><span class="cv-p-protocol__code"><span class="cv-p-sr-only">Protocol </span>P-1</span><button type="button" class="cv-p-protocol__copy" aria-label="Copy protocol P-1"><svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-copy" aria-hidden="true"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"></rect><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"></path></svg></button></span><div class="cv-p-sr-only" role="status" aria-live="polite"></div><span class="cv-p-channels"><span class="cv-p-sr-only">Channels in this conversation: </span><span class="cv-p-channel cv-p-channel--app"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-smartphone" aria-hidden="true"><rect width="14" height="20" x="5" y="2" rx="2" ry="2"></rect><path d="M12 18h.01"></path></svg><span class="cv-p-sr-only">App, </span></span><span class="cv-p-channel cv-p-channel--whatsapp"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-message-circle" aria-hidden="true"><path d="M2.992 16.342a2 2 0 0 1 .094 1.167l-1.065 3.29a1 1 0 0 0 1.236 1.168l3.413-.998a2 2 0 0 1 1.099.092 10 10 0 1 0-4.777-4.719"></path></svg><span class="cv-p-sr-only">WhatsApp</span></span></span></div></div></header><div class="cv-p-thread__scroll" tabindex="-1"><div class="cv-p-loading"><span class="cv-p-skeleton" aria-hidden="true"></span><span class="cv-p-skeleton" aria-hidden="true"></span><span class="cv-p-sr-only">Loading…</span></div><button type="button" class="cv-p-thread__older">Load older messages</button><div class="cv-p-thread__messages"></div></div><div class="cv-p-thread__live" role="status" aria-live="polite">New messages</div><form class="cv-p-composer"><div class="cv-p-quick" role="group" aria-label="Quick replies"><button type="button" class="cv-p-chip">T</button></div><div class="cv-p-composer__row"><input class="cv-p-composer__file-input" type="file" multiple="" hidden=""/><button type="button" class="cv-p-composer__attach" aria-label="Attach"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-paperclip" aria-hidden="true" focusable="false"><path d="m16 6-8.414 8.586a2 2 0 0 0 2.829 2.829l8.414-8.586a4 4 0 1 0-5.657-5.657l-8.379 8.551a6 6 0 1 0 8.485 8.485l8.379-8.551"></path></svg></button><textarea class="cv-p-composer__input" aria-label="Message" placeholder="Write a message…" rows="1">abc</textarea><button type="submit" class="cv-p-button cv-p-button--primary cv-p-composer__send" aria-label="Send"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-send" aria-hidden="true" focusable="false"><path d="M14.536 21.686a.5.5 0 0 0 .937-.024l6.5-19a.496.496 0 0 0-.635-.635l-19 6.5a.5.5 0 0 0-.024.937l7.93 3.18a2 2 0 0 1 1.112 1.11z"></path><path d="m21.854 2.147-10.94 10.939"></path></svg></button></div></form></div>
@@ -0,0 +1 @@
1
+ <div class="cv-p cv-p-thread" aria-busy="false"><header class="cv-p-thread__header"><button type="button" class="cv-p-thread__back" aria-label="Back"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-left" aria-hidden="true" focusable="false"><path d="m12 19-7-7 7-7"></path><path d="M19 12H5"></path></svg></button><div class="cv-p-thread__heading"><h2 class="cv-p-thread__title"><span class="cv-p-thread__title-text">Invoice 4521</span></h2><div class="cv-p-thread__meta"><span class="cv-p-protocol cv-p-protocol--header"><span class="cv-p-protocol__code"><span class="cv-p-sr-only">Protocol </span>P-1</span><button type="button" class="cv-p-protocol__copy" aria-label="Copy protocol P-1"><svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-copy" aria-hidden="true"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"></rect><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"></path></svg></button></span><div class="cv-p-sr-only" role="status" aria-live="polite"></div><span class="cv-p-channels"><span class="cv-p-sr-only">Channels in this conversation: </span><span class="cv-p-channel cv-p-channel--app"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-smartphone" aria-hidden="true"><rect width="14" height="20" x="5" y="2" rx="2" ry="2"></rect><path d="M12 18h.01"></path></svg><span class="cv-p-sr-only">App, </span></span><span class="cv-p-channel cv-p-channel--whatsapp"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-message-circle" aria-hidden="true"><path d="M2.992 16.342a2 2 0 0 1 .094 1.167l-1.065 3.29a1 1 0 0 0 1.236 1.168l3.413-.998a2 2 0 0 1 1.099.092 10 10 0 1 0-4.777-4.719"></path></svg><span class="cv-p-sr-only">WhatsApp</span></span></span></div></div></header><div class="cv-p-thread__scroll" tabindex="-1"><div class="cv-p-thread__messages"><div class="cv-p-day" role="separator" aria-label="Thu, 10/01"><time class="cv-p-day__label" dateTime="2026-10-01">Thu, 10/01</time></div><div class="cv-p-bubble cv-p-bubble--mine"><span class="cv-p-sr-only">Me</span><div class="cv-p-text">*<strong>oi</strong>* <button type="button" class="cv-p-copyable cv-p-copyable--phone" aria-label="Copy 11 98888-7777">11 98888-7777</button> <button type="button" class="cv-p-copyable cv-p-copyable--email" aria-label="Copy a@b.com">a@b.com</button> <span class="cv-p-text__link-group"><a class="cv-p-text__link" href="https://x.com" rel="noopener noreferrer nofollow ugc" target="_blank">https://x.com<span class="cv-p-sr-only"> (opens in a new tab)</span></a><button type="button" class="cv-p-text__link-copy" aria-label="Copy https://x.com"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-copy" aria-hidden="true"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"></rect><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"></path></svg></button></span></div><span class="cv-p-bubble__meta"><time class="cv-p-bubble__time" dateTime="2026-10-01T12:00:00.000Z">12:00</time><span class="cv-p-bubble__status"><span class="cv-status-ticks cv-status-ticks--sent"><svg viewBox="0 0 20 12" width="15" height="9" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M1 6.5L5 10.5L14.5 1" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"></path></svg></span><span class="cv-p-sr-only">Sent</span></span></span></div><div class="cv-p-bubble"><span class="cv-p-bubble__author">Op</span><div class="cv-p-text">Hello</div><span class="cv-p-bubble__meta"><time class="cv-p-bubble__time" dateTime="2026-10-01T12:05:00.000Z">12:05</time></span></div></div></div><div class="cv-p-thread__live" role="status" aria-live="polite"></div><form class="cv-p-composer"><div class="cv-p-composer__row"><input class="cv-p-composer__file-input" type="file" multiple="" hidden=""/><button type="button" class="cv-p-composer__attach" aria-label="Attach"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-paperclip" aria-hidden="true" focusable="false"><path d="m16 6-8.414 8.586a2 2 0 0 0 2.829 2.829l8.414-8.586a4 4 0 1 0-5.657-5.657l-8.379 8.551a6 6 0 1 0 8.485 8.485l8.379-8.551"></path></svg></button><textarea class="cv-p-composer__input" aria-label="Message" placeholder="Write a message…" rows="1">abc</textarea><button type="submit" class="cv-p-button cv-p-button--primary cv-p-composer__send" aria-label="Send"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-send" aria-hidden="true" focusable="false"><path d="M14.536 21.686a.5.5 0 0 0 .937-.024l6.5-19a.496.496 0 0 0-.635-.635l-19 6.5a.5.5 0 0 0-.024.937l7.93 3.18a2 2 0 0 1 1.112 1.11z"></path><path d="m21.854 2.147-10.94 10.939"></path></svg></button></div></form></div>
@@ -0,0 +1 @@
1
+ <div class="cv-p cv-p-thread" aria-busy="false"><header class="cv-p-thread__header"><button type="button" class="cv-p-thread__back" aria-label="Back"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-left" aria-hidden="true" focusable="false"><path d="m12 19-7-7 7-7"></path><path d="M19 12H5"></path></svg></button><div class="cv-p-thread__heading"><h2 class="cv-p-thread__title"><span class="cv-p-thread__title-text">Invoice 4521</span></h2><div class="cv-p-thread__meta"><span class="cv-p-protocol cv-p-protocol--header"><span class="cv-p-protocol__code"><span class="cv-p-sr-only">Protocol </span>P-1</span><button type="button" class="cv-p-protocol__copy" aria-label="Copy protocol P-1"><svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-copy" aria-hidden="true"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"></rect><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"></path></svg></button></span><div class="cv-p-sr-only" role="status" aria-live="polite"></div><span class="cv-p-channels"><span class="cv-p-sr-only">Channels in this conversation: </span><span class="cv-p-channel cv-p-channel--app"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-smartphone" aria-hidden="true"><rect width="14" height="20" x="5" y="2" rx="2" ry="2"></rect><path d="M12 18h.01"></path></svg><span class="cv-p-sr-only">App, </span></span><span class="cv-p-channel cv-p-channel--whatsapp"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-message-circle" aria-hidden="true"><path d="M2.992 16.342a2 2 0 0 1 .094 1.167l-1.065 3.29a1 1 0 0 0 1.236 1.168l3.413-.998a2 2 0 0 1 1.099.092 10 10 0 1 0-4.777-4.719"></path></svg><span class="cv-p-sr-only">WhatsApp</span></span></span></div></div></header><div class="cv-p-thread__scroll" tabindex="-1"><button type="button" class="cv-p-thread__older">Load older messages</button><div class="cv-p-thread__messages"><div class="cv-p-day" role="separator" aria-label="Thu, 10/01"><time class="cv-p-day__label" dateTime="2026-10-01">Thu, 10/01</time></div><div class="cv-p-bubble cv-p-bubble--mine cv-p-bubble--no-tail"><span class="cv-p-sr-only">Me</span><div class="cv-p-text">*<strong>oi</strong>* <button type="button" class="cv-p-copyable cv-p-copyable--phone" aria-label="Copy 11 98888-7777">11 98888-7777</button> <button type="button" class="cv-p-copyable cv-p-copyable--email" aria-label="Copy a@b.com">a@b.com</button> <span class="cv-p-text__link-group"><a class="cv-p-text__link" href="https://x.com" rel="noopener noreferrer nofollow ugc" target="_blank">https://x.com<span class="cv-p-sr-only"> (opens in a new tab)</span></a><button type="button" class="cv-p-text__link-copy" aria-label="Copy https://x.com"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-copy" aria-hidden="true"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"></rect><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"></path></svg></button></span></div><span class="cv-p-bubble__meta"><time class="cv-p-bubble__time" dateTime="2026-10-01T12:00:00.000Z">12:00</time><span class="cv-p-bubble__status"><span class="cv-status-ticks cv-status-ticks--sent"><svg viewBox="0 0 20 12" width="15" height="9" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M1 6.5L5 10.5L14.5 1" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"></path></svg></span><span class="cv-p-sr-only">Sent</span></span></span></div><div class="cv-p-bubble cv-p-bubble--no-tail"><span class="cv-p-bubble__author">Op</span><div class="cv-p-text">Hello</div><span class="cv-p-bubble__meta"><time class="cv-p-bubble__time" dateTime="2026-10-01T12:05:00.000Z">12:05</time></span></div><div class="cv-p-day" role="separator" aria-label="Fri, 10/02"><time class="cv-p-day__label" dateTime="2026-10-02">Fri, 10/02</time></div><div class="cv-p-bubble cv-p-bubble--mine cv-p-bubble--no-tail"><span class="cv-p-sr-only">Me</span><div class="cv-p-text">Hello</div><span class="cv-p-bubble__meta"><time class="cv-p-bubble__time" dateTime="2026-10-02T12:05:00.000Z">12:05</time><span class="cv-p-bubble__status"><span class="cv-status-ticks cv-status-ticks--delivered"><svg viewBox="0 0 20 12" width="15" height="9" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M1 6.5L4.5 10L11 2" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"></path><path d="M6 6.5L9.5 10L19 1" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"></path></svg></span><span class="cv-p-sr-only">Delivered</span></span></span></div><div class="cv-p-bubble cv-p-bubble--mine cv-p-bubble--no-tail"><span class="cv-p-sr-only">Me</span><div class="cv-p-text">W</div><span class="cv-p-bubble__meta"><time class="cv-p-bubble__time" dateTime="2026-10-02T12:06:00.000Z">12:06</time><button type="button" class="cv-p-bubble__retry"><span class="cv-status-ticks cv-status-ticks--failed"><svg xmlns="http://www.w3.org/2000/svg" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-triangle-alert lucide-alert-triangle" aria-hidden="true"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"></path><path d="M12 9v4"></path><path d="M12 17h.01"></path></svg></span><span>Failed — tap to retry</span></button></span><span class="cv-p-bubble__actions"><button type="button" class="cv-p-bubble__action">Edit</button><button type="button" class="cv-p-bubble__action">Discard</button></span></div></div><div class="cv-p-thread__latest"><button type="button" class="cv-p-thread__latest-button" aria-label="Scroll to latest message"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-down" aria-hidden="true" focusable="false"><path d="m6 9 6 6 6-6"></path></svg><span class="cv-p-thread__latest-badge" aria-hidden="true">2</span></button></div></div><div class="cv-p-thread__live" role="status" aria-live="polite">New messages</div><form class="cv-p-composer"><div class="cv-p-quick" role="group" aria-label="Quick replies"><button type="button" class="cv-p-chip">T</button></div><div class="cv-p-composer__row"><input class="cv-p-composer__file-input" type="file" multiple="" hidden=""/><button type="button" class="cv-p-composer__attach" aria-label="Attach"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-paperclip" aria-hidden="true" focusable="false"><path d="m16 6-8.414 8.586a2 2 0 0 0 2.829 2.829l8.414-8.586a4 4 0 1 0-5.657-5.657l-8.379 8.551a6 6 0 1 0 8.485 8.485l8.379-8.551"></path></svg></button><textarea class="cv-p-composer__input" aria-label="Message" placeholder="Write a message…" rows="1">abc</textarea><button type="submit" class="cv-p-button cv-p-button--primary cv-p-composer__send" aria-label="Send"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-send" aria-hidden="true" focusable="false"><path d="M14.536 21.686a.5.5 0 0 0 .937-.024l6.5-19a.496.496 0 0 0-.635-.635l-19 6.5a.5.5 0 0 0-.024.937l7.93 3.18a2 2 0 0 1 1.112 1.11z"></path><path d="m21.854 2.147-10.94 10.939"></path></svg></button></div></form></div>
@@ -4,6 +4,7 @@ import type { ParticipantConversationSummary, ParticipantSubjectRef } from '@ada
4
4
 
5
5
  import type { ConversationChannel } from '../conversationChannel'
6
6
  import type { ParticipantConversationsApi, ParticipantPendingMessage } from './participantApi.types'
7
+ import type { ParticipantPerspective } from './participantPerspective'
7
8
  import { mergeParticipantMessages, type ParticipantTimelineItem } from './participantMessages'
8
9
  import type { ParticipantSendAction, ParticipantSendEntry } from './participantSendController'
9
10
  import { selectSendEntries, type ParticipantSendStates, type ParticipantSendStatesAction } from './participantSendStates'
@@ -18,6 +19,7 @@ export type UseParticipantConversationViewParams = {
18
19
  readonly pendingMessages?: readonly ParticipantPendingMessage[]
19
20
  readonly sendStates: ParticipantSendStates
20
21
  readonly dispatchSendStates: Dispatch<ParticipantSendStatesAction>
22
+ readonly perspective?: ParticipantPerspective
21
23
  }
22
24
 
23
25
  export type UseParticipantConversationViewResult = {
@@ -44,6 +46,7 @@ export function useParticipantConversationView(params: UseParticipantConversatio
44
46
  sendEntries,
45
47
  dispatchSend,
46
48
  hostPending: pendingMessages,
49
+ perspective: params.perspective,
47
50
  })
48
51
  const items = useMemo(
49
52
  () =>
@@ -0,0 +1,33 @@
1
+ import { useEffect, useReducer } from 'react'
2
+
3
+ import { useAnnounceCopy } from './ParticipantCopyAnnouncer'
4
+ import { INITIAL_PROTOCOL_COPY_STATE, copyProtocolToClipboard, protocolCopyReducer } from './participantProtocolCopy'
5
+
6
+ const COPIED_FEEDBACK_MS = 2000
7
+
8
+ export type CopyFeedback = {
9
+ readonly isCopied: boolean
10
+ readonly copy: (value: string, announcement: string) => void
11
+ }
12
+
13
+ /** Copied state is local to each token; a clipboard failure is silent and shows nothing. */
14
+ export function useParticipantCopyFeedback(): CopyFeedback {
15
+ const [state, dispatch] = useReducer(protocolCopyReducer, INITIAL_PROTOCOL_COPY_STATE)
16
+ const announce = useAnnounceCopy()
17
+ const { isCopied, copyCount } = state
18
+
19
+ useEffect(() => {
20
+ if (!isCopied) return undefined
21
+ const timer = setTimeout(() => dispatch({ type: 'expired' }), COPIED_FEEDBACK_MS)
22
+ return () => clearTimeout(timer)
23
+ }, [isCopied, copyCount])
24
+
25
+ async function copyValue(value: string, announcement: string): Promise<void> {
26
+ const clipboard = typeof navigator === 'undefined' ? undefined : navigator.clipboard
27
+ if (!(await copyProtocolToClipboard(value, clipboard))) return
28
+ dispatch({ type: 'copied' })
29
+ announce(announcement)
30
+ }
31
+
32
+ return { isCopied, copy: (value, announcement) => void copyValue(value, announcement) }
33
+ }
@@ -17,9 +17,11 @@ import {
17
17
  type ParticipantSendEntry,
18
18
  } from './participantSendController'
19
19
  import { INITIAL_PARTICIPANT_THREAD_STATE, participantThreadReducer, type ParticipantThreadState } from './participantThreadState'
20
+ import type { ParticipantPerspective } from './participantPerspective'
20
21
  import { useParticipantMarkRead } from './useParticipantMarkRead'
21
22
  import { useParticipantRevalidation } from './useParticipantRevalidation'
22
23
  import { useParticipantSend, type UseParticipantSendResult } from './useParticipantSend'
24
+ import { useParticipantUnread } from './useParticipantUnread'
23
25
 
24
26
  const THREAD_PAGE_SIZE = 30
25
27
 
@@ -35,6 +37,8 @@ export type UseParticipantThreadParams = {
35
37
  readonly sendEntries: readonly ParticipantSendEntry[]
36
38
  readonly dispatchSend: (action: ParticipantSendAction) => void
37
39
  readonly hostPending?: readonly ParticipantPendingMessage[]
40
+ /** Present on a single-conversation screen: unread comes from the messages, not from `unreadCount`. */
41
+ readonly perspective?: ParticipantPerspective
38
42
  }
39
43
 
40
44
  export type UseParticipantThreadResult = ParticipantThreadState &
@@ -55,7 +59,7 @@ function isForSubject(event: ParticipantConversationEvent | undefined, subject:
55
59
  }
56
60
 
57
61
  export function useParticipantThread(params: UseParticipantThreadParams): UseParticipantThreadResult {
58
- const { api, subject, channel, unreadCount = 0, onMarkedRead, sendEntries, dispatchSend, hostPending = [] } = params
62
+ const { api, subject, channel, unreadCount = 0, onMarkedRead, sendEntries, dispatchSend, hostPending = [], perspective } = params
59
63
  const { subjectType, subjectId } = subject
60
64
  const stableSubject = useMemo<ParticipantSubjectRef>(() => ({ subjectType, subjectId }), [subjectType, subjectId])
61
65
  const [state, dispatch] = useReducer(participantThreadReducer, INITIAL_PARTICIPANT_THREAD_STATE)
@@ -109,7 +113,14 @@ export function useParticipantThread(params: UseParticipantThreadParams): UsePar
109
113
  dispatchSend({ type: 'reflected', knownClientMessageIds: collectKnownClientMessageIds({ messages: state.messages, hostPending }) })
110
114
  }, [sendEntries, state.messages, hostPending, dispatchSend])
111
115
 
112
- useParticipantMarkRead({ apiRef, subject: stableSubject, unreadCount, messageCount: state.messages.length, onMarkedRead })
116
+ const unread = useParticipantUnread({ messages: state.messages, perspective, reportedUnreadCount: unreadCount, onMarkedRead })
117
+ useParticipantMarkRead({
118
+ apiRef,
119
+ subject: stableSubject,
120
+ unreadCount: unread.unreadCount,
121
+ messageCount: state.messages.length,
122
+ onMarkedRead: unread.handleMarkedRead,
123
+ })
113
124
 
114
125
  const localPending = useMemo(() => toLocalPending(sendEntries), [sendEntries])
115
126
  return { ...state, localPending, confirmsRead, refresh, loadOlder, ...sending }
@@ -0,0 +1,112 @@
1
+ import { useMemo, type Dispatch, type MutableRefObject } from 'react'
2
+
3
+ import type { ParticipantConversationSummary, ParticipantSubjectRef } from '@adatechnology/conversation-contracts'
4
+
5
+ import type { ConversationChannel } from '../conversationChannel'
6
+ import type { ParticipantThreadProps } from './ParticipantThread'
7
+ import { bindAttachmentUrlResolver } from './bindAttachmentUrlResolver'
8
+ import type { ParticipantConversationsApi, ParticipantPendingMessage } from './participantApi.types'
9
+ import type { ParticipantDrafts } from './participantDrafts'
10
+ import type { ParticipantConversationsLabels } from './participantLabels'
11
+ import type { ParticipantPerspective } from './participantPerspective'
12
+ import { findFailedEntry, hasSendingEntry, type ParticipantSendAction } from './participantSendController'
13
+ import type { ParticipantSendStates, ParticipantSendStatesAction } from './participantSendStates'
14
+ import { useParticipantConversationView } from './useParticipantConversationView'
15
+ import { useParticipantDraft } from './useParticipantDraft'
16
+ import { useParticipantThread, type UseParticipantThreadResult } from './useParticipantThread'
17
+ import { useParticipantThreadScroll } from './useParticipantThreadScroll'
18
+
19
+ export type ParticipantThreadControllerParams = {
20
+ readonly api: ParticipantConversationsApi
21
+ readonly subject: ParticipantSubjectRef
22
+ readonly conversation: ParticipantConversationSummary
23
+ readonly labels: ParticipantConversationsLabels
24
+ /** Holds the immutable drafts of every subject; replaced, never mutated. */
25
+ readonly draftsRef: MutableRefObject<ParticipantDrafts>
26
+ readonly sendStates: ParticipantSendStates
27
+ readonly dispatchSendStates: Dispatch<ParticipantSendStatesAction>
28
+ readonly onMarkedRead: (subject: ParticipantSubjectRef) => void
29
+ readonly channel?: ConversationChannel
30
+ readonly locale?: string
31
+ readonly pendingMessages?: readonly ParticipantPendingMessage[]
32
+ readonly onRetryPending?: (clientMessageId: string) => void
33
+ readonly perspective?: ParticipantPerspective
34
+ }
35
+
36
+ /** Everything a conversation screen needs from the hooks; the caller adds only its own presentation props. */
37
+ export type ParticipantThreadCoreProps = Pick<
38
+ ParticipantThreadProps,
39
+ | 'conversation'
40
+ | 'items'
41
+ | 'hasMore'
42
+ | 'labels'
43
+ | 'resolveAttachmentUrl'
44
+ | 'draft'
45
+ | 'onSend'
46
+ | 'status'
47
+ | 'refresh'
48
+ | 'scroll'
49
+ | 'newMessagesCount'
50
+ | 'isSending'
51
+ | 'channel'
52
+ | 'locale'
53
+ | 'onLoadOlder'
54
+ | 'pendingActions'
55
+ | 'perspective'
56
+ >
57
+
58
+ type BuildPendingActionsParams = {
59
+ readonly thread: UseParticipantThreadResult
60
+ readonly dispatchSend: (action: ParticipantSendAction) => void
61
+ readonly handleEdit: (clientMessageId: string) => void
62
+ readonly onRetryPending: ParticipantThreadControllerParams['onRetryPending']
63
+ }
64
+
65
+ function buildPendingActions({ thread, dispatchSend, handleEdit, onRetryPending }: BuildPendingActionsParams): ParticipantThreadCoreProps['pendingActions'] {
66
+ return {
67
+ retryLocal: (clientMessageId) => void thread.retry(clientMessageId),
68
+ discardLocal: (clientMessageId) => dispatchSend({ type: 'discarded', clientMessageId }),
69
+ editLocal: handleEdit,
70
+ ...(onRetryPending ? { retryHost: onRetryPending } : {}),
71
+ }
72
+ }
73
+
74
+ export function useParticipantThreadController(params: ParticipantThreadControllerParams): ParticipantThreadCoreProps {
75
+ const { api, subject, conversation, draftsRef } = params
76
+ const { draft, setText, setFiles, takeForSend, restoreFromEntry } = useParticipantDraft(draftsRef, subject)
77
+ const { thread, items, sendEntries, dispatchSend } = useParticipantConversationView(params)
78
+ const resolveAttachmentUrl = useMemo(() => bindAttachmentUrlResolver(api), [api])
79
+ const { scroll, newMessagesCount } = useParticipantThreadScroll(items, params.perspective)
80
+
81
+ function handleSend(): void {
82
+ const content = takeForSend()
83
+ if (content) void thread.send(content)
84
+ }
85
+
86
+ function handleEdit(clientMessageId: string): void {
87
+ const entry = findFailedEntry(sendEntries, clientMessageId)
88
+ if (!entry) return
89
+ restoreFromEntry(entry)
90
+ dispatchSend({ type: 'discarded', clientMessageId })
91
+ }
92
+
93
+ return {
94
+ conversation,
95
+ items,
96
+ hasMore: thread.hasMore,
97
+ labels: params.labels,
98
+ resolveAttachmentUrl,
99
+ draft: { value: draft.text, onChange: setText, files: draft.files, onFilesChange: setFiles },
100
+ onSend: handleSend,
101
+ status: thread.status,
102
+ refresh: () => void thread.refresh(),
103
+ scroll,
104
+ newMessagesCount,
105
+ isSending: hasSendingEntry(sendEntries),
106
+ channel: params.channel,
107
+ locale: params.locale,
108
+ onLoadOlder: () => void thread.loadOlder(),
109
+ pendingActions: buildPendingActions({ thread, dispatchSend, handleEdit, onRetryPending: params.onRetryPending }),
110
+ perspective: params.perspective,
111
+ }
112
+ }
@@ -2,6 +2,8 @@ import { useEffect, useRef } from 'react'
2
2
 
3
3
  import { collectServerMessageIds, countNewIncomingMessages } from './participantNewMessages'
4
4
  import type { ParticipantTimelineItem } from './participantMessages'
5
+ import type { ParticipantPerspective } from './participantPerspective'
6
+ import { useScrollAwayState } from './useScrollAwayState'
5
7
  import { useStickToBottom, type ParticipantThreadScroll } from './useStickToBottom'
6
8
 
7
9
  export type UseParticipantThreadScrollResult = {
@@ -14,13 +16,21 @@ function keyOf(item: ParticipantTimelineItem | undefined): string | undefined {
14
16
  return item.kind === 'server' ? item.message.id : `pending:${item.pending.clientMessageId}`
15
17
  }
16
18
 
17
- export function useParticipantThreadScroll(items: readonly ParticipantTimelineItem[]): UseParticipantThreadScrollResult {
18
- const { scroll, isNearBottom } = useStickToBottom({ firstKey: keyOf(items[0]), lastKey: keyOf(items[items.length - 1]) })
19
+ export function useParticipantThreadScroll(
20
+ items: readonly ParticipantTimelineItem[],
21
+ perspective?: ParticipantPerspective,
22
+ ): UseParticipantThreadScrollResult {
23
+ const { scroll: base, isNearBottom } = useStickToBottom({
24
+ firstKey: keyOf(items[0]),
25
+ lastKey: keyOf(items[items.length - 1]),
26
+ })
27
+ const { isAwayFromBottom, scrollToLatest } = useScrollAwayState(base.ref, isNearBottom)
28
+ const scroll = { ...base, isAwayFromBottom, scrollToLatest }
19
29
  const seenIds = useRef<ReadonlySet<string>>(new Set())
20
30
 
21
31
  useEffect(() => {
22
32
  if (isNearBottom) seenIds.current = collectServerMessageIds(items)
23
33
  }, [isNearBottom, items])
24
34
 
25
- return { scroll, newMessagesCount: isNearBottom ? 0 : countNewIncomingMessages(seenIds.current, items) }
35
+ return { scroll, newMessagesCount: isNearBottom ? 0 : countNewIncomingMessages(seenIds.current, items, perspective) }
26
36
  }
@@ -0,0 +1,40 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import { renderToStaticMarkup } from 'react-dom/server'
3
+
4
+ import type { ParticipantMessage } from '@adatechnology/conversation-contracts'
5
+
6
+ import { buildMessage } from './participantFixtures.test-helper'
7
+ import type { ParticipantPerspective } from './participantPerspective'
8
+ import { useParticipantUnread } from './useParticipantUnread'
9
+
10
+ const MESSAGES: readonly ParticipantMessage[] = [
11
+ buildMessage({ id: 'a', direction: 'inbound' }),
12
+ buildMessage({ id: 'b', direction: 'inbound' }),
13
+ buildMessage({ id: 'c', direction: 'outbound' }),
14
+ ]
15
+
16
+ type ProbeProps = { readonly perspective?: ParticipantPerspective; readonly reported: number }
17
+
18
+ function Probe({ perspective, reported }: ProbeProps) {
19
+ const { unreadCount } = useParticipantUnread({ messages: MESSAGES, perspective, reportedUnreadCount: reported })
20
+ return <output>{unreadCount}</output>
21
+ }
22
+
23
+ function unreadOf(props: ProbeProps): string {
24
+ return renderToStaticMarkup(<Probe {...props} />)
25
+ }
26
+
27
+ describe('useParticipantUnread', () => {
28
+ it('keeps the count the inbox reported when no perspective is given', () => {
29
+ expect(unreadOf({ reported: 3 })).toBe('<output>3</output>')
30
+ expect(unreadOf({ reported: 0 })).toBe('<output>0</output>')
31
+ })
32
+
33
+ it('derives from the messages for the operator: only the inbound ones, whatever the inbox said', () => {
34
+ expect(unreadOf({ perspective: 'operator', reported: 9 })).toBe('<output>2</output>')
35
+ })
36
+
37
+ it('derives from the messages for the participant: only the outbound ones', () => {
38
+ expect(unreadOf({ perspective: 'participant', reported: 9 })).toBe('<output>1</output>')
39
+ })
40
+ })
@@ -0,0 +1,38 @@
1
+ import { useCallback, useRef, useState } from 'react'
2
+
3
+ import type { ParticipantMessage, ParticipantSubjectRef } from '@adatechnology/conversation-contracts'
4
+
5
+ import { countOtherSideMessages, resolveViewerUnread, type ParticipantPerspective } from './participantPerspective'
6
+
7
+ export type UseParticipantUnreadParams = {
8
+ readonly messages: readonly ParticipantMessage[]
9
+ /** Absent keeps the count the inbox reported, which is how the list-driven screen works. */
10
+ readonly perspective?: ParticipantPerspective
11
+ readonly reportedUnreadCount: number
12
+ readonly onMarkedRead?: (subject: ParticipantSubjectRef) => void
13
+ }
14
+
15
+ export type UseParticipantUnreadResult = {
16
+ readonly unreadCount: number
17
+ readonly handleMarkedRead: (subject: ParticipantSubjectRef) => void
18
+ }
19
+
20
+ /** A single conversation has no inbox, so what the viewer has not read is derived from the messages themselves. */
21
+ export function useParticipantUnread(params: UseParticipantUnreadParams): UseParticipantUnreadResult {
22
+ const { messages, perspective, reportedUnreadCount, onMarkedRead } = params
23
+ const [acknowledgedCount, setAcknowledgedCount] = useState(0)
24
+ const messagesRef = useRef(messages)
25
+ messagesRef.current = messages
26
+
27
+ const handleMarkedRead = useCallback(
28
+ (subject: ParticipantSubjectRef): void => {
29
+ if (perspective) setAcknowledgedCount(countOtherSideMessages(messagesRef.current, perspective))
30
+ onMarkedRead?.(subject)
31
+ },
32
+ [perspective, onMarkedRead],
33
+ )
34
+ const unreadCount = perspective
35
+ ? resolveViewerUnread({ messages, perspective, acknowledgedCount })
36
+ : reportedUnreadCount
37
+ return { unreadCount, handleMarkedRead }
38
+ }
@@ -0,0 +1,20 @@
1
+ import { useEffect, useRef, type RefObject } from 'react'
2
+
3
+ import { readPrefersReducedMotion, scrollIntoViewOptions } from './participantScrollView'
4
+
5
+ /** Brings the pressed chip into view when the active one changes; the first render never scrolls. */
6
+ export function useRevealActiveChip(active: string): RefObject<HTMLDivElement | null> {
7
+ const strip = useRef<HTMLDivElement | null>(null)
8
+ const previousActive = useRef<string | undefined>(undefined)
9
+
10
+ useEffect(() => {
11
+ const wasActive = previousActive.current
12
+ previousActive.current = active
13
+ if (wasActive === undefined || wasActive === active) return
14
+ strip.current
15
+ ?.querySelector<HTMLElement>('[aria-pressed="true"]')
16
+ ?.scrollIntoView(scrollIntoViewOptions(readPrefersReducedMotion()))
17
+ }, [active])
18
+
19
+ return strip
20
+ }
@@ -0,0 +1,24 @@
1
+ import { useCallback, type RefObject } from 'react'
2
+
3
+ import { readPrefersReducedMotion, scrollToLatestOptions } from './participantScrollView'
4
+
5
+ export type UseScrollAwayStateResult = {
6
+ readonly isAwayFromBottom: boolean
7
+ readonly scrollToLatest: () => void
8
+ }
9
+
10
+ /** Away is the negation of the near-the-end state useStickToBottom already tracks with shouldStickToBottom. */
11
+ export function useScrollAwayState(
12
+ ref: RefObject<HTMLDivElement | null>,
13
+ isNearBottom: boolean,
14
+ ): UseScrollAwayStateResult {
15
+ const scrollToLatest = useCallback((): void => {
16
+ const element = ref.current
17
+ if (!element) return
18
+ element.scrollTo(
19
+ scrollToLatestOptions({ scrollHeight: element.scrollHeight, isReducedMotion: readPrefersReducedMotion() }),
20
+ )
21
+ }, [ref])
22
+
23
+ return { isAwayFromBottom: !isNearBottom, scrollToLatest }
24
+ }
@@ -1,6 +1,11 @@
1
1
  import { useCallback, useLayoutEffect, useRef, useState, type RefObject, type UIEvent } from 'react'
2
2
 
3
- import { NEAR_BOTTOM_THRESHOLD_PX, resolveScrollAction, scrollAnchorAfterPrepend, shouldStickToBottom } from './participantScroll'
3
+ import {
4
+ NEAR_BOTTOM_THRESHOLD_PX,
5
+ resolveScrollAction,
6
+ scrollAnchorAfterPrepend,
7
+ shouldStickToBottom,
8
+ } from './participantScroll'
4
9
 
5
10
  export type UseStickToBottomParams = {
6
11
  readonly firstKey: string | undefined
@@ -10,6 +15,9 @@ export type UseStickToBottomParams = {
10
15
  export type ParticipantThreadScroll = {
11
16
  readonly ref: RefObject<HTMLDivElement | null>
12
17
  readonly onScroll: (event: UIEvent<HTMLDivElement>) => void
18
+ /** Absent draws no scroll-to-latest button. */
19
+ readonly isAwayFromBottom?: boolean
20
+ readonly scrollToLatest?: () => void
13
21
  }
14
22
 
15
23
  export type UseStickToBottomResult = {
@@ -27,7 +35,13 @@ type Snapshot = {
27
35
 
28
36
  export function useStickToBottom({ firstKey, lastKey }: UseStickToBottomParams): UseStickToBottomResult {
29
37
  const ref = useRef<HTMLDivElement | null>(null)
30
- const snapshot = useRef<Snapshot>({ hasPositioned: false, firstKey: undefined, lastKey: undefined, scrollTop: 0, scrollHeight: 0 })
38
+ const snapshot = useRef<Snapshot>({
39
+ hasPositioned: false,
40
+ firstKey: undefined,
41
+ lastKey: undefined,
42
+ scrollTop: 0,
43
+ scrollHeight: 0,
44
+ })
31
45
  const [isNearBottom, setIsNearBottom] = useState(true)
32
46
  const isNearBottomRef = useRef(true)
33
47