@adatechnology/conversations-ui 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +96 -1
- package/dist/{ConversationSimulatorPanel-CrfFjiWh.d.ts → ConversationSimulatorPanel-q-wVgBCN.d.ts} +1 -1
- package/dist/index.d.ts +4 -4
- package/dist/participant/index.d.ts +45 -3
- package/dist/participant/index.js +287 -112
- package/dist/preview/index.d.ts +3 -3
- package/dist/{quickReply.types-B4gmZsSr.d.ts → quickReply.types-DzWH3NYV.d.ts} +1 -1
- package/dist/styles.css +74 -29
- package/package.json +1 -1
- package/src/participant/ConversationThread.test.tsx +165 -0
- package/src/participant/ConversationThread.tsx +117 -0
- package/src/participant/ParticipantMessageBubble.tsx +7 -3
- package/src/participant/ParticipantThread.tsx +9 -0
- package/src/participant/ParticipantThreadHeader.tsx +10 -0
- package/src/participant/ParticipantThreadScreen.tsx +6 -46
- package/src/participant/ParticipantTimeline.tsx +6 -3
- package/src/participant/conversationThreadApi.test.ts +27 -0
- package/src/participant/conversationThreadApi.ts +24 -0
- package/src/participant/conversationThreadCss.test.ts +116 -0
- package/src/participant/conversationThreadSummary.ts +34 -0
- package/src/participant/index.ts +3 -0
- package/src/participant/participantContrast.test-helper.ts +116 -0
- package/src/participant/participantGolden.test.tsx +46 -0
- package/src/participant/participantGoldenScenarios.test-helper.tsx +57 -0
- package/src/participant/participantMessages.ts +4 -3
- package/src/participant/participantNewMessages.test.ts +21 -0
- package/src/participant/participantNewMessages.ts +8 -3
- package/src/participant/participantPerspective.test.ts +67 -0
- package/src/participant/participantPerspective.ts +50 -0
- package/src/participant/participantPerspectiveRender.test.tsx +94 -0
- package/src/participant/participantThemeContrast.test.ts +98 -0
- package/src/participant/test-fixtures/conversations-list.golden.html +1 -0
- package/src/participant/test-fixtures/conversations-selected.golden.html +1 -0
- package/src/participant/test-fixtures/thread-avatars-initials.golden.html +1 -0
- package/src/participant/test-fixtures/thread-closed.golden.html +1 -0
- package/src/participant/test-fixtures/thread-default.golden.html +1 -0
- package/src/participant/test-fixtures/thread-error.golden.html +1 -0
- package/src/participant/test-fixtures/thread-loading.golden.html +1 -0
- package/src/participant/test-fixtures/thread-plain.golden.html +1 -0
- package/src/participant/test-fixtures/thread-tail-off.golden.html +1 -0
- package/src/participant/useParticipantConversationView.ts +3 -0
- package/src/participant/useParticipantThread.ts +13 -2
- package/src/participant/useParticipantThreadController.ts +112 -0
- package/src/participant/useParticipantThreadScroll.ts +3 -1
- package/src/participant/useParticipantUnread.test.tsx +40 -0
- package/src/participant/useParticipantUnread.ts +38 -0
- package/src/styles.css +24 -12
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
} from './participantBubbleActions'
|
|
16
16
|
import type { ParticipantConversationsLabels } from './participantLabels'
|
|
17
17
|
import { isOwnMessage, type ParticipantTimelineItem } from './participantMessages'
|
|
18
|
+
import type { ParticipantPerspective } from './participantPerspective'
|
|
18
19
|
|
|
19
20
|
export type ParticipantTimelineProps = {
|
|
20
21
|
readonly items: readonly ParticipantTimelineItem[]
|
|
@@ -26,6 +27,7 @@ export type ParticipantTimelineProps = {
|
|
|
26
27
|
readonly avatars?: 'initials'
|
|
27
28
|
readonly renderAuthorAvatar?: ParticipantAuthorAvatarRenderer
|
|
28
29
|
readonly tail?: boolean
|
|
30
|
+
readonly perspective?: ParticipantPerspective
|
|
29
31
|
}
|
|
30
32
|
|
|
31
33
|
function createdAtOf(item: ParticipantTimelineItem): string {
|
|
@@ -38,9 +40,9 @@ function keyOf(item: ParticipantTimelineItem): string {
|
|
|
38
40
|
|
|
39
41
|
type AuthorOfItem = { readonly isMine: boolean; readonly author: ParticipantAvatarAuthor }
|
|
40
42
|
|
|
41
|
-
function authorOf(item: ParticipantTimelineItem): AuthorOfItem {
|
|
43
|
+
function authorOf(item: ParticipantTimelineItem, perspective?: ParticipantPerspective): AuthorOfItem {
|
|
42
44
|
if (item.kind === 'pending') return { isMine: true, author: null }
|
|
43
|
-
return { isMine: isOwnMessage(item.message), author: item.message.authorName ?? null }
|
|
45
|
+
return { isMine: isOwnMessage(item.message, perspective), author: item.message.authorName ?? null }
|
|
44
46
|
}
|
|
45
47
|
|
|
46
48
|
function resolveActions(item: ParticipantTimelineItem, pendingActions?: ParticipantPendingActions) {
|
|
@@ -76,7 +78,7 @@ export function ParticipantTimeline(props: ParticipantTimelineProps) {
|
|
|
76
78
|
previousAuthor = undefined
|
|
77
79
|
}
|
|
78
80
|
previousDay = day
|
|
79
|
-
const { isMine, author } = authorOf(item)
|
|
81
|
+
const { isMine, author } = authorOf(item, props.perspective)
|
|
80
82
|
const showAvatar = shouldShowAvatar({ previousAuthor, author, isMine })
|
|
81
83
|
previousAuthor = isMine ? undefined : author
|
|
82
84
|
const avatar = resolveAvatarSlot({
|
|
@@ -94,6 +96,7 @@ export function ParticipantTimeline(props: ParticipantTimelineProps) {
|
|
|
94
96
|
confirmsRead={confirmsRead}
|
|
95
97
|
resolveAttachmentUrl={resolveAttachmentUrl}
|
|
96
98
|
tail={props.tail}
|
|
99
|
+
perspective={props.perspective}
|
|
97
100
|
avatar={avatar}
|
|
98
101
|
{...resolveActions(item, pendingActions)}
|
|
99
102
|
/>,
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
|
|
3
|
+
import { toParticipantApi, type ConversationThreadApi } from './conversationThreadApi'
|
|
4
|
+
|
|
5
|
+
const SUBJECT = { subjectType: 'invoice', subjectId: '1' }
|
|
6
|
+
|
|
7
|
+
function buildApi(overrides: Partial<ConversationThreadApi> = {}): ConversationThreadApi {
|
|
8
|
+
return {
|
|
9
|
+
fetchMessages: async () => [],
|
|
10
|
+
sendMessage: async () => ({ outcome: 'queued' }),
|
|
11
|
+
resolveAttachmentUrl: async () => 'x',
|
|
12
|
+
...overrides,
|
|
13
|
+
} as ConversationThreadApi
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe('toParticipantApi markRead', () => {
|
|
17
|
+
it('rejects when the host does not implement markRead, so nothing is reported as read', async () => {
|
|
18
|
+
await expect(toParticipantApi(buildApi()).markRead(SUBJECT)).rejects.toThrow()
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('delegates to the host markRead keeping its this', async () => {
|
|
22
|
+
const calls: unknown[] = []
|
|
23
|
+
const host = buildApi({ markRead: async function (this: unknown, subject) { calls.push([this, subject]) } })
|
|
24
|
+
await toParticipantApi(host).markRead(SUBJECT)
|
|
25
|
+
expect(calls).toEqual([[host, SUBJECT]])
|
|
26
|
+
})
|
|
27
|
+
})
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { ParticipantConversationsApi } from './participantApi.types'
|
|
2
|
+
|
|
3
|
+
/** What one conversation needs from the host: no list, no open-by-subject. `markRead` and `subscribe` are optional. */
|
|
4
|
+
export type ConversationThreadApi = Pick<
|
|
5
|
+
ParticipantConversationsApi,
|
|
6
|
+
'fetchMessages' | 'sendMessage' | 'resolveAttachmentUrl'
|
|
7
|
+
> &
|
|
8
|
+
Partial<Pick<ParticipantConversationsApi, 'markRead' | 'subscribe'>>
|
|
9
|
+
|
|
10
|
+
/** Methods are delegated, never detached, so adapters written as classes keep their `this`. */
|
|
11
|
+
export function toParticipantApi(api: ConversationThreadApi): ParticipantConversationsApi {
|
|
12
|
+
const { subscribe } = api
|
|
13
|
+
return {
|
|
14
|
+
listConversations: async () => ({ data: [] }),
|
|
15
|
+
fetchMessages: (subject, params) => api.fetchMessages(subject, params),
|
|
16
|
+
sendMessage: (input) => api.sendMessage(input),
|
|
17
|
+
markRead: async (subject) => {
|
|
18
|
+
if (!api.markRead) throw new Error('The host api has no markRead')
|
|
19
|
+
await api.markRead(subject)
|
|
20
|
+
},
|
|
21
|
+
resolveAttachmentUrl: (attachment, disposition) => api.resolveAttachmentUrl(attachment, disposition),
|
|
22
|
+
...(subscribe ? { subscribe: (listener) => api.subscribe?.(listener) ?? (() => undefined) } : {}),
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { describe, expect, it } from 'bun:test'
|
|
4
|
+
|
|
5
|
+
import { declarationsIn, mediaBody, PARTICIPANT_CSS } from './participantCss.test-helper'
|
|
6
|
+
|
|
7
|
+
const NEW_SOURCES = [
|
|
8
|
+
'ConversationThread.tsx',
|
|
9
|
+
'conversationThreadApi.ts',
|
|
10
|
+
'conversationThreadSummary.ts',
|
|
11
|
+
'participantPerspective.ts',
|
|
12
|
+
'useParticipantUnread.ts',
|
|
13
|
+
'useParticipantThreadController.ts',
|
|
14
|
+
].map((file) => ({ file, source: readFileSync(join(import.meta.dir, file), 'utf8') }))
|
|
15
|
+
|
|
16
|
+
describe('header additions', () => {
|
|
17
|
+
it('puts the counterpart line in one muted, ellipsized line', () => {
|
|
18
|
+
const rule = declarationsIn('.cv-p-thread__counterpart')
|
|
19
|
+
expect(rule.get('color')).toBe('var(--cv-p-i-text-muted)')
|
|
20
|
+
expect(rule.get('white-space')).toBe('nowrap')
|
|
21
|
+
expect(rule.get('text-overflow')).toBe('ellipsis')
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('keeps the host actions at the end of the bar, never wider than the bar', () => {
|
|
25
|
+
const rule = declarationsIn('.cv-p-thread__actions')
|
|
26
|
+
expect(rule.get('flex')).toBe('0 0 auto')
|
|
27
|
+
expect(rule.get('margin-inline-start')).toBe('auto')
|
|
28
|
+
expect(rule.get('max-width')).toBe('100%')
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('lets the bar wrap and gives the title a floor only when there are host actions', () => {
|
|
32
|
+
const withActions = '.cv-p-thread__header:has(.cv-p-thread__actions)'
|
|
33
|
+
expect(declarationsIn(withActions).get('flex-wrap')).toBe('wrap')
|
|
34
|
+
expect(declarationsIn(`${withActions} .cv-p-thread__heading`).get('min-width')).toBe('6rem')
|
|
35
|
+
expect(declarationsIn('.cv-p-thread__header').has('flex-wrap')).toBe(false)
|
|
36
|
+
expect(declarationsIn('.cv-p-thread__heading').get('min-width')).toBe('0')
|
|
37
|
+
})
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
describe('ticks', () => {
|
|
41
|
+
it('take their color from package tokens that a host can override, with a fallback', () => {
|
|
42
|
+
const root = declarationsIn('.cv-p')
|
|
43
|
+
expect(root.get('--cv-p-i-tick')).toBe('var(--cv-p-tick, var(--cv-p-i-text-muted))')
|
|
44
|
+
expect(root.get('--cv-p-i-tick-read')).toMatch(/^var\(--cv-p-tick-read, #[0-9a-f]{6}\)$/)
|
|
45
|
+
expect(declarationsIn('.dark .cv-p').get('--cv-p-i-tick-read')).toMatch(/^var\(--cv-p-tick-read, #[0-9a-f]{6}\)$/)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('override the shared tick colors only inside the participant bubble', () => {
|
|
49
|
+
expect(declarationsIn('.cv-p-bubble .cv-status-ticks--sent').get('color')).toBe('var(--cv-p-i-tick)')
|
|
50
|
+
expect(declarationsIn('.cv-p-bubble .cv-status-ticks--delivered').get('color')).toBe('var(--cv-p-i-tick)')
|
|
51
|
+
expect(declarationsIn('.cv-p-bubble .cv-status-ticks--read').get('color')).toBe('var(--cv-p-i-tick-read)')
|
|
52
|
+
expect(declarationsIn('.cv-p-bubble .cv-status-ticks--failed').get('color')).toBe('var(--cv-p-i-danger)')
|
|
53
|
+
expect(declarationsIn('.cv-status-ticks--read').get('color')).toBe('rgb(14 165 233)')
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('give way to the system colors under forced-colors', () => {
|
|
57
|
+
const forced = mediaBody('(forced-colors: active)') ?? ''
|
|
58
|
+
expect(forced).toContain('.cv-p-bubble .cv-status-ticks')
|
|
59
|
+
expect(declarationsIn('.cv-p-bubble .cv-status-ticks', forced).get('color')).toBe('CanvasText')
|
|
60
|
+
})
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
describe('wallpaper and the strips around it', () => {
|
|
64
|
+
it('gives the load error a solid panel so no text depends on the dots', () => {
|
|
65
|
+
expect(declarationsIn('.cv-p-thread__scroll > .cv-p-error').get('background')).toBe('var(--cv-p-i-surface)')
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('paints the live strip like the composer it sits on', () => {
|
|
69
|
+
expect(declarationsIn('.cv-p-thread__live').get('background')).toBe('var(--cv-p-i-surface-raised)')
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('keeps the wallpaper off under forced-colors', () => {
|
|
73
|
+
expect(
|
|
74
|
+
declarationsIn('.cv-p-thread__scroll', mediaBody('(forced-colors: active)') ?? '').get('background-image'),
|
|
75
|
+
).toBe('none')
|
|
76
|
+
})
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
describe('motion', () => {
|
|
80
|
+
it('adds no transition or animation outside the no-preference block', () => {
|
|
81
|
+
const rules = declarationsIn('.cv-p-thread__actions')
|
|
82
|
+
const counterpart = declarationsIn('.cv-p-thread__counterpart')
|
|
83
|
+
for (const declarations of [rules, counterpart]) {
|
|
84
|
+
expect(
|
|
85
|
+
[...declarations.keys()].some(
|
|
86
|
+
(property) => property.startsWith('transition') || property.startsWith('animation'),
|
|
87
|
+
),
|
|
88
|
+
).toBe(false)
|
|
89
|
+
}
|
|
90
|
+
expect(mediaBody('(prefers-reduced-motion: no-preference)')).toBeDefined()
|
|
91
|
+
})
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
describe('new sources', () => {
|
|
95
|
+
it('use no Tailwind utilities and none of the vocabulary of a specific product', () => {
|
|
96
|
+
for (const { file, source } of NEW_SOURCES) {
|
|
97
|
+
expect(source, file).not.toMatch(/className="[^"]*\b(flex|grid|p-\d|m-\d|text-(sm|xs|lg)|bg-\w+)\b/)
|
|
98
|
+
expect(source, file).not.toMatch(
|
|
99
|
+
/\b(nota|ocorr\w+|motorista|viagem|carga|entrega|frete|cte|nf-?e|transport\w*|trip|driver)\b/i,
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('stay under 200 lines and use no any', () => {
|
|
105
|
+
for (const { file, source } of NEW_SOURCES) {
|
|
106
|
+
expect(source.split('\n').length, file).toBeLessThanOrEqual(200)
|
|
107
|
+
expect(source, file).not.toMatch(/:\s*any\b|\bas any\b|<any>/)
|
|
108
|
+
}
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('declare the new tokens in the public theming comment', () => {
|
|
112
|
+
const raw = readFileSync(join(import.meta.dir, '..', 'styles.css'), 'utf8')
|
|
113
|
+
expect(raw).toContain('--cv-p-tick, --cv-p-tick-read')
|
|
114
|
+
expect(PARTICIPANT_CSS).toContain('--cv-p-i-tick-read')
|
|
115
|
+
})
|
|
116
|
+
})
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ParticipantConversationStatus,
|
|
3
|
+
ParticipantConversationSummary,
|
|
4
|
+
ParticipantSubjectRef,
|
|
5
|
+
} from '@adatechnology/conversation-contracts'
|
|
6
|
+
|
|
7
|
+
export type BuildThreadSummaryParams = {
|
|
8
|
+
readonly subject: ParticipantSubjectRef
|
|
9
|
+
readonly title: string
|
|
10
|
+
readonly protocol?: string
|
|
11
|
+
readonly channels?: ParticipantConversationSummary['channels']
|
|
12
|
+
readonly status?: ParticipantConversationStatus
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** The header reads a summary; a single conversation has no list to take it from, so the host's props build it. */
|
|
16
|
+
export function buildThreadSummary({
|
|
17
|
+
subject,
|
|
18
|
+
title,
|
|
19
|
+
protocol,
|
|
20
|
+
channels,
|
|
21
|
+
status,
|
|
22
|
+
}: BuildThreadSummaryParams): ParticipantConversationSummary {
|
|
23
|
+
return {
|
|
24
|
+
subjectType: subject.subjectType,
|
|
25
|
+
subjectId: subject.subjectId,
|
|
26
|
+
subjectLabel: title,
|
|
27
|
+
lastMessageAt: null,
|
|
28
|
+
unreadCount: 0,
|
|
29
|
+
awaitingParticipant: false,
|
|
30
|
+
status: status ?? 'open',
|
|
31
|
+
...(protocol ? { protocol } : {}),
|
|
32
|
+
...(channels ? { channels } : {}),
|
|
33
|
+
}
|
|
34
|
+
}
|
package/src/participant/index.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
export { ParticipantConversations, type ParticipantConversationsProps } from './ParticipantConversations'
|
|
2
|
+
export { ConversationThread, type ConversationThreadProps } from './ConversationThread'
|
|
3
|
+
export type { ConversationThreadApi } from './conversationThreadApi'
|
|
4
|
+
export type { ParticipantPerspective } from './participantPerspective'
|
|
2
5
|
export type { ParticipantAuthorAvatarRenderer } from './ParticipantAuthorAvatar'
|
|
3
6
|
export { groupParticipantConversations } from './participantGrouping'
|
|
4
7
|
export { DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS, type ParticipantConversationsLabels } from './participantLabels'
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { declarationsIn } from './participantCss.test-helper'
|
|
2
|
+
|
|
3
|
+
export type Rgba = { readonly r: number; readonly g: number; readonly b: number; readonly a: number }
|
|
4
|
+
|
|
5
|
+
export type TokenName =
|
|
6
|
+
'surface' | 'raised' | 'text' | 'muted' | 'border' | 'accent' | 'accentContrast' | 'highlight' | 'danger'
|
|
7
|
+
|
|
8
|
+
export type Palette = Readonly<Record<TokenName, Rgba>>
|
|
9
|
+
|
|
10
|
+
const TOKEN_VARIABLE: Record<TokenName, string> = {
|
|
11
|
+
surface: '--cv-p-surface',
|
|
12
|
+
raised: '--cv-p-surface-raised',
|
|
13
|
+
text: '--cv-p-text',
|
|
14
|
+
muted: '--cv-p-text-muted',
|
|
15
|
+
border: '--cv-p-border',
|
|
16
|
+
accent: '--cv-p-accent',
|
|
17
|
+
accentContrast: '--cv-p-accent-contrast',
|
|
18
|
+
highlight: '--cv-p-highlight',
|
|
19
|
+
danger: '--cv-p-danger',
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function parseColor(source: string): Rgba {
|
|
23
|
+
const hex = /^#([0-9a-f]{6})$/i.exec(source.trim())
|
|
24
|
+
if (hex?.[1]) {
|
|
25
|
+
const value = hex[1]
|
|
26
|
+
return {
|
|
27
|
+
r: parseInt(value.slice(0, 2), 16),
|
|
28
|
+
g: parseInt(value.slice(2, 4), 16),
|
|
29
|
+
b: parseInt(value.slice(4, 6), 16),
|
|
30
|
+
a: 1,
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
const functional = /^rgb\(\s*(\d+)\s+(\d+)\s+(\d+)\s*(?:\/\s*(\d+)%)?\s*\)$/.exec(source.trim())
|
|
34
|
+
if (!functional) throw new Error(`unsupported color: ${source}`)
|
|
35
|
+
return {
|
|
36
|
+
r: Number(functional[1]),
|
|
37
|
+
g: Number(functional[2]),
|
|
38
|
+
b: Number(functional[3]),
|
|
39
|
+
a: Number(functional[4] ?? 100) / 100,
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Paints `top` over an opaque `base`; the result is opaque. */
|
|
44
|
+
export function over(top: Rgba, base: Rgba): Rgba {
|
|
45
|
+
const mixChannel = (from: number, to: number): number => Math.round(from * top.a + to * (1 - top.a))
|
|
46
|
+
return { r: mixChannel(top.r, base.r), g: mixChannel(top.g, base.g), b: mixChannel(top.b, base.b), a: 1 }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** What `color-mix(in srgb, top <percent>%, transparent)` paints over `base`. */
|
|
50
|
+
export function tint(top: Rgba, percent: number, base: Rgba): Rgba {
|
|
51
|
+
return over({ ...top, a: top.a * (percent / 100) }, base)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function channelLuminance(channel: number): number {
|
|
55
|
+
const unit = channel / 255
|
|
56
|
+
return unit <= 0.03928 ? unit / 12.92 : ((unit + 0.055) / 1.055) ** 2.4
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function luminance(color: Rgba): number {
|
|
60
|
+
return 0.2126 * channelLuminance(color.r) + 0.7152 * channelLuminance(color.g) + 0.0722 * channelLuminance(color.b)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** WCAG 2.x contrast ratio; both colors must already be opaque. */
|
|
64
|
+
export function contrastRatio(first: Rgba, second: Rgba): number {
|
|
65
|
+
const [lighter, darker] = [luminance(first), luminance(second)].sort((left, right) => right - left)
|
|
66
|
+
return ((lighter ?? 0) + 0.05) / ((darker ?? 0) + 0.05)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function fallbackOf(declaration: string | undefined): string {
|
|
70
|
+
const match = /^var\(--cv-p-[a-z-]+,\s*(.+)\)$/.exec(declaration ?? '')
|
|
71
|
+
if (!match?.[1]) throw new Error(`no fallback in: ${declaration}`)
|
|
72
|
+
return match[1]
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function internalVariable(token: TokenName): string {
|
|
76
|
+
return TOKEN_VARIABLE[token].replace('--cv-p-', '--cv-p-i-')
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The default palette as styles.css declares it: the light one on `.cv-p`, the dark one on `.dark .cv-p`. */
|
|
80
|
+
export function defaultPalette(theme: 'light' | 'dark'): Palette {
|
|
81
|
+
const light = declarationsIn('.cv-p')
|
|
82
|
+
const dark = theme === 'dark' ? declarationsIn('.dark .cv-p') : new Map<string, string>()
|
|
83
|
+
const entries = (Object.keys(TOKEN_VARIABLE) as TokenName[]).map((token) => {
|
|
84
|
+
const variable = internalVariable(token)
|
|
85
|
+
return [token, parseColor(fallbackOf(dark.get(variable) ?? light.get(variable)))] as const
|
|
86
|
+
})
|
|
87
|
+
return Object.fromEntries(entries) as Palette
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** A host mapping: only the tokens it sets; the rest keeps the light defaults, exactly as the cascade would. */
|
|
91
|
+
export function mappedPalette(overrides: Partial<Record<TokenName, string>>): Palette {
|
|
92
|
+
const base = defaultPalette('light')
|
|
93
|
+
const entries = (Object.keys(base) as TokenName[]).map((token) => {
|
|
94
|
+
const override = overrides[token]
|
|
95
|
+
return [token, override ? parseColor(override) : base[token]] as const
|
|
96
|
+
})
|
|
97
|
+
return Object.fromEntries(entries) as Palette
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Opaque version of the palette: translucent tokens are painted over the surface they sit on. */
|
|
101
|
+
export function flatten(palette: Palette): Palette {
|
|
102
|
+
const entries = (Object.keys(palette) as TokenName[]).map(
|
|
103
|
+
(token) => [token, over(palette[token], palette.surface)] as const,
|
|
104
|
+
)
|
|
105
|
+
return Object.fromEntries(entries) as Palette
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Default value of a standalone token declared on `.cv-p` or `.dark .cv-p` (for example the read tick). */
|
|
109
|
+
export function declaredToken(name: string, theme: 'light' | 'dark'): string {
|
|
110
|
+
const value =
|
|
111
|
+
(theme === 'dark' ? declarationsIn('.dark .cv-p').get(name) : undefined) ?? declarationsIn('.cv-p').get(name)
|
|
112
|
+
if (!value) throw new Error(`token not declared: ${name}`)
|
|
113
|
+
return value
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export { fallbackOf }
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { beforeAll, describe, expect, it } from 'bun:test'
|
|
5
|
+
|
|
6
|
+
import { ParticipantConversations } from './ParticipantConversations'
|
|
7
|
+
import { ParticipantThread } from './ParticipantThread'
|
|
8
|
+
import { renderGoldenScenarios, type GoldenComponents } from './participantGoldenScenarios.test-helper'
|
|
9
|
+
import { DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS } from './participantLabels'
|
|
10
|
+
|
|
11
|
+
const FIXTURES_DIRECTORY = join(import.meta.dir, 'test-fixtures')
|
|
12
|
+
|
|
13
|
+
function readGolden(name: string): string {
|
|
14
|
+
return readFileSync(join(FIXTURES_DIRECTORY, `${name}.golden.html`), 'utf8')
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe('participant default markup against the recorded origin/main output', () => {
|
|
18
|
+
let rendered: Record<string, string> = {}
|
|
19
|
+
|
|
20
|
+
beforeAll(() => {
|
|
21
|
+
process.env.TZ = 'UTC'
|
|
22
|
+
rendered = renderGoldenScenarios({
|
|
23
|
+
ParticipantThread,
|
|
24
|
+
ParticipantConversations,
|
|
25
|
+
labels: DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS,
|
|
26
|
+
} as unknown as GoldenComponents)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
const scenarioNames = [
|
|
30
|
+
'thread-default',
|
|
31
|
+
'thread-plain',
|
|
32
|
+
'thread-avatars-initials',
|
|
33
|
+
'thread-tail-off',
|
|
34
|
+
'thread-error',
|
|
35
|
+
'thread-loading',
|
|
36
|
+
'thread-closed',
|
|
37
|
+
'conversations-list',
|
|
38
|
+
'conversations-selected',
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
for (const name of scenarioNames) {
|
|
42
|
+
it(`${name} is byte-identical`, () => {
|
|
43
|
+
expect(rendered[name]).toBe(readGolden(name))
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
})
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { ComponentType } from 'react'
|
|
2
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
3
|
+
|
|
4
|
+
import { buildConversation, buildMessage } from './participantFixtures.test-helper'
|
|
5
|
+
|
|
6
|
+
/** The components under comparison are injected so the same scenarios render against any checkout. */
|
|
7
|
+
export type GoldenComponents = {
|
|
8
|
+
readonly ParticipantThread: ComponentType<Record<string, unknown>>
|
|
9
|
+
readonly ParticipantConversations: ComponentType<Record<string, unknown>>
|
|
10
|
+
readonly labels: unknown
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const NOOP = (): void => undefined
|
|
14
|
+
const SUBJECT_GROUPS = [{ subjectType: 'invoice', label: 'Invoices' }]
|
|
15
|
+
|
|
16
|
+
const ITEMS = [
|
|
17
|
+
{ kind: 'server', message: buildMessage({ id: 'a', direction: 'inbound', authorName: 'Ana Souza', text: '**oi** 11 98888-7777 a@b.com https://x.com', createdAt: '2026-10-01T12:00:00.000Z' }) },
|
|
18
|
+
{ kind: 'server', message: buildMessage({ id: 'b', direction: 'outbound', authorName: 'Op', status: 'read', createdAt: '2026-10-01T12:05:00.000Z' }) },
|
|
19
|
+
{ kind: 'server', message: buildMessage({ id: 'b2', direction: 'inbound', status: 'delivered', createdAt: '2026-10-02T12:05:00.000Z' }) },
|
|
20
|
+
{ kind: 'pending', origin: 'local', displayState: 'failed', pending: { clientMessageId: 'c', subject: { subjectType: 'invoice', subjectId: '1' }, text: 'W', createdAt: '2026-10-02T12:06:00.000Z', state: 'failed' } },
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
function buildThreadProps(labels: unknown): Record<string, unknown> {
|
|
24
|
+
return {
|
|
25
|
+
conversation: buildConversation({ subjectId: '1', subjectLabel: 'Invoice 4521', protocol: 'P-1', channels: ['app', 'whatsapp'] }),
|
|
26
|
+
items: ITEMS, hasMore: true, onLoadOlder: NOOP, labels, resolveAttachmentUrl: async () => 'x',
|
|
27
|
+
draft: { value: 'abc', onChange: NOOP, files: [], onFilesChange: NOOP }, onSend: NOOP, status: 'ready', refresh: NOOP,
|
|
28
|
+
scroll: { ref: { current: null }, onScroll: NOOP, isAwayFromBottom: true, scrollToLatest: NOOP },
|
|
29
|
+
newMessagesCount: 2, isSending: false, onBack: NOOP,
|
|
30
|
+
pendingActions: { retryLocal: NOOP, discardLocal: NOOP, editLocal: NOOP },
|
|
31
|
+
quickReplies: [{ id: '1', title: 'T', shortcut: 't', body: 'b' }],
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const THREAD_VARIANTS: Record<string, Record<string, unknown>> = {
|
|
36
|
+
'thread-default': {},
|
|
37
|
+
'thread-plain': { hasMore: false, newMessagesCount: 0, quickReplies: [], scroll: { ref: { current: null }, onScroll: NOOP, isAwayFromBottom: false, scrollToLatest: NOOP }, items: ITEMS.slice(0, 2) },
|
|
38
|
+
'thread-avatars-initials': { avatars: 'initials' },
|
|
39
|
+
'thread-tail-off': { tail: false },
|
|
40
|
+
'thread-error': { status: 'error', items: [] },
|
|
41
|
+
'thread-loading': { status: 'loading', items: [] },
|
|
42
|
+
'thread-closed': { conversation: buildConversation({ subjectId: '2', status: 'closed' }) },
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function renderGoldenScenarios({ ParticipantThread, ParticipantConversations, labels }: GoldenComponents): Record<string, string> {
|
|
46
|
+
const base = buildThreadProps(labels)
|
|
47
|
+
const html: Record<string, string> = {}
|
|
48
|
+
for (const [name, extra] of Object.entries(THREAD_VARIANTS)) {
|
|
49
|
+
html[name] = renderToStaticMarkup(<ParticipantThread {...base} {...extra} />)
|
|
50
|
+
}
|
|
51
|
+
const api = { listConversations: async () => ({ data: [] }), fetchMessages: async () => [], sendMessage: async () => ({ outcome: 'queued' }), markRead: async () => undefined, resolveAttachmentUrl: async () => 'x' }
|
|
52
|
+
html['conversations-list'] = renderToStaticMarkup(<ParticipantConversations api={api} subjectGroups={SUBJECT_GROUPS} />)
|
|
53
|
+
html['conversations-selected'] = renderToStaticMarkup(
|
|
54
|
+
<ParticipantConversations api={api} subjectGroups={SUBJECT_GROUPS} selected={{ subjectType: 'invoice', subjectId: '1' }} />,
|
|
55
|
+
)
|
|
56
|
+
return html
|
|
57
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { MessageDeliveryStatus, ParticipantMessage, ParticipantSubjectRef } from '@adatechnology/conversation-contracts'
|
|
2
2
|
|
|
3
3
|
import type { ParticipantLocalPendingMessage, ParticipantPendingMessage } from './participantApi.types'
|
|
4
|
+
import { isMessageMine, type ParticipantPerspective } from './participantPerspective'
|
|
4
5
|
|
|
5
6
|
export type ParticipantPendingDisplayState = 'sending' | 'queued' | 'failed' | 'sent'
|
|
6
7
|
export type ParticipantOwnMessageStatus = 'sending' | 'queued' | 'sent' | 'delivered' | 'read' | 'failed'
|
|
@@ -58,9 +59,9 @@ export function mergeParticipantMessages(params: MergeParticipantMessagesParams)
|
|
|
58
59
|
return items.sort((left, right) => createdAtOf(left) - createdAtOf(right))
|
|
59
60
|
}
|
|
60
61
|
|
|
61
|
-
/** Direction is from the company
|
|
62
|
-
export function isOwnMessage(message: ParticipantMessage): boolean {
|
|
63
|
-
return message
|
|
62
|
+
/** Direction is from the company side: the participant is the inbound side, the operator the outbound one. */
|
|
63
|
+
export function isOwnMessage(message: ParticipantMessage, perspective?: ParticipantPerspective): boolean {
|
|
64
|
+
return isMessageMine(message, perspective)
|
|
64
65
|
}
|
|
65
66
|
|
|
66
67
|
export function resolveOwnMessageStatus(params: ResolveOwnMessageStatusParams): ParticipantOwnMessageStatus {
|
|
@@ -52,3 +52,24 @@ describe('collectServerMessageIds', () => {
|
|
|
52
52
|
expect([...collectServerMessageIds([server('a', 'inbound'), server('b', 'outbound')])]).toEqual(['a', 'b'])
|
|
53
53
|
})
|
|
54
54
|
})
|
|
55
|
+
|
|
56
|
+
describe('countNewIncomingMessages with the operator perspective', () => {
|
|
57
|
+
const seen = [server('s', 'inbound', '2026-10-01T10:00:00.000Z')]
|
|
58
|
+
const seenIds = collectServerMessageIds(seen)
|
|
59
|
+
|
|
60
|
+
it('counts a new inbound message as news', () => {
|
|
61
|
+
const items = [...seen, server('n', 'inbound', '2026-10-01T10:05:00.000Z')]
|
|
62
|
+
expect(countNewIncomingMessages(seenIds, items, 'operator')).toBe(1)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('does not count a new outbound message (written by the operator)', () => {
|
|
66
|
+
const items = [...seen, server('n', 'outbound', '2026-10-01T10:05:00.000Z')]
|
|
67
|
+
expect(countNewIncomingMessages(seenIds, items, 'operator')).toBe(0)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('keeps the participant behavior when the perspective is omitted or participant', () => {
|
|
71
|
+
const items = [...seen, server('n', 'outbound', '2026-10-01T10:05:00.000Z'), server('m', 'inbound', '2026-10-01T10:06:00.000Z')]
|
|
72
|
+
expect(countNewIncomingMessages(seenIds, items)).toBe(1)
|
|
73
|
+
expect(countNewIncomingMessages(seenIds, items, 'participant')).toBe(1)
|
|
74
|
+
})
|
|
75
|
+
})
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ParticipantTimelineItem } from './participantMessages'
|
|
2
|
+
import { isMessageMine, type ParticipantPerspective } from './participantPerspective'
|
|
2
3
|
|
|
3
4
|
export function collectServerMessageIds(items: readonly ParticipantTimelineItem[]): ReadonlySet<string> {
|
|
4
5
|
const ids = new Set<string>()
|
|
@@ -16,13 +17,17 @@ function latestSeenTime(previousIds: ReadonlySet<string>, items: readonly Partic
|
|
|
16
17
|
return latest
|
|
17
18
|
}
|
|
18
19
|
|
|
19
|
-
/** Messages from the other side
|
|
20
|
-
export function countNewIncomingMessages(
|
|
20
|
+
/** Messages from the other side of the viewer's perspective newer than what the participant has seen; older history loaded above is not news. */
|
|
21
|
+
export function countNewIncomingMessages(
|
|
22
|
+
previousIds: ReadonlySet<string>,
|
|
23
|
+
currentItems: readonly ParticipantTimelineItem[],
|
|
24
|
+
perspective?: ParticipantPerspective,
|
|
25
|
+
): number {
|
|
21
26
|
const latestSeen = latestSeenTime(previousIds, currentItems)
|
|
22
27
|
return currentItems.filter(
|
|
23
28
|
(item) =>
|
|
24
29
|
item.kind === 'server' &&
|
|
25
|
-
item.message
|
|
30
|
+
!isMessageMine(item.message, perspective) &&
|
|
26
31
|
!previousIds.has(item.message.id) &&
|
|
27
32
|
Date.parse(item.message.createdAt) > latestSeen,
|
|
28
33
|
).length
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
|
|
3
|
+
import { buildMessage } from './participantFixtures.test-helper'
|
|
4
|
+
import { isOwnMessage } from './participantMessages'
|
|
5
|
+
import {
|
|
6
|
+
countOtherSideMessages,
|
|
7
|
+
countUnreadByViewer,
|
|
8
|
+
isMessageMine,
|
|
9
|
+
resolveViewerUnread,
|
|
10
|
+
} from './participantPerspective'
|
|
11
|
+
|
|
12
|
+
const INBOUND = buildMessage({ id: 'i', direction: 'inbound' })
|
|
13
|
+
const OUTBOUND = buildMessage({ id: 'o', direction: 'outbound' })
|
|
14
|
+
|
|
15
|
+
describe('isMessageMine', () => {
|
|
16
|
+
it('treats inbound as mine for the participant, and by default', () => {
|
|
17
|
+
expect(isMessageMine(INBOUND, 'participant')).toBe(true)
|
|
18
|
+
expect(isMessageMine(OUTBOUND, 'participant')).toBe(false)
|
|
19
|
+
expect(isMessageMine(INBOUND)).toBe(true)
|
|
20
|
+
expect(isMessageMine(OUTBOUND)).toBe(false)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('inverts for the operator: outbound is mine', () => {
|
|
24
|
+
expect(isMessageMine(OUTBOUND, 'operator')).toBe(true)
|
|
25
|
+
expect(isMessageMine(INBOUND, 'operator')).toBe(false)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('keeps isOwnMessage identical without a perspective', () => {
|
|
29
|
+
expect(isOwnMessage(INBOUND)).toBe(true)
|
|
30
|
+
expect(isOwnMessage(OUTBOUND)).toBe(false)
|
|
31
|
+
expect(isOwnMessage(OUTBOUND, 'operator')).toBe(true)
|
|
32
|
+
})
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
describe('unread by the viewer', () => {
|
|
36
|
+
const MESSAGES = [
|
|
37
|
+
buildMessage({ id: 'a', direction: 'inbound' }),
|
|
38
|
+
buildMessage({ id: 'b', direction: 'inbound', status: 'read' }),
|
|
39
|
+
buildMessage({ id: 'c', direction: 'inbound', readAt: '2026-10-01T11:00:00.000Z' }),
|
|
40
|
+
buildMessage({ id: 'd', direction: 'outbound' }),
|
|
41
|
+
buildMessage({ id: 'e', direction: 'outbound' }),
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
it('counts only the other side, never the viewer own messages', () => {
|
|
45
|
+
expect(countUnreadByViewer(MESSAGES, 'operator')).toBe(1)
|
|
46
|
+
expect(countUnreadByViewer(MESSAGES, 'participant')).toBe(2)
|
|
47
|
+
expect(countOtherSideMessages(MESSAGES, 'operator')).toBe(3)
|
|
48
|
+
expect(countOtherSideMessages(MESSAGES, 'participant')).toBe(2)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('reads nothing as unread when the operator has only outbound messages', () => {
|
|
52
|
+
expect(countUnreadByViewer([OUTBOUND, OUTBOUND], 'operator')).toBe(0)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('resolves to zero until something new arrives after the acknowledgement', () => {
|
|
56
|
+
const base = { messages: MESSAGES, perspective: 'operator' } as const
|
|
57
|
+
expect(resolveViewerUnread({ ...base, acknowledgedCount: 0 })).toBe(1)
|
|
58
|
+
expect(resolveViewerUnread({ ...base, acknowledgedCount: 3 })).toBe(0)
|
|
59
|
+
expect(
|
|
60
|
+
resolveViewerUnread({
|
|
61
|
+
...base,
|
|
62
|
+
messages: [...MESSAGES, buildMessage({ id: 'f', direction: 'inbound' })],
|
|
63
|
+
acknowledgedCount: 3,
|
|
64
|
+
}),
|
|
65
|
+
).toBe(2)
|
|
66
|
+
})
|
|
67
|
+
})
|