@adatechnology/conversations-ui 0.5.0 → 0.6.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 (50) hide show
  1. package/README.md +82 -20
  2. package/dist/{chunk-JXJCPN34.js → chunk-FAQLC3TX.js} +1 -1
  3. package/dist/{chunk-RJMSM3YA.js → chunk-O6457XHK.js} +17 -11
  4. package/dist/chunk-OVFCLRQQ.js +84 -0
  5. package/dist/index.d.ts +14 -4
  6. package/dist/index.js +3 -3
  7. package/dist/participant/index.d.ts +25 -1
  8. package/dist/participant/index.js +454 -194
  9. package/dist/preview/index.js +2 -2
  10. package/dist/styles.css +98 -0
  11. package/package.json +2 -2
  12. package/src/MessageBubble.test.tsx +2 -2
  13. package/src/MessageText.test.tsx +27 -10
  14. package/src/MessageText.tsx +25 -17
  15. package/src/MessageTextCopiedBadge.tsx +10 -2
  16. package/src/StatusTicks.test.tsx +27 -6
  17. package/src/StatusTicks.tsx +30 -15
  18. package/src/legacyChannels.golden.test.ts +89 -0
  19. package/src/legacyDefaults.golden.test.tsx +127 -0
  20. package/src/legacyStyles.fixture.css +1121 -0
  21. package/src/legacyStyles.golden.test.ts +121 -0
  22. package/src/messageTextCopyInteraction.test.ts +75 -0
  23. package/src/messageTextCopyInteraction.ts +37 -0
  24. package/src/participant/ParticipantChannelBadges.test.tsx +59 -0
  25. package/src/participant/ParticipantChannelBadges.tsx +75 -0
  26. package/src/participant/ParticipantConversations.tsx +13 -6
  27. package/src/participant/ParticipantConversationsScreen.tsx +18 -7
  28. package/src/participant/ParticipantInbox.test.tsx +99 -2
  29. package/src/participant/ParticipantInbox.tsx +27 -46
  30. package/src/participant/ParticipantInboxRow.tsx +75 -0
  31. package/src/participant/ParticipantInboxSearchField.tsx +34 -0
  32. package/src/participant/ParticipantMessageBubble.test.tsx +2 -0
  33. package/src/participant/ParticipantMessageBubble.tsx +3 -3
  34. package/src/participant/ParticipantProtocolBadge.tsx +74 -0
  35. package/src/participant/ParticipantProtocolView.test.tsx +208 -0
  36. package/src/participant/ParticipantThread.test.tsx +19 -2
  37. package/src/participant/ParticipantThread.tsx +25 -15
  38. package/src/participant/index.ts +1 -0
  39. package/src/participant/participant.types.ts +9 -0
  40. package/src/participant/participantChannels.test.ts +42 -0
  41. package/src/participant/participantChannels.ts +33 -0
  42. package/src/participant/participantGrouping.test.ts +101 -3
  43. package/src/participant/participantGrouping.ts +20 -7
  44. package/src/participant/participantLabels.ts +29 -0
  45. package/src/participant/participantProtocol.test.ts +104 -0
  46. package/src/participant/participantProtocol.ts +52 -0
  47. package/src/participant/participantProtocolCopy.test.ts +45 -0
  48. package/src/participant/participantProtocolCopy.ts +32 -0
  49. package/src/styles.css +19 -1
  50. package/dist/chunk-WSFGNR5R.js +0 -55
@@ -0,0 +1,121 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { describe, expect, it } from 'bun:test'
4
+
5
+ // legacyStyles.fixture.css is the styles.css shipped by 0.4.2; the stylesheet is global, so anything
6
+ // added since must be namespaced under .cv- and must not reach the host's own elements.
7
+ const LEGACY_CSS = readFileSync(join(import.meta.dir, 'legacyStyles.fixture.css'), 'utf8')
8
+ const CURRENT_CSS = readFileSync(join(import.meta.dir, 'styles.css'), 'utf8')
9
+
10
+ const NESTED_AT_RULES = ['@media', '@supports']
11
+ const ALLOWED_NEW_AT_RULES = ['@media', '@supports', '@keyframes']
12
+
13
+ type Block = { readonly prelude: string; readonly body: string | undefined }
14
+ type Inventory = { selectors: string[]; atRules: string[]; keyframeNames: string[] }
15
+
16
+ function splitBlocks(css: string): Block[] {
17
+ const blocks: Block[] = []
18
+ let depth = 0
19
+ let start = 0
20
+ let bodyStart = 0
21
+ let prelude = ''
22
+ for (let index = 0; index < css.length; index += 1) {
23
+ const char = css[index]
24
+ if (depth === 0 && char === ';') {
25
+ blocks.push({ prelude: css.slice(start, index).trim(), body: undefined })
26
+ start = index + 1
27
+ }
28
+ if (char === '{') {
29
+ if (depth === 0) {
30
+ prelude = css.slice(start, index).trim()
31
+ bodyStart = index + 1
32
+ }
33
+ depth += 1
34
+ }
35
+ if (char === '}') {
36
+ depth -= 1
37
+ if (depth === 0) {
38
+ blocks.push({ prelude, body: css.slice(bodyStart, index) })
39
+ start = index + 1
40
+ }
41
+ }
42
+ }
43
+ return blocks
44
+ }
45
+
46
+ function splitSelectorList(prelude: string): string[] {
47
+ const parts: string[] = []
48
+ let parenthesisDepth = 0
49
+ let current = ''
50
+ for (const char of prelude) {
51
+ if (char === '(') parenthesisDepth += 1
52
+ if (char === ')') parenthesisDepth -= 1
53
+ if (char === ',' && parenthesisDepth === 0) {
54
+ parts.push(current.trim())
55
+ current = ''
56
+ } else {
57
+ current += char
58
+ }
59
+ }
60
+ parts.push(current.trim())
61
+ return parts.filter(Boolean)
62
+ }
63
+
64
+ function collect(css: string, inventory: Inventory): Inventory {
65
+ for (const { prelude, body } of splitBlocks(css.replace(/\/\*[\s\S]*?\*\//g, ''))) {
66
+ if (!prelude.startsWith('@')) {
67
+ inventory.selectors.push(...splitSelectorList(prelude))
68
+ continue
69
+ }
70
+ const atRuleName = prelude.split(/[\s(]/)[0] ?? prelude
71
+ inventory.atRules.push(atRuleName)
72
+ if (atRuleName === '@keyframes') inventory.keyframeNames.push(prelude.replace('@keyframes', '').trim())
73
+ if (NESTED_AT_RULES.includes(atRuleName) && body !== undefined) collect(body, inventory)
74
+ }
75
+ return inventory
76
+ }
77
+
78
+ function inventoryOf(css: string): Inventory {
79
+ return collect(css, { selectors: [], atRules: [], keyframeNames: [] })
80
+ }
81
+
82
+ function addedSince(legacy: readonly string[], current: readonly string[]): string[] {
83
+ const remaining = [...legacy]
84
+ return current.filter((entry) => {
85
+ const position = remaining.indexOf(entry)
86
+ if (position === -1) return true
87
+ remaining.splice(position, 1)
88
+ return false
89
+ })
90
+ }
91
+
92
+ describe('styles.css não vaza para o host desde a 0.4.2', () => {
93
+ const legacy = inventoryOf(LEGACY_CSS)
94
+ const current = inventoryOf(CURRENT_CSS)
95
+ const addedSelectors = addedSince(legacy.selectors, current.selectors)
96
+ const addedAtRules = addedSince(legacy.atRules, current.atRules)
97
+
98
+ it('G8: o parse enxerga as regras (guarda contra um parser que não acha nada)', () => {
99
+ expect(legacy.selectors.length).toBeGreaterThan(100)
100
+ expect(addedSelectors.length).toBeGreaterThan(20)
101
+ })
102
+
103
+ it('G8: todo seletor adicionado começa por .cv- ou .dark .cv-', () => {
104
+ const offenders = addedSelectors.filter((selector) => !/^(\.dark )?\.cv-/.test(selector))
105
+
106
+ expect(offenders).toEqual([])
107
+ })
108
+
109
+ it('G8: nenhum seletor global (:root, html, body, *) foi adicionado', () => {
110
+ const globals = addedSelectors.filter((selector) => /^(:root|html|body|\*)([\s.:#[>+~,]|$)/.test(selector))
111
+
112
+ expect(globals).toEqual([])
113
+ })
114
+
115
+ it('G8: nenhum @import, @layer ou outra at-rule nova além de @media, @supports e @keyframes', () => {
116
+ expect(addedAtRules.filter((rule) => !ALLOWED_NEW_AT_RULES.includes(rule))).toEqual([])
117
+ expect(addedSince(legacy.keyframeNames, current.keyframeNames).filter((name) => !name.startsWith('cv-'))).toEqual(
118
+ [],
119
+ )
120
+ })
121
+ })
@@ -0,0 +1,75 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+
3
+ import { resolveCopyInteraction } from './messageTextCopyInteraction'
4
+
5
+ function buildKeyEvent(key: string): { key: string; preventDefault: () => void; wasPrevented: () => boolean } {
6
+ let prevented = false
7
+ return {
8
+ key,
9
+ preventDefault: () => {
10
+ prevented = true
11
+ },
12
+ wasPrevented: () => prevented,
13
+ }
14
+ }
15
+
16
+ describe('resolveCopyInteraction', () => {
17
+ it('por padrão devolve só o onClick de cópia', () => {
18
+ const onCopy = () => undefined
19
+ const props = resolveCopyInteraction({ copyOnClick: true, accessibleCopy: false, onCopy })
20
+
21
+ expect(props.onClick).toBe(onCopy)
22
+ expect('role' in props).toBe(false)
23
+ expect('tabIndex' in props).toBe(false)
24
+ expect('onKeyDown' in props).toBe(false)
25
+ })
26
+
27
+ it('com copyOnClick desligado devolve objeto vazio, mesmo com accessibleCopy', () => {
28
+ expect(resolveCopyInteraction({ copyOnClick: false, accessibleCopy: false, onCopy: () => undefined })).toEqual({})
29
+ expect(resolveCopyInteraction({ copyOnClick: false, accessibleCopy: true, onCopy: () => undefined })).toEqual({})
30
+ })
31
+
32
+ it('com accessibleCopy expõe role, tabIndex e o onClick', () => {
33
+ const onCopy = () => undefined
34
+ const props = resolveCopyInteraction({ copyOnClick: true, accessibleCopy: true, onCopy })
35
+
36
+ expect(props.role).toBe('button')
37
+ expect(props.tabIndex).toBe(0)
38
+ expect(props.onClick).toBe(onCopy)
39
+ })
40
+
41
+ it('Enter e Espaço copiam e previnem o default', () => {
42
+ let copies = 0
43
+ const props = resolveCopyInteraction({
44
+ copyOnClick: true,
45
+ accessibleCopy: true,
46
+ onCopy: () => {
47
+ copies += 1
48
+ },
49
+ })
50
+
51
+ for (const key of ['Enter', ' ']) {
52
+ const event = buildKeyEvent(key)
53
+ props.onKeyDown?.(event)
54
+ expect(event.wasPrevented()).toBe(true)
55
+ }
56
+ expect(copies).toBe(2)
57
+ })
58
+
59
+ it('outras teclas não copiam nem previnem o default', () => {
60
+ let copies = 0
61
+ const props = resolveCopyInteraction({
62
+ copyOnClick: true,
63
+ accessibleCopy: true,
64
+ onCopy: () => {
65
+ copies += 1
66
+ },
67
+ })
68
+ const event = buildKeyEvent('Tab')
69
+
70
+ props.onKeyDown?.(event)
71
+
72
+ expect(copies).toBe(0)
73
+ expect(event.wasPrevented()).toBe(false)
74
+ })
75
+ })
@@ -0,0 +1,37 @@
1
+ export type CopyKeyboardEvent = {
2
+ readonly key: string
3
+ preventDefault: () => void
4
+ }
5
+
6
+ export type CopyInteractionProps = {
7
+ onClick?: () => void
8
+ role?: 'button'
9
+ tabIndex?: 0
10
+ onKeyDown?: (event: CopyKeyboardEvent) => void
11
+ }
12
+
13
+ export type ResolveCopyInteractionOptions = {
14
+ readonly copyOnClick: boolean
15
+ readonly accessibleCopy: boolean
16
+ readonly onCopy: () => void
17
+ }
18
+
19
+ export function resolveCopyInteraction({
20
+ copyOnClick,
21
+ accessibleCopy,
22
+ onCopy,
23
+ }: ResolveCopyInteractionOptions): CopyInteractionProps {
24
+ if (!copyOnClick) return {}
25
+ if (!accessibleCopy) return { onClick: onCopy }
26
+
27
+ return {
28
+ onClick: onCopy,
29
+ role: 'button',
30
+ tabIndex: 0,
31
+ onKeyDown: (event) => {
32
+ if (event.key !== 'Enter' && event.key !== ' ') return
33
+ event.preventDefault()
34
+ onCopy()
35
+ },
36
+ }
37
+ }
@@ -0,0 +1,59 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import { renderToStaticMarkup } from 'react-dom/server'
3
+
4
+ import { ParticipantChannelBadges } from './ParticipantChannelBadges'
5
+ import { findUtilityClassTokens } from './participantFixtures.test-helper'
6
+ import { DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS } from './participantLabels'
7
+
8
+ const render = (channels: readonly string[] | undefined, variant?: 'list' | 'inline') =>
9
+ renderToStaticMarkup(
10
+ <ParticipantChannelBadges
11
+ channels={channels}
12
+ labels={DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS}
13
+ {...(variant === undefined ? {} : { variant })}
14
+ />,
15
+ )
16
+
17
+ describe('ParticipantChannelBadges', () => {
18
+ it('draws nothing without channels, not even the list', () => {
19
+ expect(render(undefined)).toBe('')
20
+ expect(render([])).toBe('')
21
+ expect(render(['sms'])).toBe('')
22
+ })
23
+
24
+ it('draws one badge per channel in a stable order with accessible text', () => {
25
+ const markup = render(['whatsapp', 'app'])
26
+
27
+ expect(markup).toContain('<ul class="cv-p-channels"')
28
+ expect(markup).toContain('aria-label="Channels in this conversation"')
29
+ expect(markup.indexOf('cv-p-channel--app')).toBeLessThan(markup.indexOf('cv-p-channel--whatsapp'))
30
+ expect(markup).toContain('<span class="cv-p-sr-only">App</span>')
31
+ expect(markup).toContain('<span class="cv-p-sr-only">WhatsApp</span>')
32
+ })
33
+
34
+ it('hides the icons from assistive tech and uses no utility classes', () => {
35
+ const markup = render(['app', 'whatsapp', 'email', 'portal', 'webchat'])
36
+
37
+ expect(markup.match(/<svg[^>]*aria-hidden="true"/g)).toHaveLength(5)
38
+ expect(findUtilityClassTokens(markup)).toEqual([])
39
+ })
40
+
41
+ it('inline variant draws spans only, with a group prefix and audible separators', () => {
42
+ const markup = render(['whatsapp', 'app', 'app'], 'inline')
43
+
44
+ expect(markup).not.toContain('<ul')
45
+ expect(markup).not.toContain('<li')
46
+ expect(markup).toContain('<span class="cv-p-channels">')
47
+ expect(markup).toContain('<span class="cv-p-sr-only">Channels in this conversation: </span>')
48
+ expect(markup.match(/class="cv-p-channel cv-p-channel--/g)).toHaveLength(2)
49
+ expect(markup.indexOf('cv-p-channel--app')).toBeLessThan(markup.indexOf('cv-p-channel--whatsapp'))
50
+ expect(markup).toContain('<span class="cv-p-sr-only">App, </span>')
51
+ expect(markup).toContain('<span class="cv-p-sr-only">WhatsApp</span>')
52
+ expect(markup.match(/<svg[^>]*aria-hidden="true"/g)).toHaveLength(2)
53
+ })
54
+
55
+ it('inline variant draws nothing without valid channels', () => {
56
+ expect(render(undefined, 'inline')).toBe('')
57
+ expect(render(['sms'], 'inline')).toBe('')
58
+ })
59
+ })
@@ -0,0 +1,75 @@
1
+ import { Globe, Mail, MessageCircle, MessagesSquare, Smartphone } from 'lucide-react'
2
+ import type { ComponentType } from 'react'
3
+
4
+ import {
5
+ resolveParticipantChannelLabel,
6
+ resolveParticipantChannels,
7
+ type ParticipantChannelDescriptor,
8
+ } from './participantChannels'
9
+ import type { ParticipantConversationsLabels } from './participantLabels'
10
+
11
+ const ICONS: Record<
12
+ ParticipantChannelDescriptor['iconName'],
13
+ ComponentType<{ size?: number; 'aria-hidden'?: boolean }>
14
+ > = {
15
+ Smartphone,
16
+ MessageCircle,
17
+ Mail,
18
+ Globe,
19
+ MessagesSquare,
20
+ }
21
+
22
+ export type ParticipantChannelBadgesProps = {
23
+ readonly channels: readonly string[] | undefined
24
+ readonly labels: ParticipantConversationsLabels
25
+ /** 'list' draws ul/li; 'inline' draws spans only, valid inside a button. */
26
+ readonly variant?: 'list' | 'inline'
27
+ }
28
+
29
+ type BadgeProps = {
30
+ readonly descriptor: ParticipantChannelDescriptor
31
+ readonly labels: ParticipantConversationsLabels
32
+ readonly separator: string
33
+ }
34
+
35
+ function BadgeContent({ descriptor, labels, separator }: BadgeProps) {
36
+ const Icon = ICONS[descriptor.iconName]
37
+ return (
38
+ <>
39
+ <Icon size={14} aria-hidden={true} />
40
+ <span className="cv-p-sr-only">{`${resolveParticipantChannelLabel(descriptor, labels)}${separator}`}</span>
41
+ </>
42
+ )
43
+ }
44
+
45
+ export function ParticipantChannelBadges({ channels, labels, variant = 'list' }: ParticipantChannelBadgesProps) {
46
+ const descriptors = resolveParticipantChannels(channels)
47
+ if (descriptors.length === 0) return null
48
+
49
+ if (variant === 'inline') {
50
+ return (
51
+ <span className="cv-p-channels">
52
+ <span className="cv-p-sr-only">{`${labels.channelsGroup}: `}</span>
53
+ {descriptors.map((descriptor, index) => (
54
+ <span key={descriptor.channel} className={`cv-p-channel cv-p-channel--${descriptor.channel}`}>
55
+ <BadgeContent
56
+ descriptor={descriptor}
57
+ labels={labels}
58
+ separator={index < descriptors.length - 1 ? ', ' : ''}
59
+ />
60
+ </span>
61
+ ))}
62
+ </span>
63
+ )
64
+ }
65
+
66
+ return (
67
+ <ul className="cv-p-channels" aria-label={labels.channelsGroup}>
68
+ {descriptors.map((descriptor) => (
69
+ <li key={descriptor.channel} className={`cv-p-channel cv-p-channel--${descriptor.channel}`}>
70
+ <BadgeContent descriptor={descriptor} labels={labels} separator="" />
71
+ </li>
72
+ ))}
73
+ </ul>
74
+ )
75
+ }
@@ -1,15 +1,16 @@
1
1
  import type { ReactNode } from 'react'
2
2
 
3
- import type {
4
- ParticipantConversationSummary,
5
- ParticipantSubjectRef,
6
- } from '@adatechnology/conversation-contracts'
3
+ import type { ParticipantConversationSummary, ParticipantSubjectRef } from '@adatechnology/conversation-contracts'
7
4
 
8
5
  import type { ConversationChannel } from '../conversationChannel'
9
6
  import type { QuickReply } from '../quickReplies/quickReply.types'
10
7
  import type { ConversationsTheme } from '../types'
11
8
  import { ParticipantConversationsScreen } from './ParticipantConversationsScreen'
12
- import type { ParticipantConversationsClassNames, ParticipantSubjectGroup } from './participant.types'
9
+ import type {
10
+ ParticipantConversationsClassNames,
11
+ ParticipantSubjectGroup,
12
+ ParticipantSubjectIconRenderer,
13
+ } from './participant.types'
13
14
  import type { ParticipantConversationsApi, ParticipantPendingMessage } from './participantApi.types'
14
15
  import type { ParticipantConversationsLabels } from './participantLabels'
15
16
  import { useParticipantInbox } from './useParticipantInbox'
@@ -30,6 +31,7 @@ export type ParticipantConversationsProps = {
30
31
  pendingMessages?: readonly ParticipantPendingMessage[]
31
32
  onRetryPending?: (clientMessageId: string) => void
32
33
  quickReplies?: readonly QuickReply[]
34
+ renderSubjectIcon?: ParticipantSubjectIconRenderer
33
35
  renderSubjectCard?: (conversation: ParticipantConversationSummary) => ReactNode
34
36
  onOpenSubject?: (subject: ParticipantSubjectRef) => void
35
37
  }
@@ -38,5 +40,10 @@ export function ParticipantConversations(props: ParticipantConversationsProps) {
38
40
  const inbox = useParticipantInbox(props.api)
39
41
  const { status, conversations, hasMore, refresh, loadMore, markSubjectRead } = inbox
40
42
 
41
- return <ParticipantConversationsScreen {...props} inbox={{ status, conversations, hasMore, refresh, loadMore, markSubjectRead }} />
43
+ return (
44
+ <ParticipantConversationsScreen
45
+ {...props}
46
+ inbox={{ status, conversations, hasMore, refresh, loadMore, markSubjectRead }}
47
+ />
48
+ )
42
49
  }
@@ -6,12 +6,16 @@ import { ParticipantThreadScreen } from './ParticipantThreadScreen'
6
6
  import { draftKey, type ParticipantDrafts } from './participantDrafts'
7
7
  import { groupParticipantConversations } from './participantGrouping'
8
8
  import { DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS } from './participantLabels'
9
+ import { isInboxSearchVisible } from './participantProtocol'
9
10
  import { participantSendStatesReducer } from './participantSendStates'
10
11
  import { buildParticipantThemeStyle } from './participantTheme'
11
12
  import type { UseParticipantInboxResult } from './useParticipantInbox'
12
13
 
13
14
  export type ParticipantConversationsScreenProps = ParticipantConversationsProps & {
14
- readonly inbox: Pick<UseParticipantInboxResult, 'status' | 'conversations' | 'hasMore' | 'refresh' | 'loadMore' | 'markSubjectRead'>
15
+ readonly inbox: Pick<
16
+ UseParticipantInboxResult,
17
+ 'status' | 'conversations' | 'hasMore' | 'refresh' | 'loadMore' | 'markSubjectRead'
18
+ >
15
19
  }
16
20
 
17
21
  const ALL_FILTER = 'all'
@@ -19,16 +23,21 @@ const ALL_FILTER = 'all'
19
23
  export function ParticipantConversationsScreen(props: ParticipantConversationsScreenProps) {
20
24
  const { selected, inbox, subjectGroups, classNames } = props
21
25
  const [filter, setFilter] = useState(ALL_FILTER)
26
+ const [searchQuery, setSearchQuery] = useState('')
22
27
  const draftsRef = useRef<ParticipantDrafts>(new Map())
23
28
  const [sendStates, dispatchSendStates] = useReducer(participantSendStatesReducer, new Map())
24
- const labels = useMemo(
25
- () => ({ ...DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS, ...props.labels }),
26
- [props.labels],
27
- )
29
+ const labels = useMemo(() => ({ ...DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS, ...props.labels }), [props.labels])
28
30
  const view = useMemo(
29
- () => groupParticipantConversations({ conversations: inbox.conversations, subjectGroups, filter }),
30
- [inbox.conversations, subjectGroups, filter],
31
+ () =>
32
+ groupParticipantConversations({
33
+ conversations: inbox.conversations,
34
+ subjectGroups,
35
+ filter,
36
+ searchQuery,
37
+ }),
38
+ [inbox.conversations, subjectGroups, filter, searchQuery],
31
39
  )
40
+ const isSearchVisible = isInboxSearchVisible({ conversations: inbox.conversations, searchQuery })
32
41
  const rootClassName = ['cv-p-root', props.className, classNames?.root].filter(Boolean).join(' ')
33
42
 
34
43
  return (
@@ -47,6 +56,8 @@ export function ParticipantConversationsScreen(props: ParticipantConversationsSc
47
56
  hasMore={inbox.hasMore}
48
57
  loadMore={() => void inbox.loadMore()}
49
58
  locale={props.locale}
59
+ renderSubjectIcon={props.renderSubjectIcon}
60
+ search={{ value: searchQuery, onChange: setSearchQuery, isVisible: isSearchVisible }}
50
61
  />
51
62
  </div>
52
63
  ) : (
@@ -5,7 +5,7 @@ import { buildConversation, findForeignClassTokens, findUtilityClassTokens } fro
5
5
  import { groupParticipantConversations } from './participantGrouping'
6
6
  import { DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS } from './participantLabels'
7
7
  import type { ParticipantSubjectGroup } from './participant.types'
8
- import { ParticipantInbox } from './ParticipantInbox'
8
+ import { ParticipantInbox, type ParticipantInboxProps } from './ParticipantInbox'
9
9
 
10
10
  const GROUPS: readonly ParticipantSubjectGroup[] = [
11
11
  { subjectType: 'invoice', label: 'Invoice' },
@@ -13,7 +13,13 @@ const GROUPS: readonly ParticipantSubjectGroup[] = [
13
13
  ]
14
14
 
15
15
  const CONVERSATIONS = [
16
- buildConversation({ subjectId: '1', subjectLabel: 'Awaiting one', awaitingParticipant: true, unreadCount: 2, lastMessagePreview: 'Please send the photo' }),
16
+ buildConversation({
17
+ subjectId: '1',
18
+ subjectLabel: 'Awaiting one',
19
+ awaitingParticipant: true,
20
+ unreadCount: 2,
21
+ lastMessagePreview: 'Please send the photo',
22
+ }),
17
23
  buildConversation({ subjectId: '2', subjectLabel: 'Invoice two' }),
18
24
  buildConversation({ subjectId: '3', subjectType: 'incident', subjectLabel: 'Incident three' }),
19
25
  buildConversation({ subjectId: '4', subjectLabel: 'Old one', status: 'closed' }),
@@ -24,6 +30,7 @@ type RenderOverrides = {
24
30
  filter?: string
25
31
  status?: 'idle' | 'loading' | 'ready' | 'error'
26
32
  hasMore?: boolean
33
+ renderSubjectIcon?: ParticipantInboxProps['renderSubjectIcon']
27
34
  }
28
35
 
29
36
  function render(overrides: RenderOverrides = {}): string {
@@ -44,10 +51,100 @@ function render(overrides: RenderOverrides = {}): string {
44
51
  refresh={() => undefined}
45
52
  hasMore={overrides.hasMore ?? false}
46
53
  loadMore={() => undefined}
54
+ renderSubjectIcon={overrides.renderSubjectIcon}
47
55
  />,
48
56
  )
49
57
  }
50
58
 
59
+ describe('ParticipantInbox channels and subject icon', () => {
60
+ const withChannels = [
61
+ buildConversation({ subjectId: '9', subjectLabel: 'Chatty', channels: ['whatsapp', 'app'], iconName: 'file-text' }),
62
+ ]
63
+
64
+ it('shows channel badges only when the conversation has channels', () => {
65
+ expect(render({ conversations: withChannels })).toContain('cv-p-channel--app')
66
+ expect(render()).not.toContain('cv-p-channels')
67
+ })
68
+
69
+ it('keeps the badge order stable and the accessible text present', () => {
70
+ const markup = render({ conversations: withChannels })
71
+
72
+ expect(markup.indexOf('cv-p-channel--app')).toBeLessThan(markup.indexOf('cv-p-channel--whatsapp'))
73
+ expect(markup).toContain('<span class="cv-p-sr-only">WhatsApp</span>')
74
+ })
75
+
76
+ it('row badges are phrasing content: no list inside the row button, header keeps its list', () => {
77
+ const markup = render({ conversations: withChannels })
78
+ const button = markup.slice(markup.indexOf('<button'), markup.indexOf('</button>'))
79
+
80
+ expect(button).toContain('cv-p-channels')
81
+ expect(button).not.toContain('<ul')
82
+ expect(button).not.toContain('<li')
83
+ expect(button).toContain('Channels in this conversation: ')
84
+ })
85
+
86
+ it('treats false and empty string from renderSubjectIcon as absent', () => {
87
+ const groups = [{ subjectType: 'invoice', label: 'Invoice', icon: 'GRP' }]
88
+ const view = groupParticipantConversations({ conversations: withChannels, subjectGroups: groups })
89
+ const renderWith = (renderSubjectIcon: ParticipantInboxProps['renderSubjectIcon']) =>
90
+ renderToStaticMarkup(
91
+ <ParticipantInbox
92
+ view={view}
93
+ subjectGroups={groups}
94
+ filter="all"
95
+ onFilterChange={() => undefined}
96
+ onSelect={() => undefined}
97
+ labels={DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS}
98
+ status="ready"
99
+ refresh={() => undefined}
100
+ hasMore={false}
101
+ loadMore={() => undefined}
102
+ renderSubjectIcon={renderSubjectIcon}
103
+ />,
104
+ )
105
+
106
+ expect(renderWith(() => false)).toContain('GRP')
107
+ expect(renderWith(() => '')).toContain('GRP')
108
+ expect(renderWith(() => <i data-custom="1" />)).not.toContain('GRP')
109
+ })
110
+
111
+ it('uses renderSubjectIcon when it returns a node', () => {
112
+ const markup = render({
113
+ renderSubjectIcon: (conversation) => <i data-icon={conversation.iconName ?? 'none'} />,
114
+ conversations: withChannels,
115
+ })
116
+
117
+ expect(markup).toContain('cv-p-inbox__icon')
118
+ expect(markup).toContain('data-icon="file-text"')
119
+ })
120
+
121
+ it('falls back to the group icon when renderSubjectIcon returns null or undefined', () => {
122
+ const groups = [{ subjectType: 'invoice', label: 'Invoice', icon: 'GRP' }]
123
+ const view = groupParticipantConversations({ conversations: withChannels, subjectGroups: groups })
124
+ const renderWith = (renderSubjectIcon: ParticipantInboxProps['renderSubjectIcon']) =>
125
+ renderToStaticMarkup(
126
+ <ParticipantInbox
127
+ view={view}
128
+ subjectGroups={groups}
129
+ filter="all"
130
+ onFilterChange={() => undefined}
131
+ onSelect={() => undefined}
132
+ labels={DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS}
133
+ status="ready"
134
+ refresh={() => undefined}
135
+ hasMore={false}
136
+ loadMore={() => undefined}
137
+ renderSubjectIcon={renderSubjectIcon}
138
+ />,
139
+ )
140
+
141
+ expect(renderWith(() => null)).toContain('GRP')
142
+ expect(renderWith(() => undefined)).toContain('GRP')
143
+ expect(renderWith(undefined)).toContain('GRP')
144
+ expect(renderWith(() => <i data-custom="1" />)).not.toContain('GRP')
145
+ })
146
+ })
147
+
51
148
  describe('ParticipantInbox', () => {
52
149
  it('renders section headers with label and count, awaiting highlighted', () => {
53
150
  const markup = render()