@webitel/ui-chats 26.8.2 → 26.8.4
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.
- package/package.json +9 -2
- package/src/locale/en/en.ts +3 -0
- package/src/locale/es/es.ts +3 -0
- package/src/locale/kz/kz.ts +3 -0
- package/src/locale/pl/pl.ts +3 -0
- package/src/locale/ro/ro.ts +3 -0
- package/src/locale/ru/ru.ts +3 -0
- package/src/locale/uk/uk.ts +3 -0
- package/src/locale/uz/uz.ts +3 -0
- package/src/locale/vi/vi.ts +3 -0
- package/src/shared/composables/__tests__/useChatScroll.spec.ts +164 -0
- package/src/shared/composables/__tests__/useObserveHeightUntilStable.spec.ts +48 -0
- package/src/{ui/messaging → shared}/composables/useChatScroll.ts +37 -9
- package/src/{ui/messaging → shared}/composables/useObserveHeightUntilStable.ts +2 -0
- package/src/ui/chat-footer/modules/user-input/components/actions/send-message-action.vue +1 -0
- package/src/ui/index.ts +2 -2
- package/src/ui/messaging/components/the-messages-container.vue +2 -2
- package/src/v2/index.ts +4 -0
- package/src/v2/locale/en.ts +29 -0
- package/src/v2/locale/es.ts +28 -0
- package/src/v2/locale/kz.ts +28 -0
- package/src/v2/locale/pl.ts +28 -0
- package/src/v2/locale/ro.ts +28 -0
- package/src/v2/locale/ru.ts +27 -0
- package/src/v2/locale/uk.ts +27 -0
- package/src/v2/locale/useChatsV2I18n.ts +14 -0
- package/src/v2/locale/uz.ts +28 -0
- package/src/v2/locale/vi.ts +28 -0
- package/src/v2/scripts/__tests__/attachments.spec.ts +168 -0
- package/src/v2/scripts/__tests__/fixtures.ts +56 -0
- package/src/v2/scripts/__tests__/formatDate.spec.ts +96 -0
- package/src/v2/scripts/__tests__/getMessageStatus.spec.ts +214 -0
- package/src/v2/scripts/__tests__/isContactCentreSide.spec.ts +21 -0
- package/src/v2/scripts/__tests__/resolveSystemNotice.spec.ts +157 -0
- package/src/v2/scripts/__tests__/toHistoryItems.spec.ts +100 -0
- package/src/v2/scripts/attachments.ts +144 -0
- package/src/v2/scripts/formatDate.ts +74 -0
- package/src/v2/scripts/getMessageStatus.ts +58 -0
- package/src/v2/scripts/isContactCentreSide.ts +14 -0
- package/src/v2/scripts/resolveSystemNotice.ts +119 -0
- package/src/v2/scripts/toHistoryItems.ts +63 -0
- package/src/v2/scripts/toTimestamp.ts +21 -0
- package/src/v2/types/index.ts +74 -0
- package/src/v2/ui/__tests__/mountWithChats.ts +38 -0
- package/src/v2/ui/chat-composer/__tests__/chat-composer.spec.ts +221 -0
- package/src/v2/ui/chat-composer/chat-composer.vue +212 -0
- package/src/v2/ui/chat-history/__tests__/chat-history.spec.ts +235 -0
- package/src/v2/ui/chat-history/chat-history.vue +262 -0
- package/src/v2/ui/chat-history/components/chat-date-divider.vue +36 -0
- package/src/v2/ui/chat-history/components/chat-message.vue +148 -0
- package/src/v2/ui/chat-history/components/chat-system-notice.vue +100 -0
- package/src/v2/ui/chat-history/components/message-attachments.vue +162 -0
- package/src/v2/ui/chat-history/components/message-status.vue +31 -0
- package/src/v2/ui/chat-history/components/message-text.vue +37 -0
- package/src/v2/ui/chat-history/components/scroll-to-bottom-btn.vue +55 -0
- package/src/v2/ui/chat-history/gallery.ts +8 -0
- package/src/v2/ui/chat-thread/__tests__/chat-thread.spec.ts +122 -0
- package/src/v2/ui/chat-thread/chat-thread.vue +111 -0
- package/types/.tsbuildinfo +1 -1
- package/types/locale/en/en.d.ts +25 -0
- package/types/locale/es/es.d.ts +25 -0
- package/types/locale/index.d.ts +225 -0
- package/types/locale/kz/kz.d.ts +25 -0
- package/types/locale/pl/pl.d.ts +25 -0
- package/types/locale/ro/ro.d.ts +25 -0
- package/types/locale/ru/ru.d.ts +25 -0
- package/types/locale/uk/uk.d.ts +25 -0
- package/types/locale/uz/uz.d.ts +25 -0
- package/types/locale/vi/vi.d.ts +25 -0
- package/types/{ui/messaging → shared}/composables/useChatScroll.d.ts +14 -4
- package/types/ui/index.d.ts +2 -2
- package/types/v2/index.d.ts +4 -0
- package/types/v2/locale/en.d.ts +27 -0
- package/types/v2/locale/es.d.ts +26 -0
- package/types/v2/locale/kz.d.ts +26 -0
- package/types/v2/locale/pl.d.ts +26 -0
- package/types/v2/locale/ro.d.ts +26 -0
- package/types/v2/locale/ru.d.ts +26 -0
- package/types/v2/locale/uk.d.ts +26 -0
- package/types/v2/locale/useChatsV2I18n.d.ts +5 -0
- package/types/v2/locale/uz.d.ts +26 -0
- package/types/v2/locale/vi.d.ts +26 -0
- package/types/v2/scripts/__tests__/fixtures.d.ts +7 -0
- package/types/v2/scripts/attachments.d.ts +30 -0
- package/types/v2/scripts/formatDate.d.ts +17 -0
- package/types/v2/scripts/getMessageStatus.d.ts +8 -0
- package/types/v2/scripts/isContactCentreSide.d.ts +8 -0
- package/types/v2/scripts/resolveSystemNotice.d.ts +23 -0
- package/types/v2/scripts/toHistoryItems.d.ts +19 -0
- package/types/v2/scripts/toTimestamp.d.ts +7 -0
- package/types/v2/types/index.d.ts +54 -0
- package/types/v2/ui/__tests__/mountWithChats.d.ts +8 -0
- package/types/v2/ui/chat-composer/chat-composer.vue.d.ts +31 -0
- package/types/v2/ui/chat-history/chat-history.vue.d.ts +11 -0
- package/types/v2/ui/chat-history/components/chat-date-divider.vue.d.ts +6 -0
- package/types/v2/ui/chat-history/components/chat-message.vue.d.ts +10 -0
- package/types/v2/ui/chat-history/components/chat-system-notice.vue.d.ts +9 -0
- package/types/v2/ui/chat-history/components/message-attachments.vue.d.ts +7 -0
- package/types/v2/ui/chat-history/components/message-status.vue.d.ts +7 -0
- package/types/v2/ui/chat-history/components/message-text.vue.d.ts +6 -0
- package/types/v2/ui/chat-history/components/scroll-to-bottom-btn.vue.d.ts +12 -0
- package/types/v2/ui/chat-history/gallery.d.ts +6 -0
- package/types/v2/ui/chat-thread/chat-thread.vue.d.ts +26 -0
- /package/src/{ui/messaging → shared}/composables/useScrollToBottomBtn.ts +0 -0
- /package/types/{ui/messaging → shared}/composables/useObserveHeightUntilStable.d.ts +0 -0
- /package/types/{ui/messaging → shared}/composables/useScrollToBottomBtn.d.ts +0 -0
|
@@ -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
|
+
);
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { getUserTimeZone } from '@webitel/api-services/utils';
|
|
2
|
+
import { isSameDay, isSameYear } from 'date-fns';
|
|
3
|
+
import { formatInTimeZone, toZonedTime } from 'date-fns-tz';
|
|
4
|
+
|
|
5
|
+
/** App locale codes that are not BCP 47 tags `Intl` understands. */
|
|
6
|
+
const INTL_LOCALE_ALIASES: Record<string, string> = {
|
|
7
|
+
kz: 'kk',
|
|
8
|
+
ua: 'uk',
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const toIntlLocale = (locale: string) => {
|
|
12
|
+
const candidate = INTL_LOCALE_ALIASES[locale] ?? locale;
|
|
13
|
+
try {
|
|
14
|
+
return Intl.DateTimeFormat.supportedLocalesOf(candidate).length
|
|
15
|
+
? candidate
|
|
16
|
+
: 'en';
|
|
17
|
+
} catch {
|
|
18
|
+
return 'en';
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Whether two instants fall on the same calendar day in `timeZone` — the
|
|
24
|
+
* operator's setting by default, the one the rest of the app formats with.
|
|
25
|
+
*/
|
|
26
|
+
export const isSameDayInZone = (
|
|
27
|
+
a: number,
|
|
28
|
+
b: number,
|
|
29
|
+
timeZone: string = getUserTimeZone(),
|
|
30
|
+
): boolean => isSameDay(toZonedTime(a, timeZone), toZonedTime(b, timeZone));
|
|
31
|
+
|
|
32
|
+
/** "Today" · "September 15" · "September 15, 2025" (AC_03.02.02). */
|
|
33
|
+
export const formatDividerDate = (
|
|
34
|
+
timestamp: number,
|
|
35
|
+
{
|
|
36
|
+
locale,
|
|
37
|
+
todayLabel,
|
|
38
|
+
now = Date.now(),
|
|
39
|
+
timeZone = getUserTimeZone(),
|
|
40
|
+
}: {
|
|
41
|
+
locale: string;
|
|
42
|
+
todayLabel: string;
|
|
43
|
+
now?: number;
|
|
44
|
+
timeZone?: string;
|
|
45
|
+
},
|
|
46
|
+
): string => {
|
|
47
|
+
if (isSameDayInZone(timestamp, now, timeZone)) return todayLabel;
|
|
48
|
+
|
|
49
|
+
const isThisYear = isSameYear(
|
|
50
|
+
toZonedTime(timestamp, timeZone),
|
|
51
|
+
toZonedTime(now, timeZone),
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
// Intl, not date-fns format: it has month names for every app locale
|
|
55
|
+
return new Intl.DateTimeFormat(toIntlLocale(locale), {
|
|
56
|
+
timeZone,
|
|
57
|
+
month: 'long',
|
|
58
|
+
day: 'numeric',
|
|
59
|
+
...(isThisYear
|
|
60
|
+
? {}
|
|
61
|
+
: {
|
|
62
|
+
year: 'numeric',
|
|
63
|
+
}),
|
|
64
|
+
}).format(timestamp);
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* 24h "HH:mm", as in DES-730, in the operator's time zone. Not the shared
|
|
69
|
+
* formatDate 'time' mode: its uk 'p' pattern drops the leading zero ("9:05").
|
|
70
|
+
*/
|
|
71
|
+
export const formatMessageTime = (
|
|
72
|
+
timestamp: number,
|
|
73
|
+
timeZone: string = getUserTimeZone(),
|
|
74
|
+
): string => formatInTimeZone(timestamp, timeZone, 'HH:mm');
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { ChatMessageStatus, MessageModel, ThreadModel } from '../types';
|
|
2
|
+
import { isContactCentreSide } from './isContactCentreSide';
|
|
3
|
+
import { toSeq } from './toTimestamp';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Sent / delivered / read for the operator's own messages, against the
|
|
7
|
+
* client's read horizon: the lowest horizon over every client member, so a
|
|
8
|
+
* message counts as read only once all clients have read it.
|
|
9
|
+
* `null` when no ticks should show.
|
|
10
|
+
*/
|
|
11
|
+
export const getMessageStatus = (
|
|
12
|
+
message: MessageModel,
|
|
13
|
+
thread: ThreadModel,
|
|
14
|
+
selfMemberId: string,
|
|
15
|
+
): ChatMessageStatus | null => {
|
|
16
|
+
if (message.deleted || message.system) return null;
|
|
17
|
+
if (!message.sender?.id || message.sender.id !== selfMemberId) return null;
|
|
18
|
+
|
|
19
|
+
const seq = toSeq(message.seq);
|
|
20
|
+
if (seq === null) return 'sent';
|
|
21
|
+
|
|
22
|
+
const membersById = new Map(
|
|
23
|
+
(thread.members ?? []).flatMap((member) =>
|
|
24
|
+
member.id
|
|
25
|
+
? [
|
|
26
|
+
[
|
|
27
|
+
member.id,
|
|
28
|
+
member,
|
|
29
|
+
] as const,
|
|
30
|
+
]
|
|
31
|
+
: [],
|
|
32
|
+
),
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
const clientStates = (thread.readStates ?? []).filter((state) => {
|
|
36
|
+
const member =
|
|
37
|
+
state.member ??
|
|
38
|
+
(state.memberId ? membersById.get(state.memberId) : undefined);
|
|
39
|
+
return member !== undefined && !isContactCentreSide(member);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
if (!clientStates.length) return 'sent';
|
|
43
|
+
|
|
44
|
+
const horizons = clientStates.map((state) => {
|
|
45
|
+
const read = toSeq(state.readUpToSeq) ?? 0;
|
|
46
|
+
// reading a message implies it was delivered, even if the delivered
|
|
47
|
+
// horizon lags behind
|
|
48
|
+
const delivered = Math.max(toSeq(state.deliveredUpToSeq) ?? 0, read);
|
|
49
|
+
return {
|
|
50
|
+
read,
|
|
51
|
+
delivered,
|
|
52
|
+
};
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
if (horizons.every((horizon) => seq <= horizon.read)) return 'read';
|
|
56
|
+
if (horizons.every((horizon) => seq <= horizon.delivered)) return 'delivered';
|
|
57
|
+
return 'sent';
|
|
58
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { ChatParticipant } from '../types';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ⚠ Carried over from v1 (`chat-message.vue`, `member.type === 'webitel'`);
|
|
5
|
+
* not confirmed for the new IM backend. This is the one place to change it.
|
|
6
|
+
*/
|
|
7
|
+
export const INTERNAL_CONTACT_TYPE = 'webitel';
|
|
8
|
+
|
|
9
|
+
/** Operators and bots speak for the contact centre; everyone else is the client. */
|
|
10
|
+
export const isContactCentreSide = (
|
|
11
|
+
member: ChatParticipant | undefined,
|
|
12
|
+
): boolean =>
|
|
13
|
+
member?.contact?.type === INTERNAL_CONTACT_TYPE ||
|
|
14
|
+
member?.contact?.isBot === true;
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ChatNoticeTone,
|
|
3
|
+
ChatParticipant,
|
|
4
|
+
MessageModel,
|
|
5
|
+
ThreadModel,
|
|
6
|
+
} from '../types';
|
|
7
|
+
|
|
8
|
+
export interface SystemNoticeDefinition {
|
|
9
|
+
/** relative to the v2 locale namespace */
|
|
10
|
+
textKey: string;
|
|
11
|
+
tone: ChatNoticeTone;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* ⚠ WS-22 (backend, "Need info"): only `member_added`, `member_removed` and
|
|
16
|
+
* `transferred` are documented. The started / accepted / closed type strings
|
|
17
|
+
* are assumed — confirm them there and change only this table.
|
|
18
|
+
*/
|
|
19
|
+
export const SYSTEM_NOTICES: Record<string, SystemNoticeDefinition> = {
|
|
20
|
+
thread_started: {
|
|
21
|
+
textKey: 'systemNotice.started',
|
|
22
|
+
tone: 'positive',
|
|
23
|
+
},
|
|
24
|
+
member_added: {
|
|
25
|
+
textKey: 'systemNotice.joined',
|
|
26
|
+
tone: 'neutral',
|
|
27
|
+
},
|
|
28
|
+
thread_accepted: {
|
|
29
|
+
textKey: 'systemNotice.accepted',
|
|
30
|
+
tone: 'neutral',
|
|
31
|
+
},
|
|
32
|
+
transferred: {
|
|
33
|
+
textKey: 'systemNotice.transferred',
|
|
34
|
+
tone: 'neutral',
|
|
35
|
+
},
|
|
36
|
+
member_removed: {
|
|
37
|
+
textKey: 'systemNotice.left',
|
|
38
|
+
tone: 'neutral',
|
|
39
|
+
},
|
|
40
|
+
thread_closed: {
|
|
41
|
+
textKey: 'systemNotice.ended',
|
|
42
|
+
tone: 'negative',
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/** ⚠ WS-22: the metadata key carrying the acting member's id is unconfirmed. */
|
|
47
|
+
const ACTOR_METADATA_KEYS = [
|
|
48
|
+
// chat-web-sdk's memberAdded / memberLeft socket payloads use these
|
|
49
|
+
'newMemberId',
|
|
50
|
+
'new_member_id',
|
|
51
|
+
'removedMemberId',
|
|
52
|
+
'removed_member_id',
|
|
53
|
+
'memberId',
|
|
54
|
+
'member_id',
|
|
55
|
+
'actorId',
|
|
56
|
+
'actor_id',
|
|
57
|
+
] as const;
|
|
58
|
+
|
|
59
|
+
export interface ResolvedSystemNotice {
|
|
60
|
+
/** null when `fallbackText` should be shown instead */
|
|
61
|
+
textKey: string | null;
|
|
62
|
+
/** the backend's own text, for types the table does not know */
|
|
63
|
+
fallbackText: string | null;
|
|
64
|
+
tone: ChatNoticeTone;
|
|
65
|
+
actor: ChatParticipant | undefined;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const findActor = (
|
|
69
|
+
message: MessageModel,
|
|
70
|
+
thread: ThreadModel,
|
|
71
|
+
): ChatParticipant | undefined => {
|
|
72
|
+
const metadata = (message.system?.metadata ?? {}) as Record<string, unknown>;
|
|
73
|
+
const members = thread.members ?? [];
|
|
74
|
+
|
|
75
|
+
const actorId = ACTOR_METADATA_KEYS.map((key) => metadata[key]).find(
|
|
76
|
+
(value): value is string => typeof value === 'string' && value !== '',
|
|
77
|
+
);
|
|
78
|
+
const byActorId = actorId
|
|
79
|
+
? members.find((member) => member.id === actorId)
|
|
80
|
+
: undefined;
|
|
81
|
+
if (byActorId) return byActorId;
|
|
82
|
+
|
|
83
|
+
const senderId = message.sender?.id;
|
|
84
|
+
const bySender = senderId
|
|
85
|
+
? members.find((member) => member.id === senderId)
|
|
86
|
+
: undefined;
|
|
87
|
+
// a member who has left is no longer in thread.members — the sender's own
|
|
88
|
+
// enriched contact still carries the name
|
|
89
|
+
return bySender ?? message.sender;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
export const resolveSystemNotice = (
|
|
93
|
+
message: MessageModel,
|
|
94
|
+
thread: ThreadModel,
|
|
95
|
+
): ResolvedSystemNotice => {
|
|
96
|
+
const type = message.system?.type;
|
|
97
|
+
const definition = type ? SYSTEM_NOTICES[type] : undefined;
|
|
98
|
+
const actor = findActor(message, thread);
|
|
99
|
+
|
|
100
|
+
if (definition) {
|
|
101
|
+
return {
|
|
102
|
+
textKey: definition.textKey,
|
|
103
|
+
fallbackText: null,
|
|
104
|
+
tone: definition.tone,
|
|
105
|
+
actor,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
textKey: message.body ? null : 'systemNotice.unknown',
|
|
111
|
+
fallbackText: message.body || null,
|
|
112
|
+
tone: 'neutral',
|
|
113
|
+
actor,
|
|
114
|
+
};
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
/** Display name, or '' when the participant carries none. */
|
|
118
|
+
export const participantName = (member: ChatParticipant | undefined): string =>
|
|
119
|
+
member?.contact?.name || member?.contact?.username || '';
|