@adatechnology/conversations-ui 0.5.0 → 0.6.1

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 (52) 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 +7 -7
  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 +106 -0
  11. package/package.json +3 -3
  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/cssBlocks.test-helper.ts +31 -0
  19. package/src/legacyChannels.golden.test.ts +89 -0
  20. package/src/legacyDefaults.golden.test.tsx +127 -0
  21. package/src/legacyStyles.fixture.css +1121 -0
  22. package/src/legacyStyles.golden.test.ts +92 -0
  23. package/src/messageTextCopyInteraction.test.ts +75 -0
  24. package/src/messageTextCopyInteraction.ts +37 -0
  25. package/src/participant/ParticipantChannelBadges.test.tsx +59 -0
  26. package/src/participant/ParticipantChannelBadges.tsx +75 -0
  27. package/src/participant/ParticipantConversations.tsx +13 -6
  28. package/src/participant/ParticipantConversationsScreen.tsx +18 -7
  29. package/src/participant/ParticipantInbox.test.tsx +99 -2
  30. package/src/participant/ParticipantInbox.tsx +27 -46
  31. package/src/participant/ParticipantInboxRow.tsx +75 -0
  32. package/src/participant/ParticipantInboxSearchField.tsx +34 -0
  33. package/src/participant/ParticipantMessageBubble.test.tsx +2 -0
  34. package/src/participant/ParticipantMessageBubble.tsx +3 -3
  35. package/src/participant/ParticipantProtocolBadge.tsx +74 -0
  36. package/src/participant/ParticipantProtocolView.test.tsx +208 -0
  37. package/src/participant/ParticipantThread.test.tsx +19 -2
  38. package/src/participant/ParticipantThread.tsx +25 -15
  39. package/src/participant/index.ts +1 -0
  40. package/src/participant/participant.types.ts +9 -0
  41. package/src/participant/participantChannels.test.ts +42 -0
  42. package/src/participant/participantChannels.ts +33 -0
  43. package/src/participant/participantGrouping.test.ts +101 -3
  44. package/src/participant/participantGrouping.ts +20 -7
  45. package/src/participant/participantLabels.ts +29 -0
  46. package/src/participant/participantProtocol.test.ts +104 -0
  47. package/src/participant/participantProtocol.ts +52 -0
  48. package/src/participant/participantProtocolCopy.test.ts +45 -0
  49. package/src/participant/participantProtocolCopy.ts +32 -0
  50. package/src/participantLayoutShrink.test.ts +82 -0
  51. package/src/styles.css +22 -1
  52. package/dist/chunk-WSFGNR5R.js +0 -55
@@ -4,6 +4,7 @@ export { DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS, type ParticipantConversations
4
4
  export type {
5
5
  ParticipantConversationsClassNames,
6
6
  ParticipantSubjectGroup,
7
+ ParticipantSubjectIconRenderer,
7
8
  ParticipantInboxView,
8
9
  ParticipantInboxSection,
9
10
  GroupParticipantConversationsParams,
@@ -8,6 +8,9 @@ export type ParticipantSubjectGroup = {
8
8
  readonly icon?: ReactNode
9
9
  }
10
10
 
11
+ /** Host-drawn icon for a list row; null/undefined falls back to the subject group icon. */
12
+ export type ParticipantSubjectIconRenderer = (conversation: ParticipantConversationSummary) => ReactNode
13
+
11
14
  export type ParticipantInboxSection = {
12
15
  /** 'awaiting' | 'closed' | 'other' or the subjectType of a group. */
13
16
  readonly key: string
@@ -27,6 +30,10 @@ export type ParticipantInboxView = {
27
30
  readonly sections: readonly ParticipantInboxSection[]
28
31
  readonly filters: readonly ParticipantInboxFilter[]
29
32
  readonly showFilters: boolean
33
+ /** The filter actually applied: 'all' when the requested one has no conversations left. */
34
+ readonly activeFilter: string
35
+ /** The search finds conversations, but none under the active subject filter. */
36
+ readonly hasMatchesOutsideFilter: boolean
30
37
  }
31
38
 
32
39
  export type GroupParticipantConversationsParams = {
@@ -34,6 +41,8 @@ export type GroupParticipantConversationsParams = {
34
41
  readonly subjectGroups: readonly ParticipantSubjectGroup[]
35
42
  /** subjectType, or 'all'/undefined for everything. */
36
43
  readonly filter?: string
44
+ /** Narrows the rows only: chips and their counts always describe the whole list. */
45
+ readonly searchQuery?: string
37
46
  }
38
47
 
39
48
  export type ParticipantConversationsClassNames = {
@@ -0,0 +1,42 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+
3
+ import { DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS } from './participantLabels'
4
+ import {
5
+ PARTICIPANT_CHANNEL_ORDER,
6
+ resolveParticipantChannelLabel,
7
+ resolveParticipantChannels,
8
+ } from './participantChannels'
9
+
10
+ const channelsOf = (input: readonly string[] | undefined) =>
11
+ resolveParticipantChannels(input).map((descriptor) => descriptor.channel)
12
+
13
+ describe('resolveParticipantChannels', () => {
14
+ it('returns nothing for absent or empty input', () => {
15
+ expect(channelsOf(undefined)).toEqual([])
16
+ expect(channelsOf([])).toEqual([])
17
+ })
18
+
19
+ it('orders deterministically whatever the server order', () => {
20
+ expect(channelsOf(['webchat', 'email', 'whatsapp', 'app', 'portal'])).toEqual([
21
+ 'app',
22
+ 'whatsapp',
23
+ 'email',
24
+ 'portal',
25
+ 'webchat',
26
+ ])
27
+ expect(channelsOf(['whatsapp', 'app'])).toEqual(['app', 'whatsapp'])
28
+ })
29
+
30
+ it('dedupes and ignores unknown channels', () => {
31
+ expect(channelsOf(['whatsapp', 'whatsapp', 'sms', 'app'])).toEqual(['app', 'whatsapp'])
32
+ })
33
+
34
+ it('maps every channel to an icon and a label', () => {
35
+ expect(PARTICIPANT_CHANNEL_ORDER).toHaveLength(5)
36
+ const labels = PARTICIPANT_CHANNEL_ORDER.map((descriptor) =>
37
+ resolveParticipantChannelLabel(descriptor, DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS),
38
+ )
39
+ expect(labels).toEqual(['App', 'WhatsApp', 'Email', 'Portal', 'Web chat'])
40
+ expect(new Set(PARTICIPANT_CHANNEL_ORDER.map((descriptor) => descriptor.iconName)).size).toBe(5)
41
+ })
42
+ })
@@ -0,0 +1,33 @@
1
+ import type { ConversationChannel } from '@adatechnology/conversation-contracts'
2
+
3
+ import type { ParticipantConversationsLabels } from './participantLabels'
4
+
5
+ export type ParticipantChannelDescriptor = {
6
+ readonly channel: ConversationChannel
7
+ readonly iconName: 'Smartphone' | 'MessageCircle' | 'Mail' | 'Globe' | 'MessagesSquare'
8
+ readonly labelKey: 'channelApp' | 'channelWhatsapp' | 'channelEmail' | 'channelPortal' | 'channelWebchat'
9
+ }
10
+
11
+ export const PARTICIPANT_CHANNEL_ORDER: readonly ParticipantChannelDescriptor[] = [
12
+ { channel: 'app', iconName: 'Smartphone', labelKey: 'channelApp' },
13
+ { channel: 'whatsapp', iconName: 'MessageCircle', labelKey: 'channelWhatsapp' },
14
+ { channel: 'email', iconName: 'Mail', labelKey: 'channelEmail' },
15
+ { channel: 'portal', iconName: 'Globe', labelKey: 'channelPortal' },
16
+ { channel: 'webchat', iconName: 'MessagesSquare', labelKey: 'channelWebchat' },
17
+ ]
18
+
19
+ /** Stable badge order regardless of the server order; duplicates and unknown values (runtime) are dropped. */
20
+ export function resolveParticipantChannels(
21
+ channels: readonly string[] | undefined,
22
+ ): readonly ParticipantChannelDescriptor[] {
23
+ if (channels === undefined || channels.length === 0) return []
24
+ const present = new Set<string>(channels)
25
+ return PARTICIPANT_CHANNEL_ORDER.filter((descriptor) => present.has(descriptor.channel))
26
+ }
27
+
28
+ export function resolveParticipantChannelLabel(
29
+ descriptor: ParticipantChannelDescriptor,
30
+ labels: ParticipantConversationsLabels,
31
+ ): string {
32
+ return labels[descriptor.labelKey]
33
+ }
@@ -4,7 +4,9 @@ import type { ParticipantConversationSummary } from '@adatechnology/conversation
4
4
 
5
5
  import { groupParticipantConversations } from './participantGrouping'
6
6
 
7
- function build(overrides: Partial<ParticipantConversationSummary> & { subjectId: string }): ParticipantConversationSummary {
7
+ function build(
8
+ overrides: Partial<ParticipantConversationSummary> & { subjectId: string },
9
+ ): ParticipantConversationSummary {
8
10
  return {
9
11
  subjectType: 'invoice',
10
12
  subjectLabel: 'Label',
@@ -60,7 +62,12 @@ describe('groupParticipantConversations', () => {
60
62
  build({ subjectId: 'a', subjectType: 'zeta', lastMessageAt: null, subjectLabel: 'A' }),
61
63
  build({ subjectId: 'b', subjectType: 'zeta', lastMessageAt: '2026-10-01T10:00:00.000Z', subjectLabel: 'Beta' }),
62
64
  build({ subjectId: 'c', subjectType: 'zeta', lastMessageAt: '2026-10-02T10:00:00.000Z', subjectLabel: 'Z' }),
63
- build({ subjectId: 'd', subjectType: 'zeta', lastMessageAt: '2026-10-01T10:00:00.000Z', subjectLabel: 'Alpha' }),
65
+ build({
66
+ subjectId: 'd',
67
+ subjectType: 'zeta',
68
+ lastMessageAt: '2026-10-01T10:00:00.000Z',
69
+ subjectLabel: 'Alpha',
70
+ }),
64
71
  ],
65
72
  })
66
73
  expect(view.sections[0]?.conversations.map((c) => c.subjectId)).toEqual(['c', 'd', 'b', 'a'])
@@ -133,6 +140,97 @@ describe('groupParticipantConversations', () => {
133
140
  ],
134
141
  })
135
142
  expect(view.sections.map((section) => section.conversations.map((c) => c.subjectId))).toEqual([['2'], ['3']])
136
- expect(groupParticipantConversations({ subjectGroups: GROUPS, filter: 'all', conversations: [build({ subjectId: '1' })] }).sections).toHaveLength(1)
143
+ expect(
144
+ groupParticipantConversations({
145
+ subjectGroups: GROUPS,
146
+ filter: 'all',
147
+ conversations: [build({ subjectId: '1' })],
148
+ }).sections,
149
+ ).toHaveLength(1)
150
+ })
151
+ })
152
+
153
+ describe('groupParticipantConversations with a search query', () => {
154
+ const conversations = [
155
+ build({ subjectId: '1', subjectType: 'alpha', protocol: '261009-K7M2', unreadCount: 2 }),
156
+ build({ subjectId: '2', subjectType: 'zeta', protocol: '261008-AB23', unreadCount: 1 }),
157
+ build({ subjectId: '3', subjectType: 'zeta', protocol: '261007-CD45' }),
158
+ ]
159
+ const idsOf = (view: ReturnType<typeof groupParticipantConversations>) =>
160
+ view.sections.flatMap((section) => section.conversations.map((item) => item.subjectId))
161
+
162
+ it('keeps chips and counts computed on the whole list, not on the search result', () => {
163
+ const view = groupParticipantConversations({
164
+ conversations,
165
+ subjectGroups: GROUPS,
166
+ filter: 'all',
167
+ searchQuery: 'k7m2',
168
+ })
169
+ expect(idsOf(view)).toEqual(['1'])
170
+ expect(view.showFilters).toBe(true)
171
+ expect(view.filters.map((filter) => [filter.subjectType, filter.unreadCount])).toEqual([
172
+ ['all', 3],
173
+ ['zeta', 1],
174
+ ['alpha', 2],
175
+ ])
176
+ })
177
+
178
+ it('chip of another subject plus the protocol of a conversation outside it: chips stay and the view says so', () => {
179
+ const view = groupParticipantConversations({
180
+ conversations,
181
+ subjectGroups: GROUPS,
182
+ filter: 'zeta',
183
+ searchQuery: '261009',
184
+ })
185
+ expect(view.sections).toEqual([])
186
+ expect(view.showFilters).toBe(true)
187
+ expect(view.hasMatchesOutsideFilter).toBe(true)
188
+ })
189
+
190
+ it('a query that matches inside the active filter shows only those rows', () => {
191
+ const view = groupParticipantConversations({
192
+ conversations,
193
+ subjectGroups: GROUPS,
194
+ filter: 'zeta',
195
+ searchQuery: 'ab23',
196
+ })
197
+ expect(idsOf(view)).toEqual(['2'])
198
+ expect(view.hasMatchesOutsideFilter).toBe(false)
199
+ })
200
+
201
+ it('a query that matches nothing anywhere is not an outside-filter match', () => {
202
+ const view = groupParticipantConversations({
203
+ conversations,
204
+ subjectGroups: GROUPS,
205
+ filter: 'zeta',
206
+ searchQuery: 'zzz',
207
+ })
208
+ expect(view.hasMatchesOutsideFilter).toBe(false)
209
+ expect(view.showFilters).toBe(true)
210
+ })
211
+
212
+ it('without a query nothing is outside the filter', () => {
213
+ const view = groupParticipantConversations({ conversations, subjectGroups: GROUPS, filter: 'zeta' })
214
+ expect(view.hasMatchesOutsideFilter).toBe(false)
215
+ })
216
+
217
+ it('a filter pointing at a subject with no conversations behaves as all', () => {
218
+ const view = groupParticipantConversations({
219
+ conversations: [build({ subjectId: '1', subjectType: 'zeta' })],
220
+ subjectGroups: GROUPS,
221
+ filter: 'alpha',
222
+ })
223
+ expect(idsOf(view)).toEqual(['1'])
224
+ expect(view.activeFilter).toBe('all')
225
+ expect(view.hasMatchesOutsideFilter).toBe(false)
226
+ })
227
+
228
+ it('keeps a filter that still has conversations as active', () => {
229
+ const view = groupParticipantConversations({
230
+ conversations: [build({ subjectId: '1', subjectType: 'zeta' })],
231
+ subjectGroups: GROUPS,
232
+ filter: 'zeta',
233
+ })
234
+ expect(view.activeFilter).toBe('zeta')
137
235
  })
138
236
  })
@@ -6,6 +6,7 @@ import type {
6
6
  ParticipantInboxSection,
7
7
  ParticipantInboxView,
8
8
  } from './participant.types'
9
+ import { filterConversationsBySearch } from './participantProtocol'
9
10
 
10
11
  const ALL_FILTER = 'all'
11
12
  const SECTION_AWAITING = 'awaiting'
@@ -53,12 +54,18 @@ function buildFilters(
53
54
  return { filters: [all, ...groupFilters], showFilters: groupFilters.length >= 2 }
54
55
  }
55
56
 
57
+ function resolveActiveFilter(params: GroupParticipantConversationsParams): string {
58
+ const { filter, conversations } = params
59
+ if (filter === undefined || filter === ALL_FILTER) return ALL_FILTER
60
+ return conversations.some((conversation) => conversation.subjectType === filter) ? filter : ALL_FILTER
61
+ }
62
+
56
63
  export function groupParticipantConversations(params: GroupParticipantConversationsParams): ParticipantInboxView {
57
- const { filter, subjectGroups } = params
58
- const isFiltered = filter !== undefined && filter !== ALL_FILTER
59
- const visible = isFiltered
60
- ? params.conversations.filter((conversation) => conversation.subjectType === filter)
61
- : params.conversations
64
+ const { subjectGroups } = params
65
+ const filter = resolveActiveFilter(params)
66
+ const isFiltered = filter !== ALL_FILTER
67
+ const searched = filterConversationsBySearch(params.conversations, params.searchQuery ?? '')
68
+ const visible = isFiltered ? searched.filter((conversation) => conversation.subjectType === filter) : searched
62
69
  const knownTypes = new Set(subjectGroups.map((group) => group.subjectType))
63
70
 
64
71
  const buckets = new Map<string, ParticipantConversationSummary[]>()
@@ -67,11 +74,17 @@ export function groupParticipantConversations(params: GroupParticipantConversati
67
74
  buckets.set(key, [...(buckets.get(key) ?? []), conversation])
68
75
  }
69
76
 
70
- const orderedKeys = [SECTION_AWAITING, ...subjectGroups.map((group) => group.subjectType), SECTION_OTHER, SECTION_CLOSED]
77
+ const orderedKeys = [
78
+ SECTION_AWAITING,
79
+ ...subjectGroups.map((group) => group.subjectType),
80
+ SECTION_OTHER,
81
+ SECTION_CLOSED,
82
+ ]
71
83
  const sections = orderedKeys.flatMap((key) => {
72
84
  const bucket = buckets.get(key)
73
85
  return bucket === undefined ? [] : [buildSection(key, bucket)]
74
86
  })
75
87
 
76
- return { sections, ...buildFilters(params) }
88
+ const hasMatchesOutsideFilter = isFiltered && visible.length === 0 && searched.length > 0
89
+ return { sections, hasMatchesOutsideFilter, activeFilter: filter, ...buildFilters(params) }
77
90
  }
@@ -38,6 +38,21 @@ export type ParticipantConversationsLabels = {
38
38
  readonly loading: string
39
39
  readonly loadError: string
40
40
  readonly loadMore: string
41
+ readonly searchLabel: string
42
+ readonly searchPlaceholder: string
43
+ readonly noResults: string
44
+ readonly noResultsInFilter: string
45
+ readonly noResultsLoadedOnly: string
46
+ /** Spoken before the protocol code, e.g. "Protocol 261009-K7M2". */
47
+ readonly protocolPrefix: string
48
+ readonly copyProtocol: string
49
+ readonly protocolCopied: string
50
+ readonly channelsGroup: string
51
+ readonly channelApp: string
52
+ readonly channelWhatsapp: string
53
+ readonly channelEmail: string
54
+ readonly channelPortal: string
55
+ readonly channelWebchat: string
41
56
  }
42
57
 
43
58
  export const DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS: ParticipantConversationsLabels = {
@@ -79,6 +94,20 @@ export const DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS: ParticipantConversationsL
79
94
  loading: 'Loading…',
80
95
  loadError: 'Could not load. Check your connection and try again.',
81
96
  loadMore: 'Load more',
97
+ searchLabel: 'Search conversations',
98
+ searchPlaceholder: 'Search by title or protocol',
99
+ noResults: 'No conversations found',
100
+ noResultsInFilter: 'No results in this filter — try All',
101
+ noResultsLoadedOnly: 'Nothing in the loaded conversations — load more',
102
+ protocolPrefix: 'Protocol',
103
+ copyProtocol: 'Copy protocol',
104
+ protocolCopied: 'Protocol copied',
105
+ channelsGroup: 'Channels in this conversation',
106
+ channelApp: 'App',
107
+ channelWhatsapp: 'WhatsApp',
108
+ channelEmail: 'Email',
109
+ channelPortal: 'Portal',
110
+ channelWebchat: 'Web chat',
82
111
  }
83
112
 
84
113
  export function formatParticipantLabel(template: string, count: number): string {
@@ -0,0 +1,104 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+
3
+ import { buildConversation } from './participantFixtures.test-helper'
4
+ import {
5
+ PROTOCOL_SEARCH_CONVERSATION_THRESHOLD,
6
+ filterConversationsBySearch,
7
+ isInboxSearchVisible,
8
+ matchesProtocolQuery,
9
+ normalizeProtocolQuery,
10
+ shouldShowInboxSearch,
11
+ } from './participantProtocol'
12
+
13
+ describe('normalizeProtocolQuery', () => {
14
+ it('uppercases and drops dashes and spaces', () => {
15
+ expect(normalizeProtocolQuery(' 261009-k7 m2 ')).toBe('261009K7M2')
16
+ })
17
+ })
18
+
19
+ describe('matchesProtocolQuery', () => {
20
+ it('matches partially, without dash and without case', () => {
21
+ expect(matchesProtocolQuery('261009-K7M2', '261009k7')).toBe(true)
22
+ expect(matchesProtocolQuery('261009-K7M2', 'k7m2')).toBe(true)
23
+ expect(matchesProtocolQuery('261009-K7M2', '9-k')).toBe(true)
24
+ })
25
+
26
+ it('does not match a different code', () => {
27
+ expect(matchesProtocolQuery('261009-K7M2', 'ZZZ')).toBe(false)
28
+ })
29
+
30
+ it('an empty query matches everything, even a missing protocol', () => {
31
+ expect(matchesProtocolQuery(undefined, '')).toBe(true)
32
+ expect(matchesProtocolQuery('261009-K7M2', ' ')).toBe(true)
33
+ })
34
+
35
+ it('a missing protocol never matches a real query', () => {
36
+ expect(matchesProtocolQuery(undefined, 'K7')).toBe(false)
37
+ })
38
+ })
39
+
40
+ describe('filterConversationsBySearch', () => {
41
+ const conversations = [
42
+ buildConversation({ subjectId: '1', subjectLabel: 'Damaged item', protocol: '261009-K7M2' }),
43
+ buildConversation({ subjectId: '2', subjectLabel: 'Late item' }),
44
+ buildConversation({ subjectId: '3', subjectLabel: 'Other topic', protocol: '261008-AB23' }),
45
+ ]
46
+ const ids = (query: string) => filterConversationsBySearch(conversations, query).map((item) => item.subjectId)
47
+
48
+ it('returns everything for an empty query', () => {
49
+ expect(ids('')).toEqual(['1', '2', '3'])
50
+ expect(ids(' ')).toEqual(['1', '2', '3'])
51
+ })
52
+
53
+ it('matches the title without accent or case', () => {
54
+ expect(ids('DAMAGED')).toEqual(['1'])
55
+ expect(ids('látè')).toEqual(['2'])
56
+ })
57
+
58
+ it('matches the protocol partially, without dash and case', () => {
59
+ expect(ids('k7m')).toEqual(['1'])
60
+ expect(ids('261008')).toEqual(['3'])
61
+ expect(ids('2610')).toEqual(['1', '3'])
62
+ })
63
+
64
+ it('a query of only separators does not match every protocol', () => {
65
+ expect(ids('-')).toEqual([])
66
+ })
67
+
68
+ it('returns nothing when neither title nor protocol match', () => {
69
+ expect(ids('zzz')).toEqual([])
70
+ })
71
+ })
72
+
73
+ describe('shouldShowInboxSearch', () => {
74
+ it('shows when some conversation has a protocol', () => {
75
+ expect(shouldShowInboxSearch([buildConversation({ subjectId: '1', protocol: '261009-K7M2' })])).toBe(true)
76
+ })
77
+
78
+ it('shows beyond the threshold even without protocols', () => {
79
+ const many = Array.from({ length: PROTOCOL_SEARCH_CONVERSATION_THRESHOLD + 1 }, (_, index) =>
80
+ buildConversation({ subjectId: String(index) }),
81
+ )
82
+ expect(shouldShowInboxSearch(many)).toBe(true)
83
+ expect(shouldShowInboxSearch(many.slice(0, PROTOCOL_SEARCH_CONVERSATION_THRESHOLD))).toBe(false)
84
+ })
85
+
86
+ it('hides for a short list without protocols', () => {
87
+ expect(shouldShowInboxSearch([buildConversation({ subjectId: '1' })])).toBe(false)
88
+ })
89
+ })
90
+
91
+ describe('isInboxSearchVisible', () => {
92
+ const short = [buildConversation({ subjectId: '1' })]
93
+ const withProtocol = [buildConversation({ subjectId: '1', protocol: '261009-K7M2' })]
94
+
95
+ it('follows the threshold rule when there is no query', () => {
96
+ expect(isInboxSearchVisible({ conversations: short, searchQuery: '' })).toBe(false)
97
+ expect(isInboxSearchVisible({ conversations: withProtocol, searchQuery: '' })).toBe(true)
98
+ })
99
+
100
+ it('stays visible while a query is applied, even if a refresh shrank the list', () => {
101
+ expect(isInboxSearchVisible({ conversations: short, searchQuery: 'k7' })).toBe(true)
102
+ expect(isInboxSearchVisible({ conversations: [], searchQuery: 'k7' })).toBe(true)
103
+ })
104
+ })
@@ -0,0 +1,52 @@
1
+ import type { ParticipantConversationSummary } from '@adatechnology/conversation-contracts'
2
+
3
+ /** Search appears when the list is long even if no conversation carries a protocol. */
4
+ export const PROTOCOL_SEARCH_CONVERSATION_THRESHOLD = 8
5
+
6
+ export function normalizeProtocolQuery(text: string): string {
7
+ return text.replace(/[-\s]/g, '').toUpperCase()
8
+ }
9
+
10
+ export function matchesProtocolQuery(protocol: string | undefined, query: string): boolean {
11
+ const normalizedQuery = normalizeProtocolQuery(query)
12
+ if (normalizedQuery === '') return true
13
+ if (protocol === undefined) return false
14
+ return normalizeProtocolQuery(protocol).includes(normalizedQuery)
15
+ }
16
+
17
+ function foldText(text: string): string {
18
+ return text.normalize('NFD').replace(/\p{M}/gu, '').toLowerCase()
19
+ }
20
+
21
+ function matchesTitle(subjectLabel: string, query: string): boolean {
22
+ return foldText(subjectLabel).includes(foldText(query))
23
+ }
24
+
25
+ export function filterConversationsBySearch(
26
+ conversations: readonly ParticipantConversationSummary[],
27
+ query: string,
28
+ ): readonly ParticipantConversationSummary[] {
29
+ const trimmedQuery = query.trim()
30
+ if (trimmedQuery === '') return conversations
31
+ const hasProtocolTerm = normalizeProtocolQuery(trimmedQuery) !== ''
32
+ return conversations.filter(
33
+ (conversation) =>
34
+ matchesTitle(conversation.subjectLabel, trimmedQuery) ||
35
+ (hasProtocolTerm && matchesProtocolQuery(conversation.protocol, trimmedQuery)),
36
+ )
37
+ }
38
+
39
+ export type InboxSearchVisibilityParams = {
40
+ readonly conversations: readonly ParticipantConversationSummary[]
41
+ readonly searchQuery: string
42
+ }
43
+
44
+ /** An applied query keeps the field, so a refresh that shrinks the list cannot strand it. */
45
+ export function isInboxSearchVisible({ conversations, searchQuery }: InboxSearchVisibilityParams): boolean {
46
+ return searchQuery !== '' || shouldShowInboxSearch(conversations)
47
+ }
48
+
49
+ export function shouldShowInboxSearch(conversations: readonly ParticipantConversationSummary[]): boolean {
50
+ if (conversations.length > PROTOCOL_SEARCH_CONVERSATION_THRESHOLD) return true
51
+ return conversations.some((conversation) => conversation.protocol !== undefined)
52
+ }
@@ -0,0 +1,45 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+
3
+ import { INITIAL_PROTOCOL_COPY_STATE, copyProtocolToClipboard, protocolCopyReducer } from './participantProtocolCopy'
4
+
5
+ describe('copyProtocolToClipboard', () => {
6
+ it('writes the protocol and reports success', async () => {
7
+ const written: string[] = []
8
+ const clipboard = { writeText: async (text: string) => void written.push(text) }
9
+ expect(await copyProtocolToClipboard('261009-K7M2', clipboard)).toBe(true)
10
+ expect(written).toEqual(['261009-K7M2'])
11
+ })
12
+
13
+ it('reports failure silently when the clipboard rejects', async () => {
14
+ const clipboard = { writeText: async () => Promise.reject(new Error('denied')) }
15
+ expect(await copyProtocolToClipboard('261009-K7M2', clipboard)).toBe(false)
16
+ })
17
+
18
+ it('reports failure when there is no clipboard', async () => {
19
+ expect(await copyProtocolToClipboard('261009-K7M2', undefined)).toBe(false)
20
+ })
21
+ })
22
+
23
+ describe('protocolCopyReducer', () => {
24
+ it('starts not copied', () => {
25
+ expect(INITIAL_PROTOCOL_COPY_STATE).toEqual({ copyCount: 0, isCopied: false })
26
+ })
27
+
28
+ it('a copy marks it copied and counts it', () => {
29
+ expect(protocolCopyReducer(INITIAL_PROTOCOL_COPY_STATE, { type: 'copied' })).toEqual({
30
+ copyCount: 1,
31
+ isCopied: true,
32
+ })
33
+ })
34
+
35
+ it('a second copy while still copied bumps the counter so the timer restarts and the notice is announced again', () => {
36
+ const first = protocolCopyReducer(INITIAL_PROTOCOL_COPY_STATE, { type: 'copied' })
37
+ const second = protocolCopyReducer(first, { type: 'copied' })
38
+ expect(second).toEqual({ copyCount: 2, isCopied: true })
39
+ })
40
+
41
+ it('expiry clears the notice and keeps the count', () => {
42
+ const copied = protocolCopyReducer(INITIAL_PROTOCOL_COPY_STATE, { type: 'copied' })
43
+ expect(protocolCopyReducer(copied, { type: 'expired' })).toEqual({ copyCount: 1, isCopied: false })
44
+ })
45
+ })
@@ -0,0 +1,32 @@
1
+ export type ProtocolClipboard = {
2
+ readonly writeText: (text: string) => Promise<void>
3
+ }
4
+
5
+ /** Failure is silent by design: the protocol stays visible and selectable. */
6
+ export async function copyProtocolToClipboard(
7
+ protocol: string,
8
+ clipboard: ProtocolClipboard | undefined,
9
+ ): Promise<boolean> {
10
+ if (clipboard === undefined) return false
11
+ try {
12
+ await clipboard.writeText(protocol)
13
+ return true
14
+ } catch {
15
+ return false
16
+ }
17
+ }
18
+
19
+ export type ProtocolCopyState = {
20
+ readonly copyCount: number
21
+ readonly isCopied: boolean
22
+ }
23
+
24
+ export type ProtocolCopyAction = { readonly type: 'copied' } | { readonly type: 'expired' }
25
+
26
+ export const INITIAL_PROTOCOL_COPY_STATE: ProtocolCopyState = { copyCount: 0, isCopied: false }
27
+
28
+ /** The counter changes on every copy so a repeat inside the window restarts the timer. */
29
+ export function protocolCopyReducer(state: ProtocolCopyState, action: ProtocolCopyAction): ProtocolCopyState {
30
+ if (action.type === 'copied') return { copyCount: state.copyCount + 1, isCopied: true }
31
+ return { ...state, isCopied: false }
32
+ }
@@ -0,0 +1,82 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { describe, expect, it } from 'bun:test'
4
+
5
+ import { splitBlocks } from './cssBlocks.test-helper'
6
+
7
+ const CSS = readFileSync(join(import.meta.dir, 'styles.css'), 'utf8').replace(/\/\*[\s\S]*?\*\//g, '')
8
+
9
+ const NON_SHRINKING_ITEMS = [
10
+ '.cv-p-filters',
11
+ '.cv-p-quick',
12
+ '.cv-p-search',
13
+ '.cv-p-composer',
14
+ '.cv-p-thread__header',
15
+ '.cv-p-thread__subject-card',
16
+ ]
17
+
18
+ type FlexState = { grow: number; shrink: number }
19
+
20
+ const FLEX_KEYWORDS: Record<string, FlexState> = {
21
+ none: { grow: 0, shrink: 0 },
22
+ auto: { grow: 1, shrink: 1 },
23
+ initial: { grow: 0, shrink: 1 },
24
+ }
25
+
26
+ function parseFlexShorthand(value: string): FlexState {
27
+ const keyword = FLEX_KEYWORDS[value]
28
+ if (keyword) return { ...keyword }
29
+ const numbers = value
30
+ .split(/\s+/)
31
+ .filter((token) => /^\d*\.?\d+$/.test(token))
32
+ .map(Number)
33
+ return { grow: numbers[0] ?? 0, shrink: numbers[1] ?? 1 }
34
+ }
35
+
36
+ function cleanValue(raw: string): string {
37
+ return raw
38
+ .replace(/!important/g, '')
39
+ .trim()
40
+ .toLowerCase()
41
+ }
42
+
43
+ // Only depth-0 style rules count (@media/@supports are conditional); later declarations win.
44
+ function effectiveFlex(selector: string): FlexState | undefined {
45
+ let state: FlexState | undefined
46
+ for (const { prelude, body } of splitBlocks(CSS)) {
47
+ if (body === undefined || prelude.startsWith('@')) continue
48
+ if (!prelude.split(',').some((entry) => entry.trim() === selector)) continue
49
+ for (const declaration of body.split(';')) {
50
+ const separator = declaration.indexOf(':')
51
+ if (separator === -1) continue
52
+ const property = declaration.slice(0, separator).trim().toLowerCase()
53
+ const value = cleanValue(declaration.slice(separator + 1))
54
+ if (property === 'flex') state = parseFlexShorthand(value)
55
+ if (property === 'flex-shrink') state = { grow: state?.grow ?? 0, shrink: Number(value) }
56
+ }
57
+ }
58
+ return state
59
+ }
60
+
61
+ function hasMinHeightZero(selector: string): boolean {
62
+ return splitBlocks(CSS).some(
63
+ ({ prelude, body }) =>
64
+ body !== undefined &&
65
+ !prelude.startsWith('@') &&
66
+ prelude.split(',').some((entry) => entry.trim() === selector) &&
67
+ /min-height:\s*0\b/.test(body),
68
+ )
69
+ }
70
+
71
+ describe('participant view flex layout', () => {
72
+ it.each(NON_SHRINKING_ITEMS)('%s does not shrink inside a flex column', (selector) => {
73
+ expect(effectiveFlex(selector)?.shrink).toBe(0)
74
+ })
75
+
76
+ it('keeps the thread scroller as the only shrinking, growing region', () => {
77
+ const scroller = effectiveFlex('.cv-p-thread__scroll')
78
+ expect(scroller?.grow).toBe(1)
79
+ expect(scroller?.shrink).toBe(1)
80
+ expect(hasMinHeightZero('.cv-p-thread__scroll')).toBe(true)
81
+ })
82
+ })