@adatechnology/conversations-ui 0.6.1 → 0.7.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 (85) hide show
  1. package/README.md +126 -19
  2. package/dist/{chunk-HOKNKXIT.js → chunk-HMCOTZAX.js} +1 -1
  3. package/dist/{chunk-FAQLC3TX.js → chunk-L3OFAQPS.js} +2 -4
  4. package/dist/{chunk-CBYEHPX6.js → chunk-PJNR6UMN.js} +63 -1
  5. package/dist/flows/index.js +3 -5
  6. package/dist/index.js +834 -764
  7. package/dist/participant/index.d.ts +18 -1
  8. package/dist/participant/index.js +1188 -313
  9. package/dist/preview/index.js +2 -3
  10. package/dist/styles.css +583 -19
  11. package/package.json +3 -3
  12. package/src/participant/ParticipantAuthorAvatar.test.tsx +30 -0
  13. package/src/participant/ParticipantAuthorAvatar.tsx +24 -0
  14. package/src/participant/ParticipantBackButton.tsx +14 -0
  15. package/src/participant/ParticipantBubbleStatus.tsx +67 -0
  16. package/src/participant/ParticipantChannelBadges.tsx +13 -4
  17. package/src/participant/ParticipantComposer.tsx +8 -40
  18. package/src/participant/ParticipantComposerControls.tsx +83 -0
  19. package/src/participant/ParticipantConversationAvatar.tsx +13 -0
  20. package/src/participant/ParticipantConversations.tsx +7 -0
  21. package/src/participant/ParticipantConversationsOptions.test.tsx +87 -0
  22. package/src/participant/ParticipantCopyAnnouncer.tsx +48 -0
  23. package/src/participant/ParticipantCopyableToken.tsx +51 -0
  24. package/src/participant/ParticipantDayDivider.tsx +18 -0
  25. package/src/participant/ParticipantInbox.tsx +3 -1
  26. package/src/participant/ParticipantInboxRow.tsx +6 -4
  27. package/src/participant/ParticipantMessageBubble.test.tsx +52 -5
  28. package/src/participant/ParticipantMessageBubble.tsx +25 -81
  29. package/src/participant/ParticipantMessageNodes.tsx +54 -0
  30. package/src/participant/ParticipantMessageText.test.tsx +106 -0
  31. package/src/participant/ParticipantMessageText.tsx +52 -0
  32. package/src/participant/ParticipantProtocolBadge.tsx +15 -7
  33. package/src/participant/ParticipantProtocolView.test.tsx +4 -6
  34. package/src/participant/ParticipantScrollToLatest.test.tsx +153 -0
  35. package/src/participant/ParticipantScrollToLatest.tsx +40 -0
  36. package/src/participant/ParticipantThread.test.tsx +37 -0
  37. package/src/participant/ParticipantThread.tsx +45 -95
  38. package/src/participant/ParticipantThreadAvatarSequence.test.tsx +65 -0
  39. package/src/participant/ParticipantThreadHeader.test.tsx +181 -0
  40. package/src/participant/ParticipantThreadHeader.tsx +73 -0
  41. package/src/participant/ParticipantThreadScreen.tsx +24 -10
  42. package/src/participant/ParticipantTimeline.tsx +103 -0
  43. package/src/participant/index.ts +1 -0
  44. package/src/participant/participantAutoGrow.test.ts +43 -0
  45. package/src/participant/participantAutoGrow.ts +12 -0
  46. package/src/participant/participantAvatar.test.ts +74 -0
  47. package/src/participant/participantAvatar.ts +48 -0
  48. package/src/participant/participantBubbleTail.test.tsx +107 -0
  49. package/src/participant/participantChatDesign.test.tsx +198 -0
  50. package/src/participant/participantCopyAnnouncement.test.ts +52 -0
  51. package/src/participant/participantCopyAnnouncement.ts +37 -0
  52. package/src/participant/participantCss.test-helper.ts +34 -0
  53. package/src/participant/participantDay.test.ts +22 -0
  54. package/src/participant/participantDay.ts +8 -0
  55. package/src/participant/participantDocuments.ts +45 -0
  56. package/src/participant/participantHeaderA11y.test.tsx +85 -0
  57. package/src/participant/participantHeaderDesign.test.tsx +111 -0
  58. package/src/participant/participantLabels.ts +11 -0
  59. package/src/participant/participantMessageAtoms.ts +53 -0
  60. package/src/participant/participantMessageCopyables.test.ts +141 -0
  61. package/src/participant/participantMessageCopyables.ts +76 -0
  62. package/src/participant/participantMessageFormat.test-helper.ts +21 -0
  63. package/src/participant/participantMessageFormat.test.ts +177 -0
  64. package/src/participant/participantMessageFormat.ts +135 -0
  65. package/src/participant/participantMessageFormat.types.ts +22 -0
  66. package/src/participant/participantMessageInline.test.ts +82 -0
  67. package/src/participant/participantMessageInline.ts +99 -0
  68. package/src/participant/participantMessageLinks.test.ts +100 -0
  69. package/src/participant/participantMessageLinks.ts +54 -0
  70. package/src/participant/participantMessageTextStyle.test.ts +137 -0
  71. package/src/participant/participantScrollFocus.test.ts +22 -0
  72. package/src/participant/participantScrollFocus.ts +10 -0
  73. package/src/participant/participantScrollView.test.ts +74 -0
  74. package/src/participant/participantScrollView.ts +38 -0
  75. package/src/participant/participantScrollbars.test.ts +156 -0
  76. package/src/participant/participantSubjectIcon.test.tsx +27 -0
  77. package/src/participant/participantSubjectIcon.ts +26 -0
  78. package/src/participant/useParticipantCopyFeedback.ts +33 -0
  79. package/src/participant/useParticipantThreadScroll.ts +10 -2
  80. package/src/participant/useRevealActiveChip.ts +20 -0
  81. package/src/participant/useScrollAwayState.ts +24 -0
  82. package/src/participant/useStickToBottom.ts +16 -2
  83. package/src/styles.css +137 -21
  84. package/dist/chunk-MRVFFJEH.js +0 -64
  85. package/dist/chunk-OVFCLRQQ.js +0 -84
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adatechnology/conversations-ui",
3
- "version": "0.6.1",
3
+ "version": "0.7.0",
4
4
  "description": "WhatsApp conversation UI components — parametrizável por endpoint, tema e feature flags",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -35,8 +35,8 @@
35
35
  "clsx": "^2.1.1",
36
36
  "lucide-react": "^1.21.0",
37
37
  "tailwind-merge": "^3.6.0",
38
- "@adatechnology/meta-whatsapp-contracts": "0.6.0",
39
- "@adatechnology/conversation-contracts": "0.5.0"
38
+ "@adatechnology/conversation-contracts": "0.5.0",
39
+ "@adatechnology/meta-whatsapp-contracts": "0.6.0"
40
40
  },
41
41
  "peerDependencies": {
42
42
  "@xyflow/react": "^12",
@@ -0,0 +1,30 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import type { ReactNode } from 'react'
3
+ import { renderToStaticMarkup } from 'react-dom/server'
4
+
5
+ import { ParticipantAuthorAvatar } from './ParticipantAuthorAvatar'
6
+
7
+ function render(hostResult: unknown): string {
8
+ return renderToStaticMarkup(<ParticipantAuthorAvatar name="Ana Souza" render={() => hostResult as ReactNode} />)
9
+ }
10
+
11
+ describe('ParticipantAuthorAvatar host slot', () => {
12
+ it('uses the host content when it returns something', () => {
13
+ const markup = render(<img src="https://files.example/ana.png" alt="" />)
14
+ expect(markup).toContain('ana.png')
15
+ expect(markup).not.toContain('>AS<')
16
+ })
17
+
18
+ it.each([
19
+ ['false', false],
20
+ ['an empty string', ''],
21
+ ['null', null],
22
+ ['undefined', undefined],
23
+ ])('falls back to the initials when the host returns %s', (_label, hostResult) => {
24
+ expect(render(hostResult)).toContain('>AS<')
25
+ })
26
+
27
+ it('keeps zero as host content', () => {
28
+ expect(render(0)).toContain('>0<')
29
+ })
30
+ })
@@ -0,0 +1,24 @@
1
+ import { User } from 'lucide-react'
2
+ import type { ReactNode } from 'react'
3
+
4
+ import { authorInitials } from './participantAvatar'
5
+
6
+ export type ParticipantAuthorAvatarRenderer = (author: { name: string | null }) => ReactNode
7
+
8
+ export type ParticipantAuthorAvatarProps = {
9
+ readonly name: string | null
10
+ /** Host slot (a photo, for instance); its result wins over the initials. */
11
+ readonly render?: ParticipantAuthorAvatarRenderer
12
+ }
13
+
14
+ export function ParticipantAuthorAvatar({ name, render }: ParticipantAuthorAvatarProps) {
15
+ const fromHost = render?.({ name })
16
+ const hasHostContent = !(fromHost == null || fromHost === false || fromHost === '')
17
+ const initials = authorInitials(name)
18
+ const fallback = initials ?? <User size={16} aria-hidden={true} />
19
+ return (
20
+ <span className="cv-p-avatar" aria-hidden="true">
21
+ {hasHostContent ? fromHost : fallback}
22
+ </span>
23
+ )
24
+ }
@@ -0,0 +1,14 @@
1
+ import { ArrowLeft } from 'lucide-react'
2
+
3
+ export type ParticipantBackButtonProps = {
4
+ readonly label: string
5
+ readonly onBack: () => void
6
+ }
7
+
8
+ export function ParticipantBackButton({ label, onBack }: ParticipantBackButtonProps) {
9
+ return (
10
+ <button type="button" className="cv-p-thread__back" aria-label={label} onClick={onBack}>
11
+ <ArrowLeft size={24} aria-hidden="true" focusable="false" />
12
+ </button>
13
+ )
14
+ }
@@ -0,0 +1,67 @@
1
+ import { StatusTicks } from '../StatusTicks'
2
+ import type { ParticipantConversationsLabels } from './participantLabels'
3
+ import type { ParticipantOwnMessageStatus } from './participantMessages'
4
+
5
+ function statusText(status: ParticipantOwnMessageStatus, labels: ParticipantConversationsLabels): string {
6
+ const byStatus: Record<ParticipantOwnMessageStatus, string> = {
7
+ sending: labels.statusSending,
8
+ queued: labels.statusQueued,
9
+ sent: labels.statusSent,
10
+ delivered: labels.statusDelivered,
11
+ read: labels.statusRead,
12
+ failed: labels.statusFailed,
13
+ }
14
+ return byStatus[status]
15
+ }
16
+
17
+ type OwnStatusProps = {
18
+ readonly status: ParticipantOwnMessageStatus
19
+ readonly labels: ParticipantConversationsLabels
20
+ readonly onRetry?: () => void
21
+ }
22
+
23
+ export function OwnStatus({ status, labels, onRetry }: OwnStatusProps) {
24
+ const ticksStatus = status === 'sending' ? 'queued' : status
25
+ const isVisibleText = status === 'sending' || status === 'queued' || status === 'failed'
26
+
27
+ if (status === 'failed' && onRetry) {
28
+ return (
29
+ <button type="button" className="cv-p-bubble__retry" onClick={onRetry}>
30
+ <StatusTicks status="failed" appearance="stylesheet" />
31
+ <span>{labels.statusFailed}</span>
32
+ </button>
33
+ )
34
+ }
35
+
36
+ const text = status === 'failed' ? labels.statusFailedShort : statusText(status, labels)
37
+ return (
38
+ <span className="cv-p-bubble__status">
39
+ <StatusTicks status={ticksStatus} appearance="stylesheet" />
40
+ <span className={isVisibleText ? 'cv-p-bubble__status-text' : 'cv-p-sr-only'}>{text}</span>
41
+ </span>
42
+ )
43
+ }
44
+
45
+ type FailedActionsProps = {
46
+ readonly labels: ParticipantConversationsLabels
47
+ readonly onDiscard?: () => void
48
+ readonly onEdit?: () => void
49
+ }
50
+
51
+ export function FailedActions({ labels, onDiscard, onEdit }: FailedActionsProps) {
52
+ if (!onDiscard && !onEdit) return null
53
+ return (
54
+ <span className="cv-p-bubble__actions">
55
+ {onEdit ? (
56
+ <button type="button" className="cv-p-bubble__action" onClick={onEdit}>
57
+ {labels.edit}
58
+ </button>
59
+ ) : null}
60
+ {onDiscard ? (
61
+ <button type="button" className="cv-p-bubble__action" onClick={onDiscard}>
62
+ {labels.discard}
63
+ </button>
64
+ ) : null}
65
+ </span>
66
+ )
67
+ }
@@ -24,25 +24,33 @@ export type ParticipantChannelBadgesProps = {
24
24
  readonly labels: ParticipantConversationsLabels
25
25
  /** 'list' draws ul/li; 'inline' draws spans only, valid inside a button. */
26
26
  readonly variant?: 'list' | 'inline'
27
+ /** Icon size in pixels. */
28
+ readonly iconSize?: number
27
29
  }
28
30
 
29
31
  type BadgeProps = {
30
32
  readonly descriptor: ParticipantChannelDescriptor
31
33
  readonly labels: ParticipantConversationsLabels
32
34
  readonly separator: string
35
+ readonly iconSize: number
33
36
  }
34
37
 
35
- function BadgeContent({ descriptor, labels, separator }: BadgeProps) {
38
+ function BadgeContent({ descriptor, labels, separator, iconSize }: BadgeProps) {
36
39
  const Icon = ICONS[descriptor.iconName]
37
40
  return (
38
41
  <>
39
- <Icon size={14} aria-hidden={true} />
42
+ <Icon size={iconSize} aria-hidden={true} />
40
43
  <span className="cv-p-sr-only">{`${resolveParticipantChannelLabel(descriptor, labels)}${separator}`}</span>
41
44
  </>
42
45
  )
43
46
  }
44
47
 
45
- export function ParticipantChannelBadges({ channels, labels, variant = 'list' }: ParticipantChannelBadgesProps) {
48
+ export function ParticipantChannelBadges({
49
+ channels,
50
+ labels,
51
+ variant = 'list',
52
+ iconSize = 14,
53
+ }: ParticipantChannelBadgesProps) {
46
54
  const descriptors = resolveParticipantChannels(channels)
47
55
  if (descriptors.length === 0) return null
48
56
 
@@ -56,6 +64,7 @@ export function ParticipantChannelBadges({ channels, labels, variant = 'list' }:
56
64
  descriptor={descriptor}
57
65
  labels={labels}
58
66
  separator={index < descriptors.length - 1 ? ', ' : ''}
67
+ iconSize={iconSize}
59
68
  />
60
69
  </span>
61
70
  ))}
@@ -67,7 +76,7 @@ export function ParticipantChannelBadges({ channels, labels, variant = 'list' }:
67
76
  <ul className="cv-p-channels" aria-label={labels.channelsGroup}>
68
77
  {descriptors.map((descriptor) => (
69
78
  <li key={descriptor.channel} className={`cv-p-channel cv-p-channel--${descriptor.channel}`}>
70
- <BadgeContent descriptor={descriptor} labels={labels} separator="" />
79
+ <BadgeContent descriptor={descriptor} labels={labels} separator="" iconSize={iconSize} />
71
80
  </li>
72
81
  ))}
73
82
  </ul>
@@ -1,9 +1,10 @@
1
- import { useRef, useState, type ChangeEvent, type FormEvent } from 'react'
1
+ import { useState, type ChangeEvent, type FormEvent } from 'react'
2
2
 
3
3
  import { channelCapabilityFor } from '../channelCapability'
4
4
  import type { ConversationChannel } from '../conversationChannel'
5
5
  import { formatFileSize } from '../lib/format'
6
6
  import type { QuickReply } from '../quickReplies/quickReply.types'
7
+ import { AttachControl, MessageField, SendButton } from './ParticipantComposerControls'
7
8
  import { maxBytesForChannel, reduceFilesSelection, type ParticipantFileRejection } from './participantFileValidation'
8
9
  import type { ParticipantConversationsLabels } from './participantLabels'
9
10
  import { applyQuickReply } from './participantQuickReply'
@@ -73,34 +74,6 @@ function rejectionText(rejection: ParticipantFileRejection, labels: ParticipantC
73
74
  return rejection === 'tooLarge' ? labels.attachmentTooLarge : labels.attachmentTypeNotAccepted
74
75
  }
75
76
 
76
- type AttachControlProps = {
77
- readonly labels: ParticipantConversationsLabels
78
- readonly disabled: boolean
79
- readonly acceptedTypes?: readonly string[]
80
- readonly onChosen: (event: ChangeEvent<HTMLInputElement>) => void
81
- }
82
-
83
- function AttachControl({ labels, disabled, acceptedTypes, onChosen }: AttachControlProps) {
84
- const fileInputRef = useRef<HTMLInputElement>(null)
85
- return (
86
- <>
87
- <input
88
- ref={fileInputRef}
89
- className="cv-p-composer__file-input"
90
- type="file"
91
- multiple
92
- hidden
93
- accept={acceptedTypes?.join(',')}
94
- onChange={onChosen}
95
- />
96
- <button type="button" className="cv-p-button" disabled={disabled} onClick={() => fileInputRef.current?.click()}>
97
- <span aria-hidden="true">+</span>
98
- <span className="cv-p-sr-only">{labels.attach}</span>
99
- </button>
100
- </>
101
- )
102
- }
103
-
104
77
  function useFileSelection(props: ParticipantComposerProps) {
105
78
  const [rejection, setRejection] = useState<ParticipantFileRejection | undefined>(undefined)
106
79
  const { files, onFilesChange, channel } = props
@@ -147,20 +120,15 @@ export function ParticipantComposer(props: ParticipantComposerProps) {
147
120
  {channelCapabilityFor(channel).attachments.accepted ? (
148
121
  <AttachControl labels={labels} disabled={disabled} acceptedTypes={props.acceptedTypes} onChosen={handleFilesChosen} />
149
122
  ) : null}
150
- <textarea
151
- className="cv-p-composer__input"
152
- aria-label={labels.messageInputLabel}
153
- placeholder={labels.messageInputPlaceholder}
154
- rows={1}
123
+ <MessageField
155
124
  value={value}
156
- maxLength={props.maxLength}
125
+ label={labels.messageInputLabel}
126
+ placeholder={labels.messageInputPlaceholder}
157
127
  disabled={disabled}
158
- onChange={(event) => onChange(event.target.value)}
128
+ maxLength={props.maxLength}
129
+ onChange={onChange}
159
130
  />
160
- <button type="submit" className="cv-p-button cv-p-button--primary" disabled={!canSend}>
161
- <span aria-hidden="true">➤</span>
162
- <span className="cv-p-sr-only">{labels.send}</span>
163
- </button>
131
+ <SendButton label={labels.send} disabled={!canSend} />
164
132
  </div>
165
133
  </form>
166
134
  )
@@ -0,0 +1,83 @@
1
+ import { useLayoutEffect, useRef, type ChangeEvent } from 'react'
2
+
3
+ import { Paperclip, Send } from 'lucide-react'
4
+
5
+ import { fitTextareaHeight } from './participantAutoGrow'
6
+ import type { ParticipantConversationsLabels } from './participantLabels'
7
+
8
+ type AttachControlProps = {
9
+ readonly labels: ParticipantConversationsLabels
10
+ readonly disabled: boolean
11
+ readonly acceptedTypes?: readonly string[]
12
+ readonly onChosen: (event: ChangeEvent<HTMLInputElement>) => void
13
+ }
14
+
15
+ export function AttachControl({ labels, disabled, acceptedTypes, onChosen }: AttachControlProps) {
16
+ const fileInputRef = useRef<HTMLInputElement>(null)
17
+ return (
18
+ <>
19
+ <input
20
+ ref={fileInputRef}
21
+ className="cv-p-composer__file-input"
22
+ type="file"
23
+ multiple
24
+ hidden
25
+ accept={acceptedTypes?.join(',')}
26
+ onChange={onChosen}
27
+ />
28
+ <button
29
+ type="button"
30
+ className="cv-p-composer__attach"
31
+ aria-label={labels.attach}
32
+ disabled={disabled}
33
+ onClick={() => fileInputRef.current?.click()}
34
+ >
35
+ <Paperclip size={24} aria-hidden="true" focusable="false" />
36
+ </button>
37
+ </>
38
+ )
39
+ }
40
+
41
+ export function SendButton({ label, disabled }: { readonly label: string; readonly disabled: boolean }) {
42
+ return (
43
+ <button
44
+ type="submit"
45
+ className="cv-p-button cv-p-button--primary cv-p-composer__send"
46
+ aria-label={label}
47
+ disabled={disabled}
48
+ >
49
+ <Send size={20} aria-hidden="true" focusable="false" />
50
+ </button>
51
+ )
52
+ }
53
+
54
+ type MessageFieldProps = {
55
+ readonly value: string
56
+ readonly label: string
57
+ readonly placeholder: string
58
+ readonly disabled: boolean
59
+ readonly maxLength?: number
60
+ readonly onChange: (value: string) => void
61
+ }
62
+
63
+ export function MessageField({ value, label, placeholder, disabled, maxLength, onChange }: MessageFieldProps) {
64
+ const fieldRef = useRef<HTMLTextAreaElement>(null)
65
+
66
+ useLayoutEffect(() => {
67
+ if (fieldRef.current) fitTextareaHeight(fieldRef.current)
68
+ }, [value])
69
+
70
+ return (
71
+ <textarea
72
+ ref={fieldRef}
73
+ className="cv-p-composer__input"
74
+ aria-label={label}
75
+ placeholder={placeholder}
76
+ rows={1}
77
+ value={value}
78
+ maxLength={maxLength}
79
+ disabled={disabled}
80
+ onChange={(event) => onChange(event.target.value)}
81
+ />
82
+ )
83
+ }
@@ -0,0 +1,13 @@
1
+ import type { ReactNode } from 'react'
2
+
3
+ export type ParticipantConversationAvatarProps = {
4
+ readonly icon: ReactNode
5
+ }
6
+
7
+ export function ParticipantConversationAvatar({ icon }: ParticipantConversationAvatarProps) {
8
+ return (
9
+ <span className="cv-p-thread__avatar" aria-hidden="true">
10
+ {icon}
11
+ </span>
12
+ )
13
+ }
@@ -5,6 +5,7 @@ import type { ParticipantConversationSummary, ParticipantSubjectRef } from '@ada
5
5
  import type { ConversationChannel } from '../conversationChannel'
6
6
  import type { QuickReply } from '../quickReplies/quickReply.types'
7
7
  import type { ConversationsTheme } from '../types'
8
+ import type { ParticipantAuthorAvatarRenderer } from './ParticipantAuthorAvatar'
8
9
  import { ParticipantConversationsScreen } from './ParticipantConversationsScreen'
9
10
  import type {
10
11
  ParticipantConversationsClassNames,
@@ -34,6 +35,12 @@ export type ParticipantConversationsProps = {
34
35
  renderSubjectIcon?: ParticipantSubjectIconRenderer
35
36
  renderSubjectCard?: (conversation: ParticipantConversationSummary) => ReactNode
36
37
  onOpenSubject?: (subject: ParticipantSubjectRef) => void
38
+ /** Absent draws no avatar. The SDK has no photo: the host supplies it through renderAuthorAvatar. */
39
+ avatars?: 'initials'
40
+ /** Host slot for the author's photo; wins over 'initials' and enables avatars on its own. */
41
+ renderAuthorAvatar?: ParticipantAuthorAvatarRenderer
42
+ /** Speech-bubble tail on the bottom corner; false removes it. Default true. */
43
+ tail?: boolean
37
44
  }
38
45
 
39
46
  export function ParticipantConversations(props: ParticipantConversationsProps) {
@@ -0,0 +1,87 @@
1
+ import { afterAll, beforeAll, describe, expect, it, mock } from 'bun:test'
2
+ import { renderToStaticMarkup } from 'react-dom/server'
3
+
4
+ import type { ParticipantConversationsApi } from './participantApi.types'
5
+ import { buildConversation, buildMessage } from './participantFixtures.test-helper'
6
+ import type { ParticipantConversationsScreenProps } from './ParticipantConversationsScreen'
7
+ import * as realThreadModule from './useParticipantThread'
8
+
9
+ const MESSAGES = [
10
+ buildMessage({ id: 'a', authorName: 'Ana Souza', createdAt: '2026-10-01T10:00:00.000Z' }),
11
+ buildMessage({ id: 'b', direction: 'inbound', createdAt: '2026-10-01T10:01:00.000Z' }),
12
+ ]
13
+ const realUseParticipantThread = realThreadModule.useParticipantThread
14
+ let isInjecting = false
15
+
16
+ const API: ParticipantConversationsApi = {
17
+ listConversations: async () => ({ data: [] }),
18
+ fetchMessages: async () => [],
19
+ sendMessage: async () => ({ outcome: 'queued' }),
20
+ markRead: async () => undefined,
21
+ resolveAttachmentUrl: async () => 'https://files.example/a',
22
+ }
23
+
24
+ let ParticipantConversationsScreen: typeof import('./ParticipantConversationsScreen').ParticipantConversationsScreen
25
+
26
+ beforeAll(async () => {
27
+ // Server rendering runs no effects, so the messages are injected over the real hook.
28
+ mock.module('./useParticipantThread', () => ({
29
+ ...realThreadModule,
30
+ useParticipantThread: (...args: Parameters<typeof realUseParticipantThread>) => {
31
+ const result = realUseParticipantThread(...args)
32
+ return isInjecting ? { ...result, messages: MESSAGES, status: 'ready' as const } : result
33
+ },
34
+ }))
35
+ ParticipantConversationsScreen = (await import('./ParticipantConversationsScreen')).ParticipantConversationsScreen
36
+ isInjecting = true
37
+ })
38
+
39
+ afterAll(() => {
40
+ isInjecting = false
41
+ })
42
+
43
+ function renderThread(overrides: Partial<ParticipantConversationsScreenProps> = {}): string {
44
+ return renderToStaticMarkup(
45
+ <ParticipantConversationsScreen
46
+ api={API}
47
+ subjectGroups={[{ subjectType: 'invoice', label: 'Invoices' }]}
48
+ selected={{ subjectType: 'invoice', subjectId: '1' }}
49
+ onSelect={() => undefined}
50
+ inbox={{
51
+ status: 'ready',
52
+ conversations: [buildConversation({ subjectId: '1', subjectLabel: 'Invoice 4521' })],
53
+ hasMore: false,
54
+ refresh: async () => undefined,
55
+ loadMore: async () => undefined,
56
+ markSubjectRead: () => undefined,
57
+ }}
58
+ {...overrides}
59
+ />,
60
+ )
61
+ }
62
+
63
+ describe('participant options reach the DOM through the screen', () => {
64
+ it('injects the messages (sanity)', () => {
65
+ expect(renderThread()).toContain('cv-p-bubble--mine')
66
+ })
67
+
68
+ it('draws the group eyebrow from subjectGroups', () => {
69
+ expect(renderThread()).toContain('<p class="cv-p-thread__eyebrow">Invoices</p>')
70
+ })
71
+
72
+ it('draws no avatar by default and the initials with avatars="initials"', () => {
73
+ expect(renderThread()).not.toContain('cv-p-avatar')
74
+ expect(renderThread({ avatars: 'initials' })).toContain('class="cv-p-avatar" aria-hidden="true">AS<')
75
+ })
76
+
77
+ it('draws the host slot through renderAuthorAvatar', () => {
78
+ const markup = renderThread({ renderAuthorAvatar: () => <i>PHOTO</i> })
79
+ expect(markup).toContain('<i>PHOTO</i>')
80
+ })
81
+
82
+ it('keeps the tail by default and removes it from every bubble with tail={false}', () => {
83
+ expect(renderThread()).not.toContain('cv-p-bubble--no-tail')
84
+ const markup = renderThread({ tail: false })
85
+ expect(markup.match(/cv-p-bubble--no-tail/g)?.length).toBe(2)
86
+ })
87
+ })
@@ -0,0 +1,48 @@
1
+ import { createContext, useCallback, useContext, useEffect, useReducer, useRef, type ReactNode } from 'react'
2
+
3
+ import {
4
+ INITIAL_COPY_ANNOUNCEMENT,
5
+ copyAnnouncementReducer,
6
+ resolveLiveText,
7
+ type CopyAnnouncementState,
8
+ } from './participantCopyAnnouncement'
9
+
10
+ const ANNOUNCEMENT_MS = 3000
11
+
12
+ const AnnounceContext = createContext<(text: string) => void>(() => undefined)
13
+ const AnnouncementContext = createContext<CopyAnnouncementState>(INITIAL_COPY_ANNOUNCEMENT)
14
+
15
+ export function useAnnounceCopy(): (text: string) => void {
16
+ return useContext(AnnounceContext)
17
+ }
18
+
19
+ /** Holds the text of the conversation's single polite region; the copy buttons announce through it. */
20
+ export function ParticipantCopyAnnouncer({ children }: { readonly children: ReactNode }) {
21
+ const [announcement, dispatch] = useReducer(copyAnnouncementReducer, INITIAL_COPY_ANNOUNCEMENT)
22
+ const announce = useCallback((text: string): void => dispatch({ type: 'announce', text }), [])
23
+
24
+ useEffect(() => {
25
+ if (announcement.text === '') return undefined
26
+ const timer = setTimeout(() => dispatch({ type: 'expired' }), ANNOUNCEMENT_MS)
27
+ return () => clearTimeout(timer)
28
+ }, [announcement.tick, announcement.text])
29
+
30
+ return (
31
+ <AnnounceContext.Provider value={announce}>
32
+ <AnnouncementContext.Provider value={announcement}>{children}</AnnouncementContext.Provider>
33
+ </AnnounceContext.Provider>
34
+ )
35
+ }
36
+
37
+ /** The one aria-live region of the thread: new-message notice, or the last copy announcement. */
38
+ export function ParticipantThreadLive({ notice }: { readonly notice: string }) {
39
+ const copy = useContext(AnnouncementContext)
40
+ const baselineNotice = useRef<string | undefined>(undefined)
41
+ const resolution = resolveLiveText({ copy, notice, baselineNotice: baselineNotice.current })
42
+ baselineNotice.current = resolution.baselineNotice
43
+ return (
44
+ <div className="cv-p-thread__live" role="status" aria-live="polite">
45
+ {resolution.text}
46
+ </div>
47
+ )
48
+ }
@@ -0,0 +1,51 @@
1
+ import { Check, Copy } from 'lucide-react'
2
+
3
+ import { useParticipantCopyFeedback } from './useParticipantCopyFeedback'
4
+ import type { CopyableKind } from './participantMessageFormat.types'
5
+ import type { ParticipantConversationsLabels } from './participantLabels'
6
+
7
+ type CopyLabels = Pick<ParticipantConversationsLabels, 'copyValue' | 'valueCopied'>
8
+
9
+ export type ParticipantCopyableTokenProps = {
10
+ readonly copyKind: CopyableKind
11
+ readonly value: string
12
+ readonly labels: CopyLabels
13
+ }
14
+
15
+ export function ParticipantCopyableToken({ copyKind, value, labels }: ParticipantCopyableTokenProps) {
16
+ const { isCopied, copy } = useParticipantCopyFeedback()
17
+ return (
18
+ <button
19
+ type="button"
20
+ className={`cv-p-copyable cv-p-copyable--${copyKind}`}
21
+ aria-label={`${labels.copyValue} ${value}`}
22
+ onClick={() => copy(value, labels.valueCopied)}
23
+ >
24
+ {value}
25
+ {isCopied ? (
26
+ <span className="cv-p-copyable__done" aria-hidden="true">
27
+ <Check size={12} aria-hidden={true} /> {labels.valueCopied}
28
+ </span>
29
+ ) : null}
30
+ </button>
31
+ )
32
+ }
33
+
34
+ export type ParticipantLinkCopyButtonProps = {
35
+ readonly value: string
36
+ readonly labels: CopyLabels
37
+ }
38
+
39
+ export function ParticipantLinkCopyButton({ value, labels }: ParticipantLinkCopyButtonProps) {
40
+ const { isCopied, copy } = useParticipantCopyFeedback()
41
+ return (
42
+ <button
43
+ type="button"
44
+ className="cv-p-text__link-copy"
45
+ aria-label={`${labels.copyValue} ${value}`}
46
+ onClick={() => copy(value, labels.valueCopied)}
47
+ >
48
+ {isCopied ? <Check size={14} aria-hidden={true} /> : <Copy size={14} aria-hidden={true} />}
49
+ </button>
50
+ )
51
+ }
@@ -0,0 +1,18 @@
1
+ import { toDateTimeAttribute } from './participantDay'
2
+
3
+ export type ParticipantDayDividerProps = {
4
+ readonly day: Date
5
+ readonly locale?: string
6
+ }
7
+
8
+ export function ParticipantDayDivider({ day, locale }: ParticipantDayDividerProps) {
9
+ const label = day.toLocaleDateString(locale, { weekday: 'short', day: '2-digit', month: '2-digit' })
10
+
11
+ return (
12
+ <div className="cv-p-day" role="separator" aria-label={label}>
13
+ <time className="cv-p-day__label" dateTime={toDateTimeAttribute(day)}>
14
+ {label}
15
+ </time>
16
+ </div>
17
+ )
18
+ }
@@ -12,6 +12,7 @@ import { resolveLoadView, type ParticipantLoadStatus } from './participantLoadVi
12
12
  import { ParticipantInboxRow } from './ParticipantInboxRow'
13
13
  import { ParticipantInboxSearchField, type ParticipantInboxSearch } from './ParticipantInboxSearchField'
14
14
  import { ParticipantLoadError, ParticipantLoading } from './ParticipantLoadState'
15
+ import { useRevealActiveChip } from './useRevealActiveChip'
15
16
 
16
17
  export type ParticipantInboxProps = {
17
18
  readonly view: ParticipantInboxView
@@ -54,8 +55,9 @@ type FiltersProps = {
54
55
  }
55
56
 
56
57
  function Filters({ filters, active, labels, onChange }: FiltersProps) {
58
+ const strip = useRevealActiveChip(active)
57
59
  return (
58
- <div className="cv-p-filters" role="group" aria-label={labels.filtersGroup}>
60
+ <div className="cv-p-filters" role="group" aria-label={labels.filtersGroup} ref={strip}>
59
61
  {filters.map((filter) => (
60
62
  <button
61
63
  key={filter.subjectType}
@@ -4,6 +4,7 @@ import { formatTimestamp, isSameDay } from '../lib/format'
4
4
  import type { ParticipantSubjectGroup, ParticipantSubjectIconRenderer } from './participant.types'
5
5
  import { formatParticipantLabel, type ParticipantConversationsLabels } from './participantLabels'
6
6
  import { ParticipantChannelBadges } from './ParticipantChannelBadges'
7
+ import { isDrawable, resolveConversationIcon } from './participantSubjectIcon'
7
8
 
8
9
  export type ParticipantInboxRowProps = {
9
10
  readonly conversation: ParticipantConversationSummary
@@ -21,17 +22,18 @@ function formatRowTime(iso: string, locale: string | undefined): string {
21
22
  }
22
23
 
23
24
  function RowIcon({ props, kind }: { readonly props: ParticipantInboxRowProps; readonly kind: string }) {
24
- const custom = props.renderSubjectIcon?.(props.conversation)
25
- if (custom !== null && custom !== undefined && custom !== false && custom !== '') {
25
+ const { conversation, group, renderSubjectIcon } = props
26
+ const hostIcon = renderSubjectIcon?.(conversation)
27
+ if (isDrawable(hostIcon)) {
26
28
  return (
27
29
  <span className="cv-p-row__icon cv-p-inbox__icon" aria-hidden="true">
28
- {custom}
30
+ {hostIcon}
29
31
  </span>
30
32
  )
31
33
  }
32
34
  return (
33
35
  <span className="cv-p-row__icon" aria-hidden="true">
34
- {props.group?.icon ?? kind.slice(0, 2)}
36
+ {resolveConversationIcon({ conversation, group }) ?? kind.slice(0, 2)}
35
37
  </span>
36
38
  )
37
39
  }