@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.
Files changed (47) hide show
  1. package/README.md +96 -1
  2. package/dist/{ConversationSimulatorPanel-CrfFjiWh.d.ts → ConversationSimulatorPanel-q-wVgBCN.d.ts} +1 -1
  3. package/dist/index.d.ts +4 -4
  4. package/dist/participant/index.d.ts +45 -3
  5. package/dist/participant/index.js +287 -112
  6. package/dist/preview/index.d.ts +3 -3
  7. package/dist/{quickReply.types-B4gmZsSr.d.ts → quickReply.types-DzWH3NYV.d.ts} +1 -1
  8. package/dist/styles.css +74 -29
  9. package/package.json +1 -1
  10. package/src/participant/ConversationThread.test.tsx +165 -0
  11. package/src/participant/ConversationThread.tsx +117 -0
  12. package/src/participant/ParticipantMessageBubble.tsx +7 -3
  13. package/src/participant/ParticipantThread.tsx +9 -0
  14. package/src/participant/ParticipantThreadHeader.tsx +10 -0
  15. package/src/participant/ParticipantThreadScreen.tsx +6 -46
  16. package/src/participant/ParticipantTimeline.tsx +6 -3
  17. package/src/participant/conversationThreadApi.test.ts +27 -0
  18. package/src/participant/conversationThreadApi.ts +24 -0
  19. package/src/participant/conversationThreadCss.test.ts +116 -0
  20. package/src/participant/conversationThreadSummary.ts +34 -0
  21. package/src/participant/index.ts +3 -0
  22. package/src/participant/participantContrast.test-helper.ts +116 -0
  23. package/src/participant/participantGolden.test.tsx +46 -0
  24. package/src/participant/participantGoldenScenarios.test-helper.tsx +57 -0
  25. package/src/participant/participantMessages.ts +4 -3
  26. package/src/participant/participantNewMessages.test.ts +21 -0
  27. package/src/participant/participantNewMessages.ts +8 -3
  28. package/src/participant/participantPerspective.test.ts +67 -0
  29. package/src/participant/participantPerspective.ts +50 -0
  30. package/src/participant/participantPerspectiveRender.test.tsx +94 -0
  31. package/src/participant/participantThemeContrast.test.ts +98 -0
  32. package/src/participant/test-fixtures/conversations-list.golden.html +1 -0
  33. package/src/participant/test-fixtures/conversations-selected.golden.html +1 -0
  34. package/src/participant/test-fixtures/thread-avatars-initials.golden.html +1 -0
  35. package/src/participant/test-fixtures/thread-closed.golden.html +1 -0
  36. package/src/participant/test-fixtures/thread-default.golden.html +1 -0
  37. package/src/participant/test-fixtures/thread-error.golden.html +1 -0
  38. package/src/participant/test-fixtures/thread-loading.golden.html +1 -0
  39. package/src/participant/test-fixtures/thread-plain.golden.html +1 -0
  40. package/src/participant/test-fixtures/thread-tail-off.golden.html +1 -0
  41. package/src/participant/useParticipantConversationView.ts +3 -0
  42. package/src/participant/useParticipantThread.ts +13 -2
  43. package/src/participant/useParticipantThreadController.ts +112 -0
  44. package/src/participant/useParticipantThreadScroll.ts +3 -1
  45. package/src/participant/useParticipantUnread.test.tsx +40 -0
  46. package/src/participant/useParticipantUnread.ts +38 -0
  47. 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
+ }
@@ -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 perspective: the participant is the inbound side. */
62
- export function isOwnMessage(message: ParticipantMessage): boolean {
63
- return message.direction === 'inbound'
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 ('outbound') newer than what the participant has seen; older history loaded above is not news. */
20
- export function countNewIncomingMessages(previousIds: ReadonlySet<string>, currentItems: readonly ParticipantTimelineItem[]): number {
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.direction === 'outbound' &&
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
+ })