@webitel/ui-chats 26.8.3 → 26.8.5

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 (107) hide show
  1. package/package.json +9 -2
  2. package/src/locale/en/en.ts +3 -0
  3. package/src/locale/es/es.ts +3 -0
  4. package/src/locale/kz/kz.ts +3 -0
  5. package/src/locale/pl/pl.ts +3 -0
  6. package/src/locale/ro/ro.ts +3 -0
  7. package/src/locale/ru/ru.ts +3 -0
  8. package/src/locale/uk/uk.ts +3 -0
  9. package/src/locale/uz/uz.ts +3 -0
  10. package/src/locale/vi/vi.ts +3 -0
  11. package/src/shared/composables/__tests__/useChatScroll.spec.ts +164 -0
  12. package/src/shared/composables/__tests__/useObserveHeightUntilStable.spec.ts +48 -0
  13. package/src/{ui/messaging → shared}/composables/useChatScroll.ts +37 -9
  14. package/src/{ui/messaging → shared}/composables/useObserveHeightUntilStable.ts +2 -0
  15. package/src/ui/index.ts +2 -2
  16. package/src/ui/messaging/components/the-messages-container.vue +2 -2
  17. package/src/v2/index.ts +4 -0
  18. package/src/v2/locale/en.ts +29 -0
  19. package/src/v2/locale/es.ts +28 -0
  20. package/src/v2/locale/kz.ts +28 -0
  21. package/src/v2/locale/pl.ts +28 -0
  22. package/src/v2/locale/ro.ts +28 -0
  23. package/src/v2/locale/ru.ts +27 -0
  24. package/src/v2/locale/uk.ts +27 -0
  25. package/src/v2/locale/useChatsV2I18n.ts +14 -0
  26. package/src/v2/locale/uz.ts +28 -0
  27. package/src/v2/locale/vi.ts +28 -0
  28. package/src/v2/scripts/__tests__/attachments.spec.ts +168 -0
  29. package/src/v2/scripts/__tests__/fixtures.ts +56 -0
  30. package/src/v2/scripts/__tests__/formatDate.spec.ts +96 -0
  31. package/src/v2/scripts/__tests__/getMessageStatus.spec.ts +304 -0
  32. package/src/v2/scripts/__tests__/isContactCentreSide.spec.ts +21 -0
  33. package/src/v2/scripts/__tests__/resolveSystemNotice.spec.ts +157 -0
  34. package/src/v2/scripts/__tests__/toHistoryItems.spec.ts +100 -0
  35. package/src/v2/scripts/attachments.ts +144 -0
  36. package/src/v2/scripts/formatDate.ts +74 -0
  37. package/src/v2/scripts/getMessageStatus.ts +59 -0
  38. package/src/v2/scripts/isContactCentreSide.ts +14 -0
  39. package/src/v2/scripts/isSelfMessage.ts +29 -0
  40. package/src/v2/scripts/resolveSystemNotice.ts +119 -0
  41. package/src/v2/scripts/toHistoryItems.ts +63 -0
  42. package/src/v2/scripts/toTimestamp.ts +21 -0
  43. package/src/v2/types/index.ts +74 -0
  44. package/src/v2/ui/__tests__/mountWithChats.ts +38 -0
  45. package/src/v2/ui/chat-composer/__tests__/chat-composer.spec.ts +221 -0
  46. package/src/v2/ui/chat-composer/chat-composer.vue +212 -0
  47. package/src/v2/ui/chat-history/__tests__/chat-history.spec.ts +334 -0
  48. package/src/v2/ui/chat-history/chat-history.vue +262 -0
  49. package/src/v2/ui/chat-history/components/chat-date-divider.vue +36 -0
  50. package/src/v2/ui/chat-history/components/chat-message.vue +156 -0
  51. package/src/v2/ui/chat-history/components/chat-system-notice.vue +111 -0
  52. package/src/v2/ui/chat-history/components/message-attachments.vue +162 -0
  53. package/src/v2/ui/chat-history/components/message-status.vue +31 -0
  54. package/src/v2/ui/chat-history/components/message-text.vue +37 -0
  55. package/src/v2/ui/chat-history/components/scroll-to-bottom-btn.vue +55 -0
  56. package/src/v2/ui/chat-history/gallery.ts +8 -0
  57. package/src/v2/ui/chat-thread/__tests__/chat-thread.spec.ts +122 -0
  58. package/src/v2/ui/chat-thread/chat-thread.vue +111 -0
  59. package/types/.tsbuildinfo +1 -1
  60. package/types/locale/en/en.d.ts +25 -0
  61. package/types/locale/es/es.d.ts +25 -0
  62. package/types/locale/index.d.ts +225 -0
  63. package/types/locale/kz/kz.d.ts +25 -0
  64. package/types/locale/pl/pl.d.ts +25 -0
  65. package/types/locale/ro/ro.d.ts +25 -0
  66. package/types/locale/ru/ru.d.ts +25 -0
  67. package/types/locale/uk/uk.d.ts +25 -0
  68. package/types/locale/uz/uz.d.ts +25 -0
  69. package/types/locale/vi/vi.d.ts +25 -0
  70. package/types/{ui/messaging → shared}/composables/useChatScroll.d.ts +14 -4
  71. package/types/ui/index.d.ts +2 -2
  72. package/types/v2/index.d.ts +4 -0
  73. package/types/v2/locale/en.d.ts +27 -0
  74. package/types/v2/locale/es.d.ts +26 -0
  75. package/types/v2/locale/kz.d.ts +26 -0
  76. package/types/v2/locale/pl.d.ts +26 -0
  77. package/types/v2/locale/ro.d.ts +26 -0
  78. package/types/v2/locale/ru.d.ts +26 -0
  79. package/types/v2/locale/uk.d.ts +26 -0
  80. package/types/v2/locale/useChatsV2I18n.d.ts +5 -0
  81. package/types/v2/locale/uz.d.ts +26 -0
  82. package/types/v2/locale/vi.d.ts +26 -0
  83. package/types/v2/scripts/__tests__/fixtures.d.ts +7 -0
  84. package/types/v2/scripts/attachments.d.ts +30 -0
  85. package/types/v2/scripts/formatDate.d.ts +17 -0
  86. package/types/v2/scripts/getMessageStatus.d.ts +8 -0
  87. package/types/v2/scripts/isContactCentreSide.d.ts +8 -0
  88. package/types/v2/scripts/isSelfMessage.d.ts +10 -0
  89. package/types/v2/scripts/resolveSystemNotice.d.ts +23 -0
  90. package/types/v2/scripts/toHistoryItems.d.ts +19 -0
  91. package/types/v2/scripts/toTimestamp.d.ts +7 -0
  92. package/types/v2/types/index.d.ts +54 -0
  93. package/types/v2/ui/__tests__/mountWithChats.d.ts +8 -0
  94. package/types/v2/ui/chat-composer/chat-composer.vue.d.ts +31 -0
  95. package/types/v2/ui/chat-history/chat-history.vue.d.ts +11 -0
  96. package/types/v2/ui/chat-history/components/chat-date-divider.vue.d.ts +6 -0
  97. package/types/v2/ui/chat-history/components/chat-message.vue.d.ts +10 -0
  98. package/types/v2/ui/chat-history/components/chat-system-notice.vue.d.ts +9 -0
  99. package/types/v2/ui/chat-history/components/message-attachments.vue.d.ts +7 -0
  100. package/types/v2/ui/chat-history/components/message-status.vue.d.ts +7 -0
  101. package/types/v2/ui/chat-history/components/message-text.vue.d.ts +6 -0
  102. package/types/v2/ui/chat-history/components/scroll-to-bottom-btn.vue.d.ts +12 -0
  103. package/types/v2/ui/chat-history/gallery.d.ts +6 -0
  104. package/types/v2/ui/chat-thread/chat-thread.vue.d.ts +26 -0
  105. /package/src/{ui/messaging → shared}/composables/useScrollToBottomBtn.ts +0 -0
  106. /package/types/{ui/messaging → shared}/composables/useObserveHeightUntilStable.d.ts +0 -0
  107. /package/types/{ui/messaging → shared}/composables/useScrollToBottomBtn.d.ts +0 -0
@@ -0,0 +1,304 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { getMessageStatus } from '../getMessageStatus';
4
+ import { bot, client, message, operator, thread } from './fixtures';
5
+
6
+ const own = (seq: string) =>
7
+ message({
8
+ sender: operator,
9
+ seq,
10
+ });
11
+
12
+ describe('getMessageStatus', () => {
13
+ it('shows no ticks on the client’s messages', () => {
14
+ expect(
15
+ getMessageStatus(
16
+ message({
17
+ sender: client,
18
+ }),
19
+ thread(),
20
+ operator.id,
21
+ ),
22
+ ).toBeNull();
23
+ });
24
+
25
+ it('shows no ticks on deleted or system messages', () => {
26
+ expect(
27
+ getMessageStatus(
28
+ {
29
+ ...own('1'),
30
+ deleted: true,
31
+ },
32
+ thread(),
33
+ operator.id,
34
+ ),
35
+ ).toBeNull();
36
+ expect(
37
+ getMessageStatus(
38
+ {
39
+ ...own('1'),
40
+ system: {
41
+ type: 'transferred',
42
+ },
43
+ },
44
+ thread(),
45
+ operator.id,
46
+ ),
47
+ ).toBeNull();
48
+ });
49
+
50
+ it('is sent without any client read state', () => {
51
+ expect(getMessageStatus(own('5'), thread(), operator.id)).toBe('sent');
52
+ });
53
+
54
+ it('is sent when the message has no seq', () => {
55
+ expect(
56
+ getMessageStatus(
57
+ {
58
+ ...own('5'),
59
+ seq: undefined,
60
+ },
61
+ thread(),
62
+ operator.id,
63
+ ),
64
+ ).toBe('sent');
65
+ });
66
+
67
+ // generated types say string | undefined, but JSON can carry null; Number(null) is 0
68
+ it('is sent, not read, when seq arrives as null', () => {
69
+ const t = thread({
70
+ readStates: [
71
+ {
72
+ memberId: client.id,
73
+ readUpToSeq: '0',
74
+ },
75
+ ],
76
+ });
77
+ expect(
78
+ getMessageStatus(
79
+ {
80
+ ...own('1'),
81
+ seq: null as never,
82
+ },
83
+ t,
84
+ operator.id,
85
+ ),
86
+ ).toBe('sent');
87
+ });
88
+
89
+ it('follows the client horizons', () => {
90
+ const t = thread({
91
+ readStates: [
92
+ {
93
+ memberId: client.id,
94
+ deliveredUpToSeq: '7',
95
+ readUpToSeq: '5',
96
+ },
97
+ ],
98
+ });
99
+ expect(getMessageStatus(own('5'), t, operator.id)).toBe('read');
100
+ expect(getMessageStatus(own('6'), t, operator.id)).toBe('delivered');
101
+ expect(getMessageStatus(own('8'), t, operator.id)).toBe('sent');
102
+ });
103
+
104
+ // Review Focus #1
105
+ it('compares seq numerically, not as strings', () => {
106
+ const t = thread({
107
+ readStates: [
108
+ {
109
+ memberId: client.id,
110
+ deliveredUpToSeq: '9',
111
+ readUpToSeq: '9',
112
+ },
113
+ ],
114
+ });
115
+ expect(getMessageStatus(own('10'), t, operator.id)).toBe('sent');
116
+ expect(getMessageStatus(own('9'), t, operator.id)).toBe('read');
117
+ });
118
+
119
+ it('ignores the contact-centre side’s own horizons', () => {
120
+ const t = thread({
121
+ members: [
122
+ operator,
123
+ client,
124
+ bot,
125
+ ],
126
+ readStates: [
127
+ {
128
+ memberId: operator.id,
129
+ readUpToSeq: '100',
130
+ },
131
+ {
132
+ memberId: bot.id,
133
+ readUpToSeq: '100',
134
+ },
135
+ ],
136
+ });
137
+ expect(getMessageStatus(own('3'), t, operator.id)).toBe('sent');
138
+ });
139
+
140
+ it('takes the lowest horizon over several clients', () => {
141
+ const second = {
142
+ id: 'm-client-2',
143
+ contact: {
144
+ name: 'Bob',
145
+ type: 'viber',
146
+ },
147
+ };
148
+ const t = thread({
149
+ members: [
150
+ operator,
151
+ client,
152
+ second,
153
+ ],
154
+ readStates: [
155
+ {
156
+ memberId: client.id,
157
+ readUpToSeq: '10',
158
+ },
159
+ {
160
+ memberId: second.id,
161
+ deliveredUpToSeq: '8',
162
+ readUpToSeq: '4',
163
+ },
164
+ ],
165
+ });
166
+ expect(getMessageStatus(own('6'), t, operator.id)).toBe('delivered');
167
+ expect(getMessageStatus(own('9'), t, operator.id)).toBe('sent');
168
+ });
169
+
170
+ it('treats a member’s read horizon as delivered for that member', () => {
171
+ const second = {
172
+ id: 'm-client-2',
173
+ contact: {
174
+ name: 'Bob',
175
+ type: 'viber',
176
+ },
177
+ };
178
+ const t = thread({
179
+ members: [
180
+ operator,
181
+ client,
182
+ second,
183
+ ],
184
+ readStates: [
185
+ {
186
+ memberId: client.id,
187
+ deliveredUpToSeq: '10',
188
+ readUpToSeq: '0',
189
+ },
190
+ {
191
+ memberId: second.id,
192
+ deliveredUpToSeq: '0',
193
+ readUpToSeq: '10',
194
+ },
195
+ ],
196
+ });
197
+ expect(getMessageStatus(own('6'), t, operator.id)).toBe('delivered');
198
+ });
199
+
200
+ it('uses the member carried on the read state when it left thread.members', () => {
201
+ const t = thread({
202
+ members: [
203
+ operator,
204
+ ],
205
+ readStates: [
206
+ {
207
+ member: client,
208
+ readUpToSeq: '3',
209
+ },
210
+ ],
211
+ });
212
+ expect(getMessageStatus(own('3'), t, operator.id)).toBe('read');
213
+ });
214
+
215
+ // A re-joined operator gets a new member id, but the thread snapshot (and so
216
+ // selfMemberId) still holds the old one; their contact is the stable identity.
217
+ describe('after the operator rejoined with a new member id', () => {
218
+ const oldMember = {
219
+ id: 'm-me-old',
220
+ contact: {
221
+ name: 'Dania',
222
+ type: 'webitel',
223
+ sub: '164',
224
+ iss: 'webitel',
225
+ },
226
+ };
227
+ const newMember = {
228
+ id: 'm-me-new',
229
+ contact: {
230
+ name: 'Dania',
231
+ type: 'webitel',
232
+ sub: '164',
233
+ iss: 'webitel',
234
+ },
235
+ };
236
+ const t = thread({
237
+ members: [
238
+ oldMember,
239
+ client,
240
+ ],
241
+ readStates: [
242
+ {
243
+ memberId: client.id,
244
+ readUpToSeq: '5',
245
+ },
246
+ ],
247
+ });
248
+
249
+ it('still treats their messages as their own', () => {
250
+ expect(
251
+ getMessageStatus(
252
+ message({
253
+ sender: newMember,
254
+ seq: '3',
255
+ }),
256
+ t,
257
+ oldMember.id,
258
+ ),
259
+ ).toBe('read');
260
+ });
261
+
262
+ it('does not claim another operator’s message', () => {
263
+ const other = {
264
+ id: 'm-other',
265
+ contact: {
266
+ name: 'Ola',
267
+ type: 'webitel',
268
+ sub: '289',
269
+ iss: 'webitel',
270
+ },
271
+ };
272
+ expect(
273
+ getMessageStatus(
274
+ message({
275
+ sender: other,
276
+ }),
277
+ t,
278
+ oldMember.id,
279
+ ),
280
+ ).toBeNull();
281
+ });
282
+
283
+ it('does not match the same subject from another issuer', () => {
284
+ const lookalike = {
285
+ id: 'm-x',
286
+ contact: {
287
+ name: 'Dania',
288
+ type: 'telegram',
289
+ sub: '164',
290
+ iss: 'telegram',
291
+ },
292
+ };
293
+ expect(
294
+ getMessageStatus(
295
+ message({
296
+ sender: lookalike,
297
+ }),
298
+ t,
299
+ oldMember.id,
300
+ ),
301
+ ).toBeNull();
302
+ });
303
+ });
304
+ });
@@ -0,0 +1,21 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { isContactCentreSide } from '../isContactCentreSide';
4
+ import { bot, client, operator } from './fixtures';
5
+
6
+ describe('isContactCentreSide', () => {
7
+ it('puts operators and bots on the contact-centre side', () => {
8
+ expect(isContactCentreSide(operator)).toBe(true);
9
+ expect(isContactCentreSide(bot)).toBe(true);
10
+ });
11
+
12
+ it('puts clients and unknown senders on the client side', () => {
13
+ expect(isContactCentreSide(client)).toBe(false);
14
+ expect(isContactCentreSide(undefined)).toBe(false);
15
+ expect(
16
+ isContactCentreSide({
17
+ id: 'x',
18
+ }),
19
+ ).toBe(false);
20
+ });
21
+ });
@@ -0,0 +1,157 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { participantName, resolveSystemNotice } from '../resolveSystemNotice';
4
+ import { client, colleague, message, operator, thread } from './fixtures';
5
+
6
+ describe('resolveSystemNotice', () => {
7
+ it('maps a known type to its text and tone', () => {
8
+ const notice = resolveSystemNotice(
9
+ message({
10
+ sender: operator,
11
+ system: {
12
+ type: 'transferred',
13
+ },
14
+ }),
15
+ thread(),
16
+ );
17
+ expect(notice).toMatchObject({
18
+ textKey: 'systemNotice.transferred',
19
+ tone: 'neutral',
20
+ });
21
+ expect(notice.actor?.id).toBe(operator.id);
22
+ });
23
+
24
+ it('colours start positive and end negative', () => {
25
+ expect(
26
+ resolveSystemNotice(
27
+ message({
28
+ system: {
29
+ type: 'thread_started',
30
+ },
31
+ }),
32
+ thread(),
33
+ ).tone,
34
+ ).toBe('positive');
35
+ expect(
36
+ resolveSystemNotice(
37
+ message({
38
+ system: {
39
+ type: 'thread_closed',
40
+ },
41
+ }),
42
+ thread(),
43
+ ).tone,
44
+ ).toBe('negative');
45
+ });
46
+
47
+ it('prefers the actor named in metadata over the sender', () => {
48
+ const t = thread({
49
+ members: [
50
+ operator,
51
+ colleague,
52
+ client,
53
+ ],
54
+ });
55
+ const notice = resolveSystemNotice(
56
+ message({
57
+ sender: operator,
58
+ system: {
59
+ type: 'member_added',
60
+ metadata: {
61
+ memberId: colleague.id,
62
+ },
63
+ },
64
+ }),
65
+ t,
66
+ );
67
+ expect(notice.actor?.id).toBe(colleague.id);
68
+ });
69
+
70
+ it('reads the joined / removed member from the chat-web-sdk metadata keys', () => {
71
+ const t = thread({
72
+ members: [
73
+ operator,
74
+ colleague,
75
+ client,
76
+ ],
77
+ });
78
+ const joined = resolveSystemNotice(
79
+ message({
80
+ sender: operator,
81
+ system: {
82
+ type: 'member_added',
83
+ metadata: {
84
+ newMemberId: colleague.id,
85
+ },
86
+ },
87
+ }),
88
+ t,
89
+ );
90
+ const removed = resolveSystemNotice(
91
+ message({
92
+ sender: operator,
93
+ system: {
94
+ type: 'member_removed',
95
+ metadata: {
96
+ removedMemberId: colleague.id,
97
+ },
98
+ },
99
+ }),
100
+ t,
101
+ );
102
+ expect(joined.actor?.id).toBe(colleague.id);
103
+ expect(removed.actor?.id).toBe(colleague.id);
104
+ });
105
+
106
+ it('falls back to the backend text for an unknown type', () => {
107
+ const notice = resolveSystemNotice(
108
+ message({
109
+ body: 'Custom event',
110
+ system: {
111
+ type: 'something_new',
112
+ },
113
+ }),
114
+ thread(),
115
+ );
116
+ expect(notice).toMatchObject({
117
+ textKey: null,
118
+ fallbackText: 'Custom event',
119
+ tone: 'neutral',
120
+ });
121
+ });
122
+
123
+ it('falls back to the generic text when an unknown type has no body', () => {
124
+ const notice = resolveSystemNotice(
125
+ message({
126
+ body: undefined,
127
+ system: {
128
+ type: 'something_new',
129
+ },
130
+ }),
131
+ thread(),
132
+ );
133
+ expect(notice).toMatchObject({
134
+ textKey: 'systemNotice.unknown',
135
+ fallbackText: null,
136
+ });
137
+ });
138
+
139
+ // Review Focus #5
140
+ it('keeps the name of an actor who already left the thread', () => {
141
+ const notice = resolveSystemNotice(
142
+ message({
143
+ sender: colleague,
144
+ system: {
145
+ type: 'member_removed',
146
+ },
147
+ }),
148
+ thread({
149
+ members: [
150
+ operator,
151
+ client,
152
+ ],
153
+ }),
154
+ );
155
+ expect(participantName(notice.actor)).toBe('Oleh Bondar');
156
+ });
157
+ });
@@ -0,0 +1,100 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { toHistoryItems } from '../toHistoryItems';
4
+ import { message } from './fixtures';
5
+
6
+ const at = (y: number, m: number, d: number, h = 12) =>
7
+ String(new Date(y, m, d, h).getTime());
8
+
9
+ describe('toHistoryItems', () => {
10
+ it('opens each local day with one divider', () => {
11
+ const items = toHistoryItems([
12
+ message({
13
+ createdAt: at(2026, 8, 15, 9),
14
+ }),
15
+ message({
16
+ createdAt: at(2026, 8, 15, 18),
17
+ }),
18
+ message({
19
+ createdAt: at(2026, 8, 16, 9),
20
+ }),
21
+ ]);
22
+ expect(items.map((item) => item.kind)).toEqual([
23
+ 'divider',
24
+ 'message',
25
+ 'message',
26
+ 'divider',
27
+ 'message',
28
+ ]);
29
+ });
30
+
31
+ it('marks system messages as system rows', () => {
32
+ const items = toHistoryItems([
33
+ message({
34
+ system: {
35
+ type: 'transferred',
36
+ },
37
+ }),
38
+ ]);
39
+ expect(items.at(-1)?.kind).toBe('system');
40
+ });
41
+
42
+ it('does not open a day for an item without createdAt', () => {
43
+ const items = toHistoryItems([
44
+ message({
45
+ createdAt: undefined,
46
+ }),
47
+ message({
48
+ createdAt: '0',
49
+ }),
50
+ ]);
51
+ expect(items.map((item) => item.kind)).toEqual([
52
+ 'message',
53
+ 'message',
54
+ ]);
55
+ });
56
+
57
+ it('keys message rows by message id', () => {
58
+ const first = message();
59
+ expect(
60
+ toHistoryItems([
61
+ first,
62
+ ]).at(-1)?.key,
63
+ ).toBe(first.id);
64
+ });
65
+
66
+ it('keeps row keys unique when a day shows up twice out of order', () => {
67
+ const items = toHistoryItems([
68
+ message({
69
+ createdAt: at(2026, 8, 15),
70
+ }),
71
+ message({
72
+ createdAt: at(2026, 8, 16),
73
+ }),
74
+ message({
75
+ createdAt: at(2026, 8, 15, 23),
76
+ }),
77
+ ]);
78
+ const keys = items.map((item) => item.key);
79
+ expect(new Set(keys).size).toBe(keys.length);
80
+ });
81
+
82
+ // 20:30 and 21:30 UTC fall on one UTC day but straddle midnight in Kyiv (UTC+3)
83
+ it('splits days in the given time zone', () => {
84
+ const messages = [
85
+ message({
86
+ createdAt: String(Date.UTC(2026, 8, 15, 20, 30)),
87
+ }),
88
+ message({
89
+ createdAt: String(Date.UTC(2026, 8, 15, 21, 30)),
90
+ }),
91
+ ];
92
+ const dividers = (timeZone: string) =>
93
+ toHistoryItems(messages, {
94
+ timeZone,
95
+ }).filter((item) => item.kind === 'divider').length;
96
+
97
+ expect(dividers('UTC')).toBe(1);
98
+ expect(dividers('Europe/Kyiv')).toBe(2);
99
+ });
100
+ });
@@ -0,0 +1,144 @@
1
+ import type { ChatDocument, ChatImage, MessageModel } from '../types';
2
+
3
+ export interface ChatFileCard {
4
+ key: string;
5
+ name: string;
6
+ size?: string;
7
+ mime?: string;
8
+ url?: string;
9
+ }
10
+
11
+ export interface ClassifiedAttachments {
12
+ images: Array<{
13
+ key: string;
14
+ image: ChatImage;
15
+ }>;
16
+ media: Array<{
17
+ key: string;
18
+ document: ChatDocument;
19
+ kind: 'audio' | 'video';
20
+ }>;
21
+ files: ChatFileCard[];
22
+ }
23
+
24
+ export interface GalleryImage {
25
+ key: string;
26
+ src: string;
27
+ }
28
+
29
+ export const imageKey = (
30
+ message: MessageModel,
31
+ image: ChatImage,
32
+ index: number,
33
+ ): string => image.id ?? `${message.id}-image-${index}`;
34
+
35
+ const documentKey = (
36
+ message: MessageModel,
37
+ document: ChatDocument,
38
+ index: number,
39
+ ): string => document.id ?? `${message.id}-document-${index}`;
40
+
41
+ /** Most browsers cannot draw HEIC / HEIF; those go out as file cards. */
42
+ const UNDRAWABLE_IMAGE_MIME = /hei[cf]/i;
43
+
44
+ export const isRenderableImage = (image: ChatImage): boolean =>
45
+ !!image.url && !UNDRAWABLE_IMAGE_MIME.test(image.mime ?? '');
46
+
47
+ const mediaKind = (mime: string | undefined): 'audio' | 'video' | null => {
48
+ const value = mime?.toLowerCase() ?? '';
49
+ if (value.startsWith('audio/')) return 'audio';
50
+ if (value.startsWith('video/')) return 'video';
51
+ return null;
52
+ };
53
+
54
+ /**
55
+ * A file card name for an image the browser cannot draw: the URL's last
56
+ * segment when it looks like a file name, else "image.<subtype>". Signed
57
+ * storage links often end in an id or an action ("…/123/download"), which
58
+ * is no name at all.
59
+ */
60
+ const imageFileName = (image: ChatImage): string => {
61
+ if (image.url) {
62
+ try {
63
+ const segment = decodeURIComponent(
64
+ new URL(image.url, 'http://localhost').pathname.split('/').pop() ?? '',
65
+ );
66
+ if (/\.[a-z0-9]+$/i.test(segment)) return segment;
67
+ } catch {
68
+ // malformed URL or escape: fall through to the generic name
69
+ }
70
+ }
71
+ const subtype = image.mime?.split('/')[1];
72
+ return subtype ? `image.${subtype}` : 'image';
73
+ };
74
+
75
+ export const hasAttachments = (message: MessageModel): boolean =>
76
+ !!(message.images?.length || message.documents?.length);
77
+
78
+ export const classifyAttachments = (
79
+ message: MessageModel,
80
+ ): ClassifiedAttachments => {
81
+ const result: ClassifiedAttachments = {
82
+ images: [],
83
+ media: [],
84
+ files: [],
85
+ };
86
+
87
+ (message.images ?? []).forEach((image, index) => {
88
+ const key = imageKey(message, image, index);
89
+ if (isRenderableImage(image)) {
90
+ result.images.push({
91
+ key,
92
+ image,
93
+ });
94
+ } else {
95
+ result.files.push({
96
+ key,
97
+ name: imageFileName(image),
98
+ mime: image.mime,
99
+ url: image.url,
100
+ });
101
+ }
102
+ });
103
+
104
+ (message.documents ?? []).forEach((document, index) => {
105
+ const key = documentKey(message, document, index);
106
+ const kind = document.url ? mediaKind(document.mime) : null;
107
+ if (kind) {
108
+ result.media.push({
109
+ key,
110
+ document,
111
+ kind,
112
+ });
113
+ } else {
114
+ result.files.push({
115
+ key,
116
+ name: document.name ?? '',
117
+ size: document.size,
118
+ mime: document.mime,
119
+ url: document.url,
120
+ });
121
+ }
122
+ });
123
+
124
+ return result;
125
+ };
126
+
127
+ /** Every drawable image across the history, in history order. */
128
+ export const collectGalleryImages = (
129
+ messages: readonly MessageModel[],
130
+ ): GalleryImage[] =>
131
+ messages.flatMap((message) =>
132
+ message.deleted
133
+ ? []
134
+ : (message.images ?? []).flatMap((image, index) =>
135
+ isRenderableImage(image)
136
+ ? [
137
+ {
138
+ key: imageKey(message, image, index),
139
+ src: image.url as string,
140
+ },
141
+ ]
142
+ : [],
143
+ ),
144
+ );