@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.
- package/README.md +126 -19
- package/dist/{chunk-HOKNKXIT.js → chunk-HMCOTZAX.js} +1 -1
- package/dist/{chunk-FAQLC3TX.js → chunk-L3OFAQPS.js} +2 -4
- package/dist/{chunk-CBYEHPX6.js → chunk-PJNR6UMN.js} +63 -1
- package/dist/flows/index.js +3 -5
- package/dist/index.js +834 -764
- package/dist/participant/index.d.ts +18 -1
- package/dist/participant/index.js +1188 -313
- package/dist/preview/index.js +2 -3
- package/dist/styles.css +591 -19
- package/package.json +1 -1
- package/src/cssBlocks.test-helper.ts +31 -0
- package/src/legacyStyles.golden.test.ts +2 -31
- package/src/participant/ParticipantAuthorAvatar.test.tsx +30 -0
- package/src/participant/ParticipantAuthorAvatar.tsx +24 -0
- package/src/participant/ParticipantBackButton.tsx +14 -0
- package/src/participant/ParticipantBubbleStatus.tsx +67 -0
- package/src/participant/ParticipantChannelBadges.tsx +13 -4
- package/src/participant/ParticipantComposer.tsx +8 -40
- package/src/participant/ParticipantComposerControls.tsx +83 -0
- package/src/participant/ParticipantConversationAvatar.tsx +13 -0
- package/src/participant/ParticipantConversations.tsx +7 -0
- package/src/participant/ParticipantConversationsOptions.test.tsx +87 -0
- package/src/participant/ParticipantCopyAnnouncer.tsx +48 -0
- package/src/participant/ParticipantCopyableToken.tsx +51 -0
- package/src/participant/ParticipantDayDivider.tsx +18 -0
- package/src/participant/ParticipantInbox.tsx +3 -1
- package/src/participant/ParticipantInboxRow.tsx +6 -4
- package/src/participant/ParticipantMessageBubble.test.tsx +52 -5
- package/src/participant/ParticipantMessageBubble.tsx +25 -81
- package/src/participant/ParticipantMessageNodes.tsx +54 -0
- package/src/participant/ParticipantMessageText.test.tsx +106 -0
- package/src/participant/ParticipantMessageText.tsx +52 -0
- package/src/participant/ParticipantProtocolBadge.tsx +15 -7
- package/src/participant/ParticipantProtocolView.test.tsx +4 -6
- package/src/participant/ParticipantScrollToLatest.test.tsx +153 -0
- package/src/participant/ParticipantScrollToLatest.tsx +40 -0
- package/src/participant/ParticipantThread.test.tsx +37 -0
- package/src/participant/ParticipantThread.tsx +45 -95
- package/src/participant/ParticipantThreadAvatarSequence.test.tsx +65 -0
- package/src/participant/ParticipantThreadHeader.test.tsx +181 -0
- package/src/participant/ParticipantThreadHeader.tsx +73 -0
- package/src/participant/ParticipantThreadScreen.tsx +24 -10
- package/src/participant/ParticipantTimeline.tsx +103 -0
- package/src/participant/index.ts +1 -0
- package/src/participant/participantAutoGrow.test.ts +43 -0
- package/src/participant/participantAutoGrow.ts +12 -0
- package/src/participant/participantAvatar.test.ts +74 -0
- package/src/participant/participantAvatar.ts +48 -0
- package/src/participant/participantBubbleTail.test.tsx +107 -0
- package/src/participant/participantChatDesign.test.tsx +198 -0
- package/src/participant/participantCopyAnnouncement.test.ts +52 -0
- package/src/participant/participantCopyAnnouncement.ts +37 -0
- package/src/participant/participantCss.test-helper.ts +34 -0
- package/src/participant/participantDay.test.ts +22 -0
- package/src/participant/participantDay.ts +8 -0
- package/src/participant/participantDocuments.ts +45 -0
- package/src/participant/participantHeaderA11y.test.tsx +85 -0
- package/src/participant/participantHeaderDesign.test.tsx +111 -0
- package/src/participant/participantLabels.ts +11 -0
- package/src/participant/participantMessageAtoms.ts +53 -0
- package/src/participant/participantMessageCopyables.test.ts +141 -0
- package/src/participant/participantMessageCopyables.ts +76 -0
- package/src/participant/participantMessageFormat.test-helper.ts +21 -0
- package/src/participant/participantMessageFormat.test.ts +177 -0
- package/src/participant/participantMessageFormat.ts +135 -0
- package/src/participant/participantMessageFormat.types.ts +22 -0
- package/src/participant/participantMessageInline.test.ts +82 -0
- package/src/participant/participantMessageInline.ts +99 -0
- package/src/participant/participantMessageLinks.test.ts +100 -0
- package/src/participant/participantMessageLinks.ts +54 -0
- package/src/participant/participantMessageTextStyle.test.ts +137 -0
- package/src/participant/participantScrollFocus.test.ts +22 -0
- package/src/participant/participantScrollFocus.ts +10 -0
- package/src/participant/participantScrollView.test.ts +74 -0
- package/src/participant/participantScrollView.ts +38 -0
- package/src/participant/participantScrollbars.test.ts +156 -0
- package/src/participant/participantSubjectIcon.test.tsx +27 -0
- package/src/participant/participantSubjectIcon.ts +26 -0
- package/src/participant/useParticipantCopyFeedback.ts +33 -0
- package/src/participant/useParticipantThreadScroll.ts +10 -2
- package/src/participant/useRevealActiveChip.ts +20 -0
- package/src/participant/useScrollAwayState.ts +24 -0
- package/src/participant/useStickToBottom.ts +16 -2
- package/src/participantLayoutShrink.test.ts +82 -0
- package/src/styles.css +140 -21
- package/dist/chunk-MRVFFJEH.js +0 -64
- 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 =
|
|
28
|
+
type OpenedState =
|
|
29
|
+
| { readonly status: 'idle' | 'loading' | 'missing' }
|
|
30
|
+
| { readonly status: 'found'; readonly conversation: ParticipantConversationSummary }
|
|
28
31
|
|
|
29
|
-
function useOpenedConversation(
|
|
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({
|
|
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))
|
|
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
|
+
}
|
package/src/participant/index.ts
CHANGED
|
@@ -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
|
+
})
|