@adatechnology/conversations-ui 0.4.1 → 0.5.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 (93) hide show
  1. package/README.md +169 -0
  2. package/dist/{ConversationSimulatorPanel-DnXElfY8.d.ts → ConversationSimulatorPanel-CrfFjiWh.d.ts} +2 -268
  3. package/dist/{chunk-PJNR6UMN.js → chunk-CBYEHPX6.js} +1 -63
  4. package/dist/{chunk-HMCOTZAX.js → chunk-HOKNKXIT.js} +1 -1
  5. package/dist/{chunk-4P5KMWOI.js → chunk-JXJCPN34.js} +327 -522
  6. package/dist/chunk-MRVFFJEH.js +64 -0
  7. package/dist/chunk-RJMSM3YA.js +249 -0
  8. package/dist/chunk-WSFGNR5R.js +55 -0
  9. package/dist/flows/index.js +5 -3
  10. package/dist/index.d.ts +9 -3
  11. package/dist/index.js +783 -807
  12. package/dist/participant/index.d.ts +162 -0
  13. package/dist/participant/index.js +1448 -0
  14. package/dist/preview/index.d.ts +4 -2
  15. package/dist/preview/index.js +4 -2
  16. package/dist/quickReply.types-B4gmZsSr.d.ts +274 -0
  17. package/dist/styles.css +591 -0
  18. package/package.json +9 -5
  19. package/src/ChannelIcon.tsx +2 -0
  20. package/src/MessageBubble.test.tsx +2 -2
  21. package/src/MessageText.test.tsx +59 -0
  22. package/src/MessageText.tsx +20 -12
  23. package/src/MessageTextCopiedBadge.tsx +7 -0
  24. package/src/StatusTicks.test.tsx +30 -0
  25. package/src/StatusTicks.tsx +24 -9
  26. package/src/buildOutput.test.ts +41 -0
  27. package/src/channelCapability.test.ts +17 -0
  28. package/src/conversationChannel.test.ts +30 -1
  29. package/src/conversationChannel.ts +19 -1
  30. package/src/participant/ParticipantAttachmentItem.tsx +77 -0
  31. package/src/participant/ParticipantComposer.test.tsx +97 -0
  32. package/src/participant/ParticipantComposer.tsx +167 -0
  33. package/src/participant/ParticipantConversations.test.tsx +90 -0
  34. package/src/participant/ParticipantConversations.tsx +42 -0
  35. package/src/participant/ParticipantConversationsScreen.tsx +67 -0
  36. package/src/participant/ParticipantInbox.test.tsx +147 -0
  37. package/src/participant/ParticipantInbox.tsx +168 -0
  38. package/src/participant/ParticipantLoadState.tsx +27 -0
  39. package/src/participant/ParticipantMessageBubble.test.tsx +165 -0
  40. package/src/participant/ParticipantMessageBubble.tsx +185 -0
  41. package/src/participant/ParticipantThread.test.tsx +132 -0
  42. package/src/participant/ParticipantThread.tsx +174 -0
  43. package/src/participant/ParticipantThreadScreen.tsx +135 -0
  44. package/src/participant/bindAttachmentUrlResolver.test.ts +25 -0
  45. package/src/participant/bindAttachmentUrlResolver.ts +7 -0
  46. package/src/participant/index.ts +25 -0
  47. package/src/participant/participant.types.ts +43 -0
  48. package/src/participant/participantApi.types.ts +59 -0
  49. package/src/participant/participantAttachmentUrl.test.ts +32 -0
  50. package/src/participant/participantAttachmentUrl.ts +10 -0
  51. package/src/participant/participantBubbleActions.test.ts +44 -0
  52. package/src/participant/participantBubbleActions.ts +40 -0
  53. package/src/participant/participantDrafts.test.ts +65 -0
  54. package/src/participant/participantDrafts.ts +50 -0
  55. package/src/participant/participantFileValidation.test.ts +86 -0
  56. package/src/participant/participantFileValidation.ts +72 -0
  57. package/src/participant/participantFixtures.test-helper.ts +41 -0
  58. package/src/participant/participantGrouping.test.ts +138 -0
  59. package/src/participant/participantGrouping.ts +77 -0
  60. package/src/participant/participantInboxState.test.ts +69 -0
  61. package/src/participant/participantInboxState.ts +74 -0
  62. package/src/participant/participantLabels.ts +86 -0
  63. package/src/participant/participantLoadView.test.ts +23 -0
  64. package/src/participant/participantLoadView.ts +21 -0
  65. package/src/participant/participantMessages.test.ts +128 -0
  66. package/src/participant/participantMessages.ts +73 -0
  67. package/src/participant/participantNewMessages.test.ts +54 -0
  68. package/src/participant/participantNewMessages.ts +29 -0
  69. package/src/participant/participantQuickReply.test.ts +23 -0
  70. package/src/participant/participantQuickReply.ts +6 -0
  71. package/src/participant/participantScroll.test.ts +69 -0
  72. package/src/participant/participantScroll.ts +43 -0
  73. package/src/participant/participantSendController.test.ts +201 -0
  74. package/src/participant/participantSendController.ts +134 -0
  75. package/src/participant/participantSendStates.ts +31 -0
  76. package/src/participant/participantTheme.ts +20 -0
  77. package/src/participant/participantThreadState.test.ts +75 -0
  78. package/src/participant/participantThreadState.ts +59 -0
  79. package/src/participant/shouldMarkRead.test.ts +24 -0
  80. package/src/participant/shouldMarkRead.ts +16 -0
  81. package/src/participant/useDocumentVisibility.ts +18 -0
  82. package/src/participant/useParticipantConversationView.ts +60 -0
  83. package/src/participant/useParticipantDraft.ts +45 -0
  84. package/src/participant/useParticipantInbox.ts +57 -0
  85. package/src/participant/useParticipantMarkRead.ts +26 -0
  86. package/src/participant/useParticipantRevalidation.ts +36 -0
  87. package/src/participant/useParticipantSend.ts +84 -0
  88. package/src/participant/useParticipantThread.ts +116 -0
  89. package/src/participant/useParticipantThreadScroll.ts +26 -0
  90. package/src/participant/useStickToBottom.ts +77 -0
  91. package/src/preview/ConversationSimulatorPanel.tsx +8 -0
  92. package/src/styles.css +160 -0
  93. package/src/types.ts +3 -1
@@ -0,0 +1,90 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import { renderToStaticMarkup } from 'react-dom/server'
3
+
4
+ import { ParticipantConversations } from './ParticipantConversations'
5
+ import { ParticipantConversationsScreen, type ParticipantConversationsScreenProps } from './ParticipantConversationsScreen'
6
+ import type { ParticipantConversationsApi } from './participantApi.types'
7
+ import { buildConversation, findForeignClassTokens, findUtilityClassTokens } from './participantFixtures.test-helper'
8
+
9
+ const API: ParticipantConversationsApi = {
10
+ listConversations: async () => ({ data: [] }),
11
+ fetchMessages: async () => [],
12
+ sendMessage: async () => ({ outcome: 'queued' }),
13
+ markRead: async () => undefined,
14
+ resolveAttachmentUrl: async () => 'https://files.example/a',
15
+ }
16
+
17
+ const GROUPS = [{ subjectType: 'invoice', label: 'Invoices' }]
18
+
19
+ function renderScreen(overrides: Partial<ParticipantConversationsScreenProps> = {}): string {
20
+ return renderToStaticMarkup(
21
+ <ParticipantConversationsScreen
22
+ api={API}
23
+ subjectGroups={GROUPS}
24
+ selected={undefined}
25
+ onSelect={() => undefined}
26
+ inbox={{
27
+ status: 'ready',
28
+ conversations: [buildConversation({ subjectId: '1', subjectLabel: 'Invoice 4521' })],
29
+ hasMore: false,
30
+ refresh: async () => undefined,
31
+ loadMore: async () => undefined,
32
+ markSubjectRead: () => undefined,
33
+ }}
34
+ {...overrides}
35
+ />,
36
+ )
37
+ }
38
+
39
+ describe('ParticipantConversations', () => {
40
+ it('renders the list without any ConversationsProvider around it', () => {
41
+ const markup = renderToStaticMarkup(
42
+ <ParticipantConversations api={API} subjectGroups={GROUPS} selected={undefined} onSelect={() => undefined} />,
43
+ )
44
+
45
+ expect(markup).toContain('cv-p-inbox')
46
+ expect(findUtilityClassTokens(markup)).toEqual([])
47
+ expect(findForeignClassTokens(markup)).toEqual([])
48
+ })
49
+
50
+ it('renders the inbox rows when selected is undefined', () => {
51
+ const markup = renderScreen()
52
+
53
+ expect(markup).toContain('cv-p-inbox')
54
+ expect(markup).toContain('Invoice 4521')
55
+ expect(markup).not.toContain('cv-p-thread')
56
+ })
57
+
58
+ it('mounts the conversation shell with the subject label when selected is set', () => {
59
+ const markup = renderScreen({ selected: { subjectType: 'invoice', subjectId: '1' } })
60
+
61
+ expect(markup).toContain('cv-p-thread__title')
62
+ expect(markup).toContain('Invoice 4521')
63
+ expect(markup).not.toContain('cv-p-inbox')
64
+ expect(findUtilityClassTokens(markup)).toEqual([])
65
+ })
66
+
67
+ it('shows the not-found state when the subject is absent and the api cannot open it', () => {
68
+ const markup = renderScreen({ selected: { subjectType: 'invoice', subjectId: '999' } })
69
+
70
+ expect(markup).toContain('Conversation not found')
71
+ })
72
+
73
+ it('merges partial labels over the defaults', () => {
74
+ const markup = renderScreen({ labels: { inboxTitle: 'Minhas conversas' } })
75
+
76
+ expect(markup).toContain('Minhas conversas')
77
+ })
78
+
79
+ it('applies the theme as --cv-p-* variables and the class names on the wrapper', () => {
80
+ const markup = renderScreen({
81
+ theme: { primaryColor: '#123456' },
82
+ className: 'host-wrapper',
83
+ classNames: { inbox: 'host-inbox' },
84
+ })
85
+
86
+ expect(markup).toContain('--cv-p-accent:#123456')
87
+ expect(markup).toContain('host-wrapper')
88
+ expect(markup).toContain('host-inbox')
89
+ })
90
+ })
@@ -0,0 +1,42 @@
1
+ import type { ReactNode } from 'react'
2
+
3
+ import type {
4
+ ParticipantConversationSummary,
5
+ ParticipantSubjectRef,
6
+ } from '@adatechnology/conversation-contracts'
7
+
8
+ import type { ConversationChannel } from '../conversationChannel'
9
+ import type { QuickReply } from '../quickReplies/quickReply.types'
10
+ import type { ConversationsTheme } from '../types'
11
+ import { ParticipantConversationsScreen } from './ParticipantConversationsScreen'
12
+ import type { ParticipantConversationsClassNames, ParticipantSubjectGroup } from './participant.types'
13
+ import type { ParticipantConversationsApi, ParticipantPendingMessage } from './participantApi.types'
14
+ import type { ParticipantConversationsLabels } from './participantLabels'
15
+ import { useParticipantInbox } from './useParticipantInbox'
16
+
17
+ export type ParticipantConversationsProps = {
18
+ api: ParticipantConversationsApi
19
+ subjectGroups: readonly ParticipantSubjectGroup[]
20
+ /** undefined renders the list; defined renders that conversation. */
21
+ selected: ParticipantSubjectRef | undefined
22
+ onSelect: (subject: ParticipantSubjectRef) => void
23
+ onBack?: () => void
24
+ channel?: ConversationChannel
25
+ labels?: Partial<ParticipantConversationsLabels>
26
+ locale?: string
27
+ theme?: ConversationsTheme
28
+ className?: string
29
+ classNames?: Partial<ParticipantConversationsClassNames>
30
+ pendingMessages?: readonly ParticipantPendingMessage[]
31
+ onRetryPending?: (clientMessageId: string) => void
32
+ quickReplies?: readonly QuickReply[]
33
+ renderSubjectCard?: (conversation: ParticipantConversationSummary) => ReactNode
34
+ onOpenSubject?: (subject: ParticipantSubjectRef) => void
35
+ }
36
+
37
+ export function ParticipantConversations(props: ParticipantConversationsProps) {
38
+ const inbox = useParticipantInbox(props.api)
39
+ const { status, conversations, hasMore, refresh, loadMore, markSubjectRead } = inbox
40
+
41
+ return <ParticipantConversationsScreen {...props} inbox={{ status, conversations, hasMore, refresh, loadMore, markSubjectRead }} />
42
+ }
@@ -0,0 +1,67 @@
1
+ import { useMemo, useReducer, useRef, useState } from 'react'
2
+
3
+ import { ParticipantInbox } from './ParticipantInbox'
4
+ import type { ParticipantConversationsProps } from './ParticipantConversations'
5
+ import { ParticipantThreadScreen } from './ParticipantThreadScreen'
6
+ import { draftKey, type ParticipantDrafts } from './participantDrafts'
7
+ import { groupParticipantConversations } from './participantGrouping'
8
+ import { DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS } from './participantLabels'
9
+ import { participantSendStatesReducer } from './participantSendStates'
10
+ import { buildParticipantThemeStyle } from './participantTheme'
11
+ import type { UseParticipantInboxResult } from './useParticipantInbox'
12
+
13
+ export type ParticipantConversationsScreenProps = ParticipantConversationsProps & {
14
+ readonly inbox: Pick<UseParticipantInboxResult, 'status' | 'conversations' | 'hasMore' | 'refresh' | 'loadMore' | 'markSubjectRead'>
15
+ }
16
+
17
+ const ALL_FILTER = 'all'
18
+
19
+ export function ParticipantConversationsScreen(props: ParticipantConversationsScreenProps) {
20
+ const { selected, inbox, subjectGroups, classNames } = props
21
+ const [filter, setFilter] = useState(ALL_FILTER)
22
+ const draftsRef = useRef<ParticipantDrafts>(new Map())
23
+ const [sendStates, dispatchSendStates] = useReducer(participantSendStatesReducer, new Map())
24
+ const labels = useMemo(
25
+ () => ({ ...DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS, ...props.labels }),
26
+ [props.labels],
27
+ )
28
+ const view = useMemo(
29
+ () => groupParticipantConversations({ conversations: inbox.conversations, subjectGroups, filter }),
30
+ [inbox.conversations, subjectGroups, filter],
31
+ )
32
+ const rootClassName = ['cv-p-root', props.className, classNames?.root].filter(Boolean).join(' ')
33
+
34
+ return (
35
+ <div className={rootClassName} style={buildParticipantThemeStyle(props.theme)}>
36
+ {selected === undefined ? (
37
+ <div className={['cv-p-root__screen', classNames?.inbox].filter(Boolean).join(' ')}>
38
+ <ParticipantInbox
39
+ view={view}
40
+ subjectGroups={subjectGroups}
41
+ filter={filter}
42
+ onFilterChange={setFilter}
43
+ onSelect={props.onSelect}
44
+ labels={labels}
45
+ status={inbox.status}
46
+ refresh={() => void inbox.refresh()}
47
+ hasMore={inbox.hasMore}
48
+ loadMore={() => void inbox.loadMore()}
49
+ locale={props.locale}
50
+ />
51
+ </div>
52
+ ) : (
53
+ <div className={['cv-p-root__screen', classNames?.thread].filter(Boolean).join(' ')}>
54
+ <ParticipantThreadScreen
55
+ key={draftKey(selected)}
56
+ {...props}
57
+ selected={selected}
58
+ labels={labels}
59
+ draftsRef={draftsRef}
60
+ sendStates={sendStates}
61
+ dispatchSendStates={dispatchSendStates}
62
+ />
63
+ </div>
64
+ )}
65
+ </div>
66
+ )
67
+ }
@@ -0,0 +1,147 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import { renderToStaticMarkup } from 'react-dom/server'
3
+
4
+ import { buildConversation, findForeignClassTokens, findUtilityClassTokens } from './participantFixtures.test-helper'
5
+ import { groupParticipantConversations } from './participantGrouping'
6
+ import { DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS } from './participantLabels'
7
+ import type { ParticipantSubjectGroup } from './participant.types'
8
+ import { ParticipantInbox } from './ParticipantInbox'
9
+
10
+ const GROUPS: readonly ParticipantSubjectGroup[] = [
11
+ { subjectType: 'invoice', label: 'Invoice' },
12
+ { subjectType: 'incident', label: 'Incident' },
13
+ ]
14
+
15
+ const CONVERSATIONS = [
16
+ buildConversation({ subjectId: '1', subjectLabel: 'Awaiting one', awaitingParticipant: true, unreadCount: 2, lastMessagePreview: 'Please send the photo' }),
17
+ buildConversation({ subjectId: '2', subjectLabel: 'Invoice two' }),
18
+ buildConversation({ subjectId: '3', subjectType: 'incident', subjectLabel: 'Incident three' }),
19
+ buildConversation({ subjectId: '4', subjectLabel: 'Old one', status: 'closed' }),
20
+ ]
21
+
22
+ type RenderOverrides = {
23
+ conversations?: typeof CONVERSATIONS
24
+ filter?: string
25
+ status?: 'idle' | 'loading' | 'ready' | 'error'
26
+ hasMore?: boolean
27
+ }
28
+
29
+ function render(overrides: RenderOverrides = {}): string {
30
+ const view = groupParticipantConversations({
31
+ conversations: overrides.conversations ?? CONVERSATIONS,
32
+ subjectGroups: GROUPS,
33
+ filter: overrides.filter,
34
+ })
35
+ return renderToStaticMarkup(
36
+ <ParticipantInbox
37
+ view={view}
38
+ subjectGroups={GROUPS}
39
+ filter={overrides.filter ?? 'all'}
40
+ onFilterChange={() => undefined}
41
+ onSelect={() => undefined}
42
+ labels={DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS}
43
+ status={overrides.status ?? 'ready'}
44
+ refresh={() => undefined}
45
+ hasMore={overrides.hasMore ?? false}
46
+ loadMore={() => undefined}
47
+ />,
48
+ )
49
+ }
50
+
51
+ describe('ParticipantInbox', () => {
52
+ it('renders section headers with label and count, awaiting highlighted', () => {
53
+ const markup = render()
54
+
55
+ expect(markup).toContain('Waiting for your reply')
56
+ expect(markup).toContain('cv-p-section--awaiting')
57
+ expect(markup).toContain('Invoice')
58
+ expect(markup).toContain('Incident')
59
+ })
60
+
61
+ it('renders rows as buttons with the subject label as text and the preview', () => {
62
+ const markup = render()
63
+
64
+ expect(markup).toContain('<button')
65
+ expect(markup).toContain('Awaiting one')
66
+ expect(markup).toContain('Please send the photo')
67
+ expect(markup).toContain('cv-p-row__kind')
68
+ expect(markup).toContain('cv-p-row--awaiting')
69
+ })
70
+
71
+ it('shows an unread badge with accessible text only when there are unread messages', () => {
72
+ const markup = render()
73
+
74
+ expect(markup).toContain('2 unread')
75
+ expect(markup.match(/cv-p-row__unread/g)?.length).toBe(1)
76
+ })
77
+
78
+ it('puts closed conversations inside a collapsible details', () => {
79
+ const markup = render()
80
+
81
+ expect(markup).toContain('<details')
82
+ expect(markup).toContain('Closed')
83
+ expect(markup).toContain('Old one')
84
+ })
85
+
86
+ it('renders filters only when showFilters is true', () => {
87
+ const withFilters = render()
88
+ const single = render({ conversations: [CONVERSATIONS[1] as (typeof CONVERSATIONS)[number]] })
89
+
90
+ expect(withFilters).toContain('cv-p-filters')
91
+ expect(withFilters).toContain('aria-pressed="true"')
92
+ expect(single).not.toContain('cv-p-filters')
93
+ })
94
+
95
+ it('renders the empty state when there are no sections', () => {
96
+ const markup = render({ conversations: [] })
97
+
98
+ expect(markup).toContain('No conversations yet')
99
+ expect(markup).not.toContain('cv-p-row"')
100
+ })
101
+
102
+ it('while loading shows a busy skeleton and never the empty state', () => {
103
+ const markup = render({ conversations: [], status: 'loading' })
104
+
105
+ expect(markup).toContain('aria-busy="true"')
106
+ expect(markup).toContain('Loading…')
107
+ expect(markup).not.toContain('No conversations yet')
108
+ expect(render({ conversations: [], status: 'idle' })).not.toContain('No conversations yet')
109
+ })
110
+
111
+ it('on error shows an alert with retry and never the empty state', () => {
112
+ const markup = render({ conversations: [], status: 'error' })
113
+
114
+ expect(markup).toContain('role="alert"')
115
+ expect(markup).toContain('Could not load')
116
+ expect(markup).toContain('Retry')
117
+ expect(markup).not.toContain('No conversations yet')
118
+ expect(markup).not.toContain('aria-busy="true"')
119
+ })
120
+
121
+ it('on success shows the empty state without busy or alert', () => {
122
+ const markup = render({ conversations: [], status: 'ready' })
123
+
124
+ expect(markup).toContain('No conversations yet')
125
+ expect(markup).not.toContain('role="alert"')
126
+ expect(markup).toContain('aria-busy="false"')
127
+ })
128
+
129
+ it('keeps the rows and adds the alert when a later request fails', () => {
130
+ const markup = render({ status: 'error' })
131
+
132
+ expect(markup).toContain('Awaiting one')
133
+ expect(markup).toContain('role="alert"')
134
+ })
135
+
136
+ it('shows load more only when there is a next page', () => {
137
+ expect(render()).not.toContain('Load more')
138
+ expect(render({ hasMore: true })).toContain('Load more')
139
+ })
140
+
141
+ it('uses only cv-p-* classes and no Tailwind utilities', () => {
142
+ const markup = render()
143
+
144
+ expect(findUtilityClassTokens(markup)).toEqual([])
145
+ expect(findForeignClassTokens(markup)).toEqual([])
146
+ })
147
+ })
@@ -0,0 +1,168 @@
1
+ import type { ParticipantConversationSummary, ParticipantSubjectRef } from '@adatechnology/conversation-contracts'
2
+
3
+ import { formatTimestamp, isSameDay } from '../lib/format'
4
+ import type {
5
+ ParticipantInboxFilter,
6
+ ParticipantInboxSection,
7
+ ParticipantInboxView,
8
+ ParticipantSubjectGroup,
9
+ } from './participant.types'
10
+ import { formatParticipantLabel, type ParticipantConversationsLabels } from './participantLabels'
11
+ import { resolveLoadView, type ParticipantLoadStatus } from './participantLoadView'
12
+ import { ParticipantLoadError, ParticipantLoading } from './ParticipantLoadState'
13
+
14
+ export type ParticipantInboxProps = {
15
+ readonly view: ParticipantInboxView
16
+ readonly subjectGroups: readonly ParticipantSubjectGroup[]
17
+ /** subjectType of the active filter, or 'all'. */
18
+ readonly filter: string
19
+ readonly onFilterChange: (filter: string) => void
20
+ readonly onSelect: (subject: ParticipantSubjectRef) => void
21
+ readonly labels: ParticipantConversationsLabels
22
+ readonly status: ParticipantLoadStatus
23
+ readonly refresh: () => void
24
+ readonly hasMore: boolean
25
+ readonly loadMore: () => void
26
+ readonly locale?: string
27
+ }
28
+
29
+ const ALL_FILTER = 'all'
30
+
31
+ function formatRowTime(iso: string, locale: string | undefined): string {
32
+ const date = new Date(iso)
33
+ if (isSameDay(date, new Date())) return formatTimestamp(iso)
34
+ return date.toLocaleDateString(locale, { day: '2-digit', month: '2-digit' })
35
+ }
36
+
37
+ function sectionTitle(section: ParticipantInboxSection, props: ParticipantInboxProps): string {
38
+ if (section.key === 'awaiting') return props.labels.sectionAwaiting
39
+ if (section.key === 'closed') return props.labels.sectionClosed
40
+ if (section.key === 'other') return props.labels.sectionOther
41
+ return props.subjectGroups.find((group) => group.subjectType === section.key)?.label ?? section.key
42
+ }
43
+
44
+ type FiltersProps = {
45
+ readonly filters: readonly ParticipantInboxFilter[]
46
+ readonly active: string
47
+ readonly labels: ParticipantConversationsLabels
48
+ readonly onChange: (filter: string) => void
49
+ }
50
+
51
+ function Filters({ filters, active, labels, onChange }: FiltersProps) {
52
+ return (
53
+ <div className="cv-p-filters" role="group" aria-label={labels.filtersGroup}>
54
+ {filters.map((filter) => (
55
+ <button
56
+ key={filter.subjectType}
57
+ type="button"
58
+ className="cv-p-chip"
59
+ aria-pressed={filter.subjectType === active}
60
+ onClick={() => onChange(filter.subjectType)}
61
+ >
62
+ {filter.subjectType === ALL_FILTER ? labels.filterAll : filter.label}
63
+ {filter.unreadCount > 0 ? <b className="cv-p-chip__count">{filter.unreadCount}</b> : null}
64
+ </button>
65
+ ))}
66
+ </div>
67
+ )
68
+ }
69
+
70
+ type RowProps = {
71
+ readonly conversation: ParticipantConversationSummary
72
+ readonly group: ParticipantSubjectGroup | undefined
73
+ readonly labels: ParticipantConversationsLabels
74
+ readonly locale?: string
75
+ readonly onSelect: (subject: ParticipantSubjectRef) => void
76
+ }
77
+
78
+ function Row({ conversation, group, labels, locale, onSelect }: RowProps) {
79
+ const kind = group?.label ?? conversation.subjectType
80
+ const className = conversation.awaitingParticipant ? 'cv-p-row cv-p-row--awaiting' : 'cv-p-row'
81
+ const { subjectType, subjectId } = conversation
82
+
83
+ return (
84
+ <button type="button" className={className} onClick={() => onSelect({ subjectType, subjectId })}>
85
+ <span className="cv-p-row__icon" aria-hidden="true">
86
+ {group?.icon ?? kind.slice(0, 2)}
87
+ </span>
88
+ <span className="cv-p-row__body">
89
+ <span className="cv-p-row__kind">{kind}</span>
90
+ <span className="cv-p-row__title">{conversation.subjectLabel}</span>
91
+ {conversation.lastMessagePreview ? <span className="cv-p-row__preview">{conversation.lastMessagePreview}</span> : null}
92
+ </span>
93
+ <span className="cv-p-row__meta">
94
+ {conversation.lastMessageAt ? <span>{formatRowTime(conversation.lastMessageAt, locale)}</span> : null}
95
+ {conversation.unreadCount > 0 ? (
96
+ <span className="cv-p-row__unread">
97
+ <span aria-hidden="true">{conversation.unreadCount}</span>
98
+ <span className="cv-p-sr-only">{formatParticipantLabel(labels.unreadCount, conversation.unreadCount)}</span>
99
+ </span>
100
+ ) : null}
101
+ </span>
102
+ </button>
103
+ )
104
+ }
105
+
106
+ function SectionHeading({ title, count }: { readonly title: string; readonly count: number }) {
107
+ return (
108
+ <>
109
+ <span className="cv-p-section__title">{title}</span>
110
+ <span className="cv-p-section__count">{count}</span>
111
+ </>
112
+ )
113
+ }
114
+
115
+ function Section({ section, props }: { readonly section: ParticipantInboxSection; readonly props: ParticipantInboxProps }) {
116
+ const rows = section.conversations.map((conversation) => (
117
+ <Row
118
+ key={`${conversation.subjectType}:${conversation.subjectId}`}
119
+ conversation={conversation}
120
+ group={props.subjectGroups.find((group) => group.subjectType === conversation.subjectType)}
121
+ labels={props.labels}
122
+ locale={props.locale}
123
+ onSelect={props.onSelect}
124
+ />
125
+ ))
126
+ const heading = <SectionHeading title={sectionTitle(section, props)} count={section.conversations.length} />
127
+
128
+ if (section.key === 'closed') {
129
+ return (
130
+ <details className="cv-p-section cv-p-section--closed">
131
+ <summary className="cv-p-section__heading">{heading}</summary>
132
+ {rows}
133
+ </details>
134
+ )
135
+ }
136
+ const modifier = section.key === 'awaiting' ? ' cv-p-section--awaiting' : ''
137
+ return (
138
+ <section className={`cv-p-section${modifier}`}>
139
+ <h3 className="cv-p-section__heading">{heading}</h3>
140
+ {rows}
141
+ </section>
142
+ )
143
+ }
144
+
145
+ export function ParticipantInbox(props: ParticipantInboxProps) {
146
+ const { view, labels, filter, onFilterChange } = props
147
+ const loadView = resolveLoadView({ status: props.status, hasItems: view.sections.length > 0 })
148
+
149
+ return (
150
+ <div className="cv-p cv-p-inbox" aria-busy={loadView.isLoading}>
151
+ <h2 className="cv-p-inbox__title">{labels.inboxTitle}</h2>
152
+ {view.showFilters ? (
153
+ <Filters filters={view.filters} active={filter} labels={labels} onChange={onFilterChange} />
154
+ ) : null}
155
+ {loadView.isLoading ? <ParticipantLoading labels={labels} /> : null}
156
+ {loadView.hasError ? <ParticipantLoadError labels={labels} onRetry={props.refresh} /> : null}
157
+ {loadView.isEmpty ? <p className="cv-p-empty">{labels.emptyInbox}</p> : null}
158
+ {view.sections.map((section) => (
159
+ <Section key={section.key} section={section} props={props} />
160
+ ))}
161
+ {props.hasMore ? (
162
+ <button type="button" className="cv-p-button cv-p-inbox__more" onClick={props.loadMore}>
163
+ {labels.loadMore}
164
+ </button>
165
+ ) : null}
166
+ </div>
167
+ )
168
+ }
@@ -0,0 +1,27 @@
1
+ import type { ParticipantConversationsLabels } from './participantLabels'
2
+
3
+ export type ParticipantLoadErrorProps = {
4
+ readonly labels: ParticipantConversationsLabels
5
+ readonly onRetry: () => void
6
+ }
7
+
8
+ export function ParticipantLoadError({ labels, onRetry }: ParticipantLoadErrorProps) {
9
+ return (
10
+ <div className="cv-p-error" role="alert">
11
+ <p className="cv-p-error__message">{labels.loadError}</p>
12
+ <button type="button" className="cv-p-button" onClick={onRetry}>
13
+ {labels.retry}
14
+ </button>
15
+ </div>
16
+ )
17
+ }
18
+
19
+ export function ParticipantLoading({ labels }: { readonly labels: ParticipantConversationsLabels }) {
20
+ return (
21
+ <div className="cv-p-loading">
22
+ <span className="cv-p-skeleton" aria-hidden="true" />
23
+ <span className="cv-p-skeleton" aria-hidden="true" />
24
+ <span className="cv-p-sr-only">{labels.loading}</span>
25
+ </div>
26
+ )
27
+ }