@adatechnology/conversations-ui 0.6.0 → 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 (88) 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 +591 -19
  11. package/package.json +1 -1
  12. package/src/cssBlocks.test-helper.ts +31 -0
  13. package/src/legacyStyles.golden.test.ts +2 -31
  14. package/src/participant/ParticipantAuthorAvatar.test.tsx +30 -0
  15. package/src/participant/ParticipantAuthorAvatar.tsx +24 -0
  16. package/src/participant/ParticipantBackButton.tsx +14 -0
  17. package/src/participant/ParticipantBubbleStatus.tsx +67 -0
  18. package/src/participant/ParticipantChannelBadges.tsx +13 -4
  19. package/src/participant/ParticipantComposer.tsx +8 -40
  20. package/src/participant/ParticipantComposerControls.tsx +83 -0
  21. package/src/participant/ParticipantConversationAvatar.tsx +13 -0
  22. package/src/participant/ParticipantConversations.tsx +7 -0
  23. package/src/participant/ParticipantConversationsOptions.test.tsx +87 -0
  24. package/src/participant/ParticipantCopyAnnouncer.tsx +48 -0
  25. package/src/participant/ParticipantCopyableToken.tsx +51 -0
  26. package/src/participant/ParticipantDayDivider.tsx +18 -0
  27. package/src/participant/ParticipantInbox.tsx +3 -1
  28. package/src/participant/ParticipantInboxRow.tsx +6 -4
  29. package/src/participant/ParticipantMessageBubble.test.tsx +52 -5
  30. package/src/participant/ParticipantMessageBubble.tsx +25 -81
  31. package/src/participant/ParticipantMessageNodes.tsx +54 -0
  32. package/src/participant/ParticipantMessageText.test.tsx +106 -0
  33. package/src/participant/ParticipantMessageText.tsx +52 -0
  34. package/src/participant/ParticipantProtocolBadge.tsx +15 -7
  35. package/src/participant/ParticipantProtocolView.test.tsx +4 -6
  36. package/src/participant/ParticipantScrollToLatest.test.tsx +153 -0
  37. package/src/participant/ParticipantScrollToLatest.tsx +40 -0
  38. package/src/participant/ParticipantThread.test.tsx +37 -0
  39. package/src/participant/ParticipantThread.tsx +45 -95
  40. package/src/participant/ParticipantThreadAvatarSequence.test.tsx +65 -0
  41. package/src/participant/ParticipantThreadHeader.test.tsx +181 -0
  42. package/src/participant/ParticipantThreadHeader.tsx +73 -0
  43. package/src/participant/ParticipantThreadScreen.tsx +24 -10
  44. package/src/participant/ParticipantTimeline.tsx +103 -0
  45. package/src/participant/index.ts +1 -0
  46. package/src/participant/participantAutoGrow.test.ts +43 -0
  47. package/src/participant/participantAutoGrow.ts +12 -0
  48. package/src/participant/participantAvatar.test.ts +74 -0
  49. package/src/participant/participantAvatar.ts +48 -0
  50. package/src/participant/participantBubbleTail.test.tsx +107 -0
  51. package/src/participant/participantChatDesign.test.tsx +198 -0
  52. package/src/participant/participantCopyAnnouncement.test.ts +52 -0
  53. package/src/participant/participantCopyAnnouncement.ts +37 -0
  54. package/src/participant/participantCss.test-helper.ts +34 -0
  55. package/src/participant/participantDay.test.ts +22 -0
  56. package/src/participant/participantDay.ts +8 -0
  57. package/src/participant/participantDocuments.ts +45 -0
  58. package/src/participant/participantHeaderA11y.test.tsx +85 -0
  59. package/src/participant/participantHeaderDesign.test.tsx +111 -0
  60. package/src/participant/participantLabels.ts +11 -0
  61. package/src/participant/participantMessageAtoms.ts +53 -0
  62. package/src/participant/participantMessageCopyables.test.ts +141 -0
  63. package/src/participant/participantMessageCopyables.ts +76 -0
  64. package/src/participant/participantMessageFormat.test-helper.ts +21 -0
  65. package/src/participant/participantMessageFormat.test.ts +177 -0
  66. package/src/participant/participantMessageFormat.ts +135 -0
  67. package/src/participant/participantMessageFormat.types.ts +22 -0
  68. package/src/participant/participantMessageInline.test.ts +82 -0
  69. package/src/participant/participantMessageInline.ts +99 -0
  70. package/src/participant/participantMessageLinks.test.ts +100 -0
  71. package/src/participant/participantMessageLinks.ts +54 -0
  72. package/src/participant/participantMessageTextStyle.test.ts +137 -0
  73. package/src/participant/participantScrollFocus.test.ts +22 -0
  74. package/src/participant/participantScrollFocus.ts +10 -0
  75. package/src/participant/participantScrollView.test.ts +74 -0
  76. package/src/participant/participantScrollView.ts +38 -0
  77. package/src/participant/participantScrollbars.test.ts +156 -0
  78. package/src/participant/participantSubjectIcon.test.tsx +27 -0
  79. package/src/participant/participantSubjectIcon.ts +26 -0
  80. package/src/participant/useParticipantCopyFeedback.ts +33 -0
  81. package/src/participant/useParticipantThreadScroll.ts +10 -2
  82. package/src/participant/useRevealActiveChip.ts +20 -0
  83. package/src/participant/useScrollAwayState.ts +24 -0
  84. package/src/participant/useStickToBottom.ts +16 -2
  85. package/src/participantLayoutShrink.test.ts +82 -0
  86. package/src/styles.css +140 -21
  87. package/dist/chunk-MRVFFJEH.js +0 -64
  88. package/dist/chunk-OVFCLRQQ.js +0 -84
@@ -0,0 +1,73 @@
1
+ import type { ParticipantConversationSummary, ParticipantSubjectRef } from '@adatechnology/conversation-contracts'
2
+
3
+ import { ParticipantBackButton } from './ParticipantBackButton'
4
+ import { ParticipantChannelBadges } from './ParticipantChannelBadges'
5
+ import { resolveParticipantChannels } from './participantChannels'
6
+ import { ParticipantConversationAvatar } from './ParticipantConversationAvatar'
7
+ import { ParticipantProtocolBadge } from './ParticipantProtocolBadge'
8
+ import type { ParticipantSubjectGroup, ParticipantSubjectIconRenderer } from './participant.types'
9
+ import type { ParticipantConversationsLabels } from './participantLabels'
10
+ import { resolveConversationIcon } from './participantSubjectIcon'
11
+
12
+ export type ParticipantThreadHeaderProps = {
13
+ readonly conversation: ParticipantConversationSummary
14
+ readonly labels: ParticipantConversationsLabels
15
+ readonly subjectGroups?: readonly ParticipantSubjectGroup[]
16
+ readonly renderSubjectIcon?: ParticipantSubjectIconRenderer
17
+ readonly onBack?: () => void
18
+ readonly onOpenSubject?: (subject: ParticipantSubjectRef) => void
19
+ }
20
+
21
+ type TitleProps = Pick<ParticipantThreadHeaderProps, 'conversation' | 'labels' | 'onOpenSubject'>
22
+
23
+ function Title({ conversation, labels, onOpenSubject }: TitleProps) {
24
+ const { subjectType, subjectId, subjectLabel } = conversation
25
+ return (
26
+ <h2 className="cv-p-thread__title">
27
+ {onOpenSubject ? (
28
+ <button
29
+ type="button"
30
+ className="cv-p-thread__title-button"
31
+ aria-label={`${labels.openSubject}: ${subjectLabel}`}
32
+ onClick={() => onOpenSubject({ subjectType, subjectId })}
33
+ >
34
+ <span className="cv-p-thread__title-text">{subjectLabel}</span>
35
+ </button>
36
+ ) : (
37
+ <span className="cv-p-thread__title-text">{subjectLabel}</span>
38
+ )}
39
+ </h2>
40
+ )
41
+ }
42
+
43
+ export function ParticipantThreadHeader({
44
+ conversation,
45
+ labels,
46
+ subjectGroups,
47
+ renderSubjectIcon,
48
+ onBack,
49
+ onOpenSubject,
50
+ }: ParticipantThreadHeaderProps) {
51
+ const group = subjectGroups?.find((candidate) => candidate.subjectType === conversation.subjectType)
52
+ const icon = resolveConversationIcon({ conversation, group, renderSubjectIcon })
53
+ const hasChannels = resolveParticipantChannels(conversation.channels).length > 0
54
+
55
+ return (
56
+ <header className="cv-p-thread__header">
57
+ {onBack ? <ParticipantBackButton label={labels.back} onBack={onBack} /> : null}
58
+ {icon !== undefined ? <ParticipantConversationAvatar icon={icon} /> : null}
59
+ <div className="cv-p-thread__heading">
60
+ {icon === undefined && group ? <p className="cv-p-thread__eyebrow">{group.label}</p> : null}
61
+ <Title conversation={conversation} labels={labels} onOpenSubject={onOpenSubject} />
62
+ {conversation.protocol || hasChannels ? (
63
+ <div className="cv-p-thread__meta">
64
+ {conversation.protocol ? (
65
+ <ParticipantProtocolBadge protocol={conversation.protocol} labels={labels} />
66
+ ) : null}
67
+ <ParticipantChannelBadges channels={conversation.channels} labels={labels} variant="inline" iconSize={16} />
68
+ </div>
69
+ ) : null}
70
+ </div>
71
+ </header>
72
+ )
73
+ }
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState, type Dispatch, type MutableRefObject } fr
2
2
 
3
3
  import type { ParticipantConversationSummary, ParticipantSubjectRef } from '@adatechnology/conversation-contracts'
4
4
 
5
+ import { ParticipantBackButton } from './ParticipantBackButton'
5
6
  import { ParticipantThread } from './ParticipantThread'
6
7
  import type { ParticipantConversationsScreenProps } from './ParticipantConversationsScreen'
7
8
  import { bindAttachmentUrlResolver } from './bindAttachmentUrlResolver'
@@ -24,9 +25,15 @@ export type ParticipantThreadScreenProps = Omit<ParticipantConversationsScreenPr
24
25
  readonly dispatchSendStates: Dispatch<ParticipantSendStatesAction>
25
26
  }
26
27
 
27
- type OpenedState = { readonly status: 'idle' | 'loading' | 'missing' } | { readonly status: 'found'; readonly conversation: ParticipantConversationSummary }
28
+ type OpenedState =
29
+ | { readonly status: 'idle' | 'loading' | 'missing' }
30
+ | { readonly status: 'found'; readonly conversation: ParticipantConversationSummary }
28
31
 
29
- function useOpenedConversation(api: ParticipantConversationsApi, subject: ParticipantSubjectRef, isNeeded: boolean): OpenedState {
32
+ function useOpenedConversation(
33
+ api: ParticipantConversationsApi,
34
+ subject: ParticipantSubjectRef,
35
+ isNeeded: boolean,
36
+ ): OpenedState {
30
37
  const [opened, setOpened] = useState<OpenedState>({ status: 'idle' })
31
38
  const { subjectType, subjectId } = subject
32
39
  const canOpen = api.openConversation !== undefined
@@ -47,16 +54,17 @@ function useOpenedConversation(api: ParticipantConversationsApi, subject: Partic
47
54
  return opened
48
55
  }
49
56
 
50
- function NotFound({ labels, onBack }: { readonly labels: ParticipantConversationsLabels; readonly onBack?: () => void }) {
57
+ function NotFound({
58
+ labels,
59
+ onBack,
60
+ }: {
61
+ readonly labels: ParticipantConversationsLabels
62
+ readonly onBack?: () => void
63
+ }) {
51
64
  return (
52
65
  <div className="cv-p cv-p-thread">
53
66
  <header className="cv-p-thread__header">
54
- {onBack ? (
55
- <button type="button" className="cv-p-button cv-p-thread__back" onClick={onBack}>
56
- <span aria-hidden="true">‹</span>
57
- <span className="cv-p-sr-only">{labels.back}</span>
58
- </button>
59
- ) : null}
67
+ {onBack ? <ParticipantBackButton label={labels.back} onBack={onBack} /> : null}
60
68
  </header>
61
69
  <p className="cv-p-empty">{labels.notFound}</p>
62
70
  </div>
@@ -74,7 +82,8 @@ export function ParticipantThreadScreen(props: ParticipantThreadScreenProps) {
74
82
  if (conversation) return <LoadedThread {...props} conversation={conversation} />
75
83
  const isResolving = opened.status === 'loading' || (api.openConversation !== undefined && opened.status === 'idle')
76
84
  const isListLoading = inbox.status === 'idle' || inbox.status === 'loading'
77
- if (isResolving || (api.openConversation === undefined && isListLoading)) return <div className="cv-p cv-p-thread" aria-busy="true" />
85
+ if (isResolving || (api.openConversation === undefined && isListLoading))
86
+ return <div className="cv-p cv-p-thread" aria-busy="true" />
78
87
  return <NotFound labels={labels} onBack={props.onBack} />
79
88
  }
80
89
 
@@ -130,6 +139,11 @@ function LoadedThread(props: LoadedThreadProps) {
130
139
  }}
131
140
  quickReplies={props.quickReplies}
132
141
  renderSubjectCard={props.renderSubjectCard}
142
+ subjectGroups={props.subjectGroups}
143
+ renderSubjectIcon={props.renderSubjectIcon}
144
+ avatars={props.avatars}
145
+ renderAuthorAvatar={props.renderAuthorAvatar}
146
+ tail={props.tail}
133
147
  />
134
148
  )
135
149
  }
@@ -0,0 +1,103 @@
1
+ import type { ReactNode } from 'react'
2
+
3
+ import { channelCapabilityFor } from '../channelCapability'
4
+ import type { ConversationChannel } from '../conversationChannel'
5
+ import { isSameDay } from '../lib/format'
6
+ import type { ResolveParticipantAttachmentUrl } from './ParticipantAttachmentItem'
7
+ import { ParticipantAuthorAvatar, type ParticipantAuthorAvatarRenderer } from './ParticipantAuthorAvatar'
8
+ import { ParticipantDayDivider } from './ParticipantDayDivider'
9
+ import { ParticipantMessageBubble } from './ParticipantMessageBubble'
10
+ import { shouldShowAvatar, type ParticipantAvatarAuthor } from './participantAvatar'
11
+ import {
12
+ bindPendingActions,
13
+ resolveParticipantBubbleActions,
14
+ type ParticipantPendingActions,
15
+ } from './participantBubbleActions'
16
+ import type { ParticipantConversationsLabels } from './participantLabels'
17
+ import { isOwnMessage, type ParticipantTimelineItem } from './participantMessages'
18
+
19
+ export type ParticipantTimelineProps = {
20
+ readonly items: readonly ParticipantTimelineItem[]
21
+ readonly labels: ParticipantConversationsLabels
22
+ readonly resolveAttachmentUrl: ResolveParticipantAttachmentUrl
23
+ readonly channel?: ConversationChannel
24
+ readonly locale?: string
25
+ readonly pendingActions?: ParticipantPendingActions
26
+ readonly avatars?: 'initials'
27
+ readonly renderAuthorAvatar?: ParticipantAuthorAvatarRenderer
28
+ readonly tail?: boolean
29
+ }
30
+
31
+ function createdAtOf(item: ParticipantTimelineItem): string {
32
+ return item.kind === 'server' ? item.message.createdAt : item.pending.createdAt
33
+ }
34
+
35
+ function keyOf(item: ParticipantTimelineItem): string {
36
+ return item.kind === 'server' ? item.message.id : `pending:${item.pending.clientMessageId}`
37
+ }
38
+
39
+ type AuthorOfItem = { readonly isMine: boolean; readonly author: ParticipantAvatarAuthor }
40
+
41
+ function authorOf(item: ParticipantTimelineItem): AuthorOfItem {
42
+ if (item.kind === 'pending') return { isMine: true, author: null }
43
+ return { isMine: isOwnMessage(item.message), author: item.message.authorName ?? null }
44
+ }
45
+
46
+ function resolveActions(item: ParticipantTimelineItem, pendingActions?: ParticipantPendingActions) {
47
+ if (!pendingActions || item.kind === 'server') return {}
48
+ return resolveParticipantBubbleActions(item, bindPendingActions(pendingActions, item.pending.clientMessageId))
49
+ }
50
+
51
+ type AvatarSlotParams = {
52
+ readonly isEnabled: boolean
53
+ readonly isMine: boolean
54
+ readonly showAvatar: boolean
55
+ readonly author: ParticipantAvatarAuthor
56
+ readonly render?: ParticipantAuthorAvatarRenderer
57
+ }
58
+
59
+ function resolveAvatarSlot({ isEnabled, isMine, showAvatar, author, render }: AvatarSlotParams): ReactNode {
60
+ if (!isEnabled || isMine) return undefined
61
+ return showAvatar ? <ParticipantAuthorAvatar name={author} render={render} /> : null
62
+ }
63
+
64
+ export function ParticipantTimeline(props: ParticipantTimelineProps) {
65
+ const { items, labels, locale, resolveAttachmentUrl, pendingActions } = props
66
+ const confirmsRead = channelCapabilityFor(props.channel ?? 'app').confirmsRead
67
+ const isAvatarEnabled = props.avatars === 'initials' || props.renderAuthorAvatar !== undefined
68
+ const nodes: ReactNode[] = []
69
+ let previousDay: Date | undefined
70
+ let previousAuthor: ParticipantAvatarAuthor | undefined
71
+
72
+ for (const item of items) {
73
+ const day = new Date(createdAtOf(item))
74
+ if (previousDay === undefined || !isSameDay(previousDay, day)) {
75
+ nodes.push(<ParticipantDayDivider key={`day:${keyOf(item)}`} day={day} locale={locale} />)
76
+ previousAuthor = undefined
77
+ }
78
+ previousDay = day
79
+ const { isMine, author } = authorOf(item)
80
+ const showAvatar = shouldShowAvatar({ previousAuthor, author, isMine })
81
+ previousAuthor = isMine ? undefined : author
82
+ const avatar = resolveAvatarSlot({
83
+ isEnabled: isAvatarEnabled,
84
+ isMine,
85
+ showAvatar,
86
+ author,
87
+ render: props.renderAuthorAvatar,
88
+ })
89
+ nodes.push(
90
+ <ParticipantMessageBubble
91
+ key={keyOf(item)}
92
+ item={item}
93
+ labels={labels}
94
+ confirmsRead={confirmsRead}
95
+ resolveAttachmentUrl={resolveAttachmentUrl}
96
+ tail={props.tail}
97
+ avatar={avatar}
98
+ {...resolveActions(item, pendingActions)}
99
+ />,
100
+ )
101
+ }
102
+ return <div className="cv-p-thread__messages">{nodes}</div>
103
+ }
@@ -1,4 +1,5 @@
1
1
  export { ParticipantConversations, type ParticipantConversationsProps } from './ParticipantConversations'
2
+ export type { ParticipantAuthorAvatarRenderer } from './ParticipantAuthorAvatar'
2
3
  export { groupParticipantConversations } from './participantGrouping'
3
4
  export { DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS, type ParticipantConversationsLabels } from './participantLabels'
4
5
  export type {
@@ -0,0 +1,43 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+
3
+ import { fitTextareaHeight, type AutoGrowTarget } from './participantAutoGrow'
4
+
5
+ describe('fitTextareaHeight', () => {
6
+ it('measures after resetting, so the field can also shrink', () => {
7
+ const heights: string[] = []
8
+ const target: AutoGrowTarget = {
9
+ style: {
10
+ get height() {
11
+ return heights.at(-1) ?? ''
12
+ },
13
+ set height(value: string) {
14
+ heights.push(value)
15
+ },
16
+ },
17
+ scrollHeight: 96,
18
+ offsetHeight: 96,
19
+ clientHeight: 96,
20
+ }
21
+ fitTextareaHeight(target)
22
+ expect(heights).toEqual(['auto', '96px'])
23
+ })
24
+
25
+ it('adds the border to the content height, so a border-box field does not overflow', () => {
26
+ const heights: string[] = []
27
+ const target: AutoGrowTarget = {
28
+ style: {
29
+ get height() {
30
+ return heights.at(-1) ?? ''
31
+ },
32
+ set height(value: string) {
33
+ heights.push(value)
34
+ },
35
+ },
36
+ scrollHeight: 61,
37
+ offsetHeight: 63,
38
+ clientHeight: 61,
39
+ }
40
+ fitTextareaHeight(target)
41
+ expect(heights.at(-1)).toBe('63px')
42
+ })
43
+ })
@@ -0,0 +1,12 @@
1
+ export type AutoGrowTarget = {
2
+ readonly style: { height: string }
3
+ readonly scrollHeight: number
4
+ readonly offsetHeight: number
5
+ readonly clientHeight: number
6
+ }
7
+
8
+ /** Fits the field to its content plus its border (border-box), so no phantom scrollbar; the CSS max-height caps it. */
9
+ export function fitTextareaHeight(target: AutoGrowTarget): void {
10
+ target.style.height = 'auto'
11
+ target.style.height = `${target.scrollHeight + (target.offsetHeight - target.clientHeight)}px`
12
+ }
@@ -0,0 +1,74 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+
3
+ import { authorInitials, shouldShowAvatar } from './participantAvatar'
4
+
5
+ describe('authorInitials', () => {
6
+ it('takes the first letters of the first and last names', () => {
7
+ expect(authorInitials('Ana Souza')).toBe('AS')
8
+ expect(authorInitials('maria ana souza lima')).toBe('ML')
9
+ })
10
+
11
+ it('uses a single initial for a single name', () => {
12
+ expect(authorInitials('Ana')).toBe('A')
13
+ })
14
+
15
+ it('ignores prepositions and punctuation', () => {
16
+ expect(authorInitials('Maria da Silva')).toBe('MS')
17
+ expect(authorInitials('João de Souza e Silva')).toBe('JS')
18
+ expect(authorInitials(' (Ana) - Souza. ')).toBe('AS')
19
+ })
20
+
21
+ it('keeps an accented initial and upper-cases it', () => {
22
+ expect(authorInitials('élida ávila')).toBe('ÉÁ')
23
+ })
24
+
25
+ it('keeps the first word even when it looks like a particle', () => {
26
+ expect(authorInitials('Le Minh')).toBe('LM')
27
+ expect(authorInitials('Van Morrison')).toBe('VM')
28
+ })
29
+
30
+ it('composes accents before taking the initial', () => {
31
+ expect(authorInitials('e\u0301lida')).toBe('\u00c9')
32
+ })
33
+
34
+ it('falls back to the first word when only prepositions remain', () => {
35
+ expect(authorInitials('de da')).toBe('D')
36
+ })
37
+
38
+ it('returns undefined for missing, empty, question-mark or emoji-only names', () => {
39
+ expect(authorInitials(null)).toBeUndefined()
40
+ expect(authorInitials(undefined)).toBeUndefined()
41
+ expect(authorInitials('')).toBeUndefined()
42
+ expect(authorInitials(' ')).toBeUndefined()
43
+ expect(authorInitials('?')).toBeUndefined()
44
+ expect(authorInitials('😀')).toBeUndefined()
45
+ })
46
+
47
+ it('ignores a trailing emoji', () => {
48
+ expect(authorInitials('Ana 😀')).toBe('A')
49
+ })
50
+ })
51
+
52
+ describe('shouldShowAvatar', () => {
53
+ it('shows it on the first message of a sequence', () => {
54
+ expect(shouldShowAvatar({ previousAuthor: undefined, author: 'Ana', isMine: false })).toBe(true)
55
+ })
56
+
57
+ it('hides it while the same author keeps talking', () => {
58
+ expect(shouldShowAvatar({ previousAuthor: 'Ana', author: 'Ana', isMine: false })).toBe(false)
59
+ })
60
+
61
+ it('shows it again when the author changes', () => {
62
+ expect(shouldShowAvatar({ previousAuthor: 'Ana', author: 'Bruno', isMine: false })).toBe(true)
63
+ })
64
+
65
+ it('treats consecutive unnamed authors as one sequence', () => {
66
+ expect(shouldShowAvatar({ previousAuthor: null, author: null, isMine: false })).toBe(false)
67
+ expect(shouldShowAvatar({ previousAuthor: 'Ana', author: null, isMine: false })).toBe(true)
68
+ })
69
+
70
+ it('never shows it on an own message', () => {
71
+ expect(shouldShowAvatar({ previousAuthor: undefined, author: null, isMine: true })).toBe(false)
72
+ expect(shouldShowAvatar({ previousAuthor: 'Ana', author: 'Ana', isMine: true })).toBe(false)
73
+ })
74
+ })
@@ -0,0 +1,48 @@
1
+ const NAME_PARTICLES = new Set([
2
+ 'a',
3
+ 'e',
4
+ 'o',
5
+ 'da',
6
+ 'das',
7
+ 'de',
8
+ 'del',
9
+ 'di',
10
+ 'do',
11
+ 'dos',
12
+ 'du',
13
+ 'la',
14
+ 'le',
15
+ 'van',
16
+ 'von',
17
+ ])
18
+
19
+ export type ParticipantAvatarAuthor = string | null
20
+
21
+ export type ShouldShowAvatarParams = {
22
+ /** Author of the previous message; undefined at the start of the list or right after an own message. */
23
+ readonly previousAuthor: ParticipantAvatarAuthor | undefined
24
+ readonly author: ParticipantAvatarAuthor
25
+ readonly isMine: boolean
26
+ }
27
+
28
+ function lettersOf(word: string): string {
29
+ return word.replace(/[^\p{L}\p{M}]/gu, '')
30
+ }
31
+
32
+ function firstLetter(word: string): string {
33
+ return (Array.from(word.normalize('NFC'))[0] ?? '').toUpperCase()
34
+ }
35
+
36
+ /** Up to two initials; undefined when the name has no letters, so the caller draws the neutral icon. */
37
+ export function authorInitials(name: string | null | undefined): string | undefined {
38
+ const [first, ...rest] = (name ?? '').split(/\s+/).map(lettersOf).filter(Boolean)
39
+ if (first === undefined) return undefined
40
+ const last = rest.filter((word) => !NAME_PARTICLES.has(word.toLowerCase())).pop()
41
+ return `${firstLetter(first)}${last === undefined ? '' : firstLetter(last)}`
42
+ }
43
+
44
+ export function shouldShowAvatar({ previousAuthor, author, isMine }: ShouldShowAvatarParams): boolean {
45
+ if (isMine) return false
46
+ if (previousAuthor === undefined) return true
47
+ return previousAuthor !== author
48
+ }
@@ -0,0 +1,107 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { describe, expect, it } from 'bun:test'
4
+ import { renderToStaticMarkup } from 'react-dom/server'
5
+
6
+ import { splitBlocks } from '../cssBlocks.test-helper'
7
+ import { buildMessage, findForeignClassTokens, findUtilityClassTokens } from './participantFixtures.test-helper'
8
+ import { DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS } from './participantLabels'
9
+ import { ParticipantMessageBubble, type ParticipantMessageBubbleProps } from './ParticipantMessageBubble'
10
+
11
+ const CSS = readFileSync(join(import.meta.dir, '..', 'styles.css'), 'utf8').replace(/\/\*[\s\S]*?\*\//g, '')
12
+
13
+ function declarationsOf(selector: string): Map<string, string> {
14
+ const declarations = new Map<string, string>()
15
+ for (const { prelude, body } of splitBlocks(CSS)) {
16
+ if (body === undefined || prelude.startsWith('@')) continue
17
+ if (!prelude.split(',').some((entry) => entry.trim() === selector)) continue
18
+ for (const declaration of body.split(';')) {
19
+ const separator = declaration.indexOf(':')
20
+ if (separator === -1) continue
21
+ declarations.set(declaration.slice(0, separator).trim().toLowerCase(), declaration.slice(separator + 1).trim())
22
+ }
23
+ }
24
+ return declarations
25
+ }
26
+
27
+ function render(direction: 'inbound' | 'outbound', extra: Partial<ParticipantMessageBubbleProps> = {}): string {
28
+ return renderToStaticMarkup(
29
+ <ParticipantMessageBubble
30
+ item={{ kind: 'server', message: buildMessage({ id: 'm1', direction }) }}
31
+ labels={DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS}
32
+ confirmsRead
33
+ resolveAttachmentUrl={async () => 'https://files.example/a.png'}
34
+ {...extra}
35
+ />,
36
+ )
37
+ }
38
+
39
+ describe('bubble tail CSS', () => {
40
+ it('draws two decorative triangles that never catch touches', () => {
41
+ for (const pseudo of ['::before', '::after']) {
42
+ const tail = declarationsOf(`.cv-p-bubble${pseudo}`)
43
+ expect(tail.get('content')).toBe("''")
44
+ expect(tail.get('pointer-events')).toBe('none')
45
+ expect(tail.get('position')).toBe('absolute')
46
+ expect(tail.get('border-style')).toBe('solid')
47
+ }
48
+ expect(declarationsOf('.cv-p-bubble').get('position')).toBe('relative')
49
+ })
50
+
51
+ it('puts the received tail on the left, edge colour under fill colour', () => {
52
+ expect(declarationsOf('.cv-p-bubble::before').get('left')).toBe('-10px')
53
+ expect(declarationsOf('.cv-p-bubble::after').get('left')).toBe('-8px')
54
+ expect(declarationsOf('.cv-p-bubble::before').get('border-bottom-color')).toBe('var(--cv-p-tail-edge)')
55
+ expect(declarationsOf('.cv-p-bubble::after').get('border-bottom-color')).toBe('var(--cv-p-tail-fill)')
56
+ expect(declarationsOf('.cv-p-bubble').get('border-bottom-left-radius')).toBe('0')
57
+ })
58
+
59
+ it('mirrors the own tail to the right with the own tokens', () => {
60
+ expect(declarationsOf('.cv-p-bubble--mine::before').get('right')).toBe('-10px')
61
+ expect(declarationsOf('.cv-p-bubble--mine::after').get('right')).toBe('-8px')
62
+ const mine = declarationsOf('.cv-p-bubble--mine')
63
+ expect(mine.get('border-bottom-right-radius')).toBe('0')
64
+ expect(mine.get('--cv-p-tail-edge')).toBe('var(--cv-p-i-accent)')
65
+ expect(mine.get('--cv-p-tail-fill')).toBe('var(--cv-p-i-highlight)')
66
+ expect(declarationsOf('.cv-p-bubble').get('--cv-p-tail-edge')).toBe('var(--cv-p-i-border)')
67
+ expect(declarationsOf('.cv-p-bubble').get('--cv-p-tail-fill')).toBe('var(--cv-p-i-surface-raised)')
68
+ })
69
+
70
+ it('turns the tail off and restores the corner with --no-tail', () => {
71
+ expect(declarationsOf('.cv-p-bubble--no-tail::before').get('content')).toBe('none')
72
+ expect(declarationsOf('.cv-p-bubble--no-tail::after').get('content')).toBe('none')
73
+ expect(declarationsOf('.cv-p-bubble--no-tail').get('border-radius')).toBe('var(--cv-p-i-radius)')
74
+ })
75
+
76
+ it('uses no fixed colour for the tail', () => {
77
+ const tailRules = splitBlocks(CSS).filter(({ prelude }) => /cv-p-bubble[^,{]*::(before|after)/.test(prelude))
78
+ expect(tailRules.length).toBeGreaterThan(0)
79
+ for (const { body } of tailRules) expect(body ?? '').not.toMatch(/#[0-9a-f]{3,8}|rgba?\(/i)
80
+ })
81
+ })
82
+
83
+ describe('bubble tail markup', () => {
84
+ it('keeps the default markup free of any new class', () => {
85
+ expect(render('outbound')).toContain('<div class="cv-p-bubble">')
86
+ expect(render('inbound')).toContain('<div class="cv-p-bubble cv-p-bubble--mine">')
87
+ })
88
+
89
+ it('adds --no-tail only when tail is false, on both sides', () => {
90
+ expect(render('outbound', { tail: false })).toContain('class="cv-p-bubble cv-p-bubble--no-tail"')
91
+ expect(render('inbound', { tail: false })).toContain('class="cv-p-bubble cv-p-bubble--mine cv-p-bubble--no-tail"')
92
+ expect(render('outbound', { tail: true })).not.toContain('--no-tail')
93
+ })
94
+
95
+ it('keeps the content and the avatar row unchanged by the tail flag', () => {
96
+ const withAvatar = render('outbound', { avatar: <b>A</b>, tail: false })
97
+ expect(withAvatar).toContain('cv-p-bubble-row')
98
+ expect(withAvatar).toContain('cv-p-bubble--no-tail')
99
+ expect(render('outbound', { tail: false })).toContain('cv-p-bubble__meta')
100
+ })
101
+
102
+ it('stays inside the participant namespace', () => {
103
+ const markup = render('outbound', { tail: false })
104
+ expect(findForeignClassTokens(markup)).toEqual([])
105
+ expect(findUtilityClassTokens(markup)).toEqual([])
106
+ })
107
+ })