@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,27 @@
|
|
|
1
|
+
import type { ChatsV2Locale } from './en';
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
composer: {
|
|
5
|
+
placeholder: 'Напишите сообщение...',
|
|
6
|
+
attach: 'Прикрепить файлы',
|
|
7
|
+
emoji: 'Эмодзи',
|
|
8
|
+
send: 'Отправить',
|
|
9
|
+
},
|
|
10
|
+
history: {
|
|
11
|
+
today: 'Сегодня',
|
|
12
|
+
waitingForOperator: 'Ожидание, пока оператор примет чат…',
|
|
13
|
+
deletedMessage: 'Сообщение удалено',
|
|
14
|
+
unsupportedMessage: 'Неподдерживаемый тип сообщения',
|
|
15
|
+
download: 'Скачать',
|
|
16
|
+
},
|
|
17
|
+
systemNotice: {
|
|
18
|
+
started: 'Чат начат: {actor}',
|
|
19
|
+
joined: 'Новый участник чата: {actor}',
|
|
20
|
+
accepted: 'Чат принят: {actor}',
|
|
21
|
+
transferred: 'Чат переведён: {actor}',
|
|
22
|
+
left: 'Участник вышел из чата: {actor}',
|
|
23
|
+
ended: 'Чат завершён: {actor}',
|
|
24
|
+
unknown: 'Системное событие',
|
|
25
|
+
system: 'система',
|
|
26
|
+
},
|
|
27
|
+
} satisfies ChatsV2Locale;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { ChatsV2Locale } from './en';
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
composer: {
|
|
5
|
+
placeholder: 'Напишіть повідомлення...',
|
|
6
|
+
attach: 'Прикріпити файли',
|
|
7
|
+
emoji: 'Емодзі',
|
|
8
|
+
send: 'Надіслати',
|
|
9
|
+
},
|
|
10
|
+
history: {
|
|
11
|
+
today: 'Сьогодні',
|
|
12
|
+
waitingForOperator: 'Очікування, поки оператор прийме чат…',
|
|
13
|
+
deletedMessage: 'Повідомлення видалено',
|
|
14
|
+
unsupportedMessage: 'Непідтримуваний тип повідомлення',
|
|
15
|
+
download: 'Завантажити',
|
|
16
|
+
},
|
|
17
|
+
systemNotice: {
|
|
18
|
+
started: 'Чат розпочато: {actor}',
|
|
19
|
+
joined: 'Новий учасник чату: {actor}',
|
|
20
|
+
accepted: 'Чат прийнято: {actor}',
|
|
21
|
+
transferred: 'Чат переведено: {actor}',
|
|
22
|
+
left: 'Учасник вийшов з чату: {actor}',
|
|
23
|
+
ended: 'Чат завершено: {actor}',
|
|
24
|
+
unknown: 'Системна подія',
|
|
25
|
+
system: 'система',
|
|
26
|
+
},
|
|
27
|
+
} satisfies ChatsV2Locale;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { useI18n } from 'vue-i18n';
|
|
2
|
+
|
|
3
|
+
const V2_NAMESPACE = '@webitel/ui-chats.v2';
|
|
4
|
+
|
|
5
|
+
/** `t` scoped to the v2 namespace: `t('composer.placeholder')`. */
|
|
6
|
+
export const useChatsV2I18n = () => {
|
|
7
|
+
const { t, locale } = useI18n();
|
|
8
|
+
|
|
9
|
+
return {
|
|
10
|
+
t: (path: string, named: Record<string, unknown> = {}) =>
|
|
11
|
+
t(`${V2_NAMESPACE}.${path}`, named),
|
|
12
|
+
locale,
|
|
13
|
+
};
|
|
14
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// DRAFT: needs native-speaker review (WS-50)
|
|
2
|
+
import type { ChatsV2Locale } from './en';
|
|
3
|
+
|
|
4
|
+
export default {
|
|
5
|
+
composer: {
|
|
6
|
+
placeholder: 'Xabar yozing...',
|
|
7
|
+
attach: 'Fayllarni biriktirish',
|
|
8
|
+
emoji: 'Emoji',
|
|
9
|
+
send: 'Yuborish',
|
|
10
|
+
},
|
|
11
|
+
history: {
|
|
12
|
+
today: 'Bugun',
|
|
13
|
+
waitingForOperator: 'Operator qabul qilishi kutilmoqda…',
|
|
14
|
+
deletedMessage: 'Xabar oʻchirildi',
|
|
15
|
+
unsupportedMessage: 'Qoʻllab-quvvatlanmaydigan xabar turi',
|
|
16
|
+
download: 'Yuklab olish',
|
|
17
|
+
},
|
|
18
|
+
systemNotice: {
|
|
19
|
+
started: 'Chat boshlandi: {actor}',
|
|
20
|
+
joined: 'Chatga yangi ishtirokchi: {actor}',
|
|
21
|
+
accepted: 'Chat qabul qilindi: {actor}',
|
|
22
|
+
transferred: 'Chat oʻtkazildi: {actor}',
|
|
23
|
+
left: 'Ishtirokchi chatdan chiqdi: {actor}',
|
|
24
|
+
ended: 'Chat yakunlandi: {actor}',
|
|
25
|
+
unknown: 'Tizim hodisasi',
|
|
26
|
+
system: 'tizim',
|
|
27
|
+
},
|
|
28
|
+
} satisfies ChatsV2Locale;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// DRAFT: needs native-speaker review (WS-50)
|
|
2
|
+
import type { ChatsV2Locale } from './en';
|
|
3
|
+
|
|
4
|
+
export default {
|
|
5
|
+
composer: {
|
|
6
|
+
placeholder: 'Nhập tin nhắn...',
|
|
7
|
+
attach: 'Đính kèm tệp',
|
|
8
|
+
emoji: 'Biểu tượng cảm xúc',
|
|
9
|
+
send: 'Gửi',
|
|
10
|
+
},
|
|
11
|
+
history: {
|
|
12
|
+
today: 'Hôm nay',
|
|
13
|
+
waitingForOperator: 'Đang chờ nhân viên tiếp nhận…',
|
|
14
|
+
deletedMessage: 'Tin nhắn đã bị xóa',
|
|
15
|
+
unsupportedMessage: 'Loại tin nhắn không được hỗ trợ',
|
|
16
|
+
download: 'Tải xuống',
|
|
17
|
+
},
|
|
18
|
+
systemNotice: {
|
|
19
|
+
started: 'Cuộc trò chuyện được bắt đầu bởi {actor}',
|
|
20
|
+
joined: '{actor} đã tham gia cuộc trò chuyện',
|
|
21
|
+
accepted: '{actor} đã tiếp nhận cuộc trò chuyện',
|
|
22
|
+
transferred: '{actor} đã chuyển cuộc trò chuyện',
|
|
23
|
+
left: '{actor} đã rời cuộc trò chuyện',
|
|
24
|
+
ended: 'Cuộc trò chuyện được kết thúc bởi {actor}',
|
|
25
|
+
unknown: 'Sự kiện hệ thống',
|
|
26
|
+
system: 'hệ thống',
|
|
27
|
+
},
|
|
28
|
+
} satisfies ChatsV2Locale;
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { classifyAttachments, collectGalleryImages } from '../attachments';
|
|
4
|
+
import { message } from './fixtures';
|
|
5
|
+
|
|
6
|
+
describe('classifyAttachments', () => {
|
|
7
|
+
it('splits images, audio/video and other files', () => {
|
|
8
|
+
const result = classifyAttachments(
|
|
9
|
+
message({
|
|
10
|
+
images: [
|
|
11
|
+
{
|
|
12
|
+
id: 'i1',
|
|
13
|
+
url: 'https://x/1.png',
|
|
14
|
+
mime: 'image/png',
|
|
15
|
+
},
|
|
16
|
+
],
|
|
17
|
+
documents: [
|
|
18
|
+
{
|
|
19
|
+
id: 'd1',
|
|
20
|
+
url: 'https://x/a.mp3',
|
|
21
|
+
mime: 'audio/mpeg',
|
|
22
|
+
name: 'a.mp3',
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
id: 'd2',
|
|
26
|
+
url: 'https://x/v.mp4',
|
|
27
|
+
mime: 'video/mp4',
|
|
28
|
+
name: 'v.mp4',
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
id: 'd3',
|
|
32
|
+
url: 'https://x/r.pdf',
|
|
33
|
+
mime: 'application/pdf',
|
|
34
|
+
name: 'r.pdf',
|
|
35
|
+
size: '2048',
|
|
36
|
+
},
|
|
37
|
+
],
|
|
38
|
+
}),
|
|
39
|
+
);
|
|
40
|
+
expect(result.images.map((i) => i.key)).toEqual([
|
|
41
|
+
'i1',
|
|
42
|
+
]);
|
|
43
|
+
expect(result.media.map((m) => m.kind)).toEqual([
|
|
44
|
+
'audio',
|
|
45
|
+
'video',
|
|
46
|
+
]);
|
|
47
|
+
expect(result.files).toEqual([
|
|
48
|
+
{
|
|
49
|
+
key: 'd3',
|
|
50
|
+
name: 'r.pdf',
|
|
51
|
+
size: '2048',
|
|
52
|
+
mime: 'application/pdf',
|
|
53
|
+
url: 'https://x/r.pdf',
|
|
54
|
+
},
|
|
55
|
+
]);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('sends HEIC images to file cards', () => {
|
|
59
|
+
const result = classifyAttachments(
|
|
60
|
+
message({
|
|
61
|
+
images: [
|
|
62
|
+
{
|
|
63
|
+
id: 'i1',
|
|
64
|
+
url: 'https://x/1.heic',
|
|
65
|
+
mime: 'image/heic',
|
|
66
|
+
},
|
|
67
|
+
],
|
|
68
|
+
}),
|
|
69
|
+
);
|
|
70
|
+
expect(result.images).toEqual([]);
|
|
71
|
+
expect(result.files.map((f) => f.key)).toEqual([
|
|
72
|
+
'i1',
|
|
73
|
+
]);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe('classifyAttachments — HEIF', () => {
|
|
78
|
+
it('sends HEIF images to file cards too', () => {
|
|
79
|
+
const result = classifyAttachments(
|
|
80
|
+
message({
|
|
81
|
+
images: [
|
|
82
|
+
{
|
|
83
|
+
id: 'i1',
|
|
84
|
+
url: 'https://x/1.heif',
|
|
85
|
+
mime: 'image/heif',
|
|
86
|
+
},
|
|
87
|
+
],
|
|
88
|
+
}),
|
|
89
|
+
);
|
|
90
|
+
expect(result.images).toEqual([]);
|
|
91
|
+
expect(result.files.map((f) => f.key)).toEqual([
|
|
92
|
+
'i1',
|
|
93
|
+
]);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe('classifyAttachments — file card names for undrawable images', () => {
|
|
98
|
+
const name = (url: string | undefined, mime = 'image/heic') =>
|
|
99
|
+
classifyAttachments(
|
|
100
|
+
message({
|
|
101
|
+
images: [
|
|
102
|
+
{
|
|
103
|
+
id: 'i1',
|
|
104
|
+
url,
|
|
105
|
+
mime,
|
|
106
|
+
},
|
|
107
|
+
],
|
|
108
|
+
}),
|
|
109
|
+
).files[0]?.name;
|
|
110
|
+
|
|
111
|
+
it('takes the file name from the URL when it has one', () => {
|
|
112
|
+
expect(name('https://x/storage/IMG_0042.HEIC?sig=abc')).toBe(
|
|
113
|
+
'IMG_0042.HEIC',
|
|
114
|
+
);
|
|
115
|
+
expect(name('https://x/files/my%20photo.heif', 'image/heif')).toBe(
|
|
116
|
+
'my photo.heif',
|
|
117
|
+
);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// signed storage links often end in an id or an action, not a name
|
|
121
|
+
it('falls back to image.<subtype> when the URL has no file name', () => {
|
|
122
|
+
expect(name('https://x/api/storage/file/123/download?sig=abc')).toBe(
|
|
123
|
+
'image.heic',
|
|
124
|
+
);
|
|
125
|
+
expect(name(undefined, 'image/heif')).toBe('image.heif');
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe('collectGalleryImages', () => {
|
|
130
|
+
it('collects drawable images across messages in order, skipping deleted ones', () => {
|
|
131
|
+
const images = collectGalleryImages([
|
|
132
|
+
message({
|
|
133
|
+
images: [
|
|
134
|
+
{
|
|
135
|
+
id: 'a',
|
|
136
|
+
url: 'https://x/a.png',
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
id: 'b',
|
|
140
|
+
url: 'https://x/b.heic',
|
|
141
|
+
mime: 'image/heic',
|
|
142
|
+
},
|
|
143
|
+
],
|
|
144
|
+
}),
|
|
145
|
+
message({
|
|
146
|
+
deleted: true,
|
|
147
|
+
images: [
|
|
148
|
+
{
|
|
149
|
+
id: 'c',
|
|
150
|
+
url: 'https://x/c.png',
|
|
151
|
+
},
|
|
152
|
+
],
|
|
153
|
+
}),
|
|
154
|
+
message({
|
|
155
|
+
images: [
|
|
156
|
+
{
|
|
157
|
+
id: 'd',
|
|
158
|
+
url: 'https://x/d.png',
|
|
159
|
+
},
|
|
160
|
+
],
|
|
161
|
+
}),
|
|
162
|
+
]);
|
|
163
|
+
expect(images.map((i) => i.key)).toEqual([
|
|
164
|
+
'a',
|
|
165
|
+
'd',
|
|
166
|
+
]);
|
|
167
|
+
});
|
|
168
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { ChatParticipant, MessageModel, ThreadModel } from '../../types';
|
|
2
|
+
|
|
3
|
+
export const operator: ChatParticipant = {
|
|
4
|
+
id: 'm-operator',
|
|
5
|
+
contact: {
|
|
6
|
+
name: 'Alina Timoshenko',
|
|
7
|
+
type: 'webitel',
|
|
8
|
+
},
|
|
9
|
+
};
|
|
10
|
+
export const colleague: ChatParticipant = {
|
|
11
|
+
id: 'm-colleague',
|
|
12
|
+
contact: {
|
|
13
|
+
name: 'Oleh Bondar',
|
|
14
|
+
type: 'webitel',
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
export const bot: ChatParticipant = {
|
|
18
|
+
id: 'm-bot',
|
|
19
|
+
contact: {
|
|
20
|
+
name: 'Helper',
|
|
21
|
+
type: 'webchat',
|
|
22
|
+
isBot: true,
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
export const client: ChatParticipant = {
|
|
26
|
+
id: 'm-client',
|
|
27
|
+
contact: {
|
|
28
|
+
name: 'Emily Johnson',
|
|
29
|
+
type: 'telegram',
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
let seq = 0;
|
|
34
|
+
export const message = (
|
|
35
|
+
overrides: Partial<MessageModel> = {},
|
|
36
|
+
): MessageModel => {
|
|
37
|
+
seq += 1;
|
|
38
|
+
return {
|
|
39
|
+
id: `msg-${seq}`,
|
|
40
|
+
seq: String(seq),
|
|
41
|
+
createdAt: String(Date.UTC(2026, 8, 15, 10, 0) + seq * 60_000),
|
|
42
|
+
sender: client,
|
|
43
|
+
body: `text ${seq}`,
|
|
44
|
+
...overrides,
|
|
45
|
+
};
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export const thread = (overrides: Partial<ThreadModel> = {}): ThreadModel => ({
|
|
49
|
+
id: 'thread-1',
|
|
50
|
+
members: [
|
|
51
|
+
operator,
|
|
52
|
+
client,
|
|
53
|
+
],
|
|
54
|
+
readStates: [],
|
|
55
|
+
...overrides,
|
|
56
|
+
});
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { formatDividerDate, formatMessageTime } from '../formatDate';
|
|
4
|
+
|
|
5
|
+
const now = new Date(2026, 9, 1, 15).getTime();
|
|
6
|
+
|
|
7
|
+
// 21:30 UTC on Sep 15 is already 00:30 on Sep 16 in Kyiv (UTC+3)
|
|
8
|
+
const lateEveningUtc = Date.UTC(2026, 8, 15, 21, 30);
|
|
9
|
+
|
|
10
|
+
describe('formatDividerDate', () => {
|
|
11
|
+
it('says Today for today', () => {
|
|
12
|
+
expect(
|
|
13
|
+
formatDividerDate(new Date(2026, 9, 1, 8).getTime(), {
|
|
14
|
+
locale: 'en',
|
|
15
|
+
todayLabel: 'Today',
|
|
16
|
+
now,
|
|
17
|
+
}),
|
|
18
|
+
).toBe('Today');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('shows month and day within the current year', () => {
|
|
22
|
+
expect(
|
|
23
|
+
formatDividerDate(new Date(2026, 8, 15, 12).getTime(), {
|
|
24
|
+
locale: 'en',
|
|
25
|
+
todayLabel: 'Today',
|
|
26
|
+
now,
|
|
27
|
+
}),
|
|
28
|
+
).toBe('September 15');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('adds the year for other years', () => {
|
|
32
|
+
expect(
|
|
33
|
+
formatDividerDate(new Date(2025, 8, 15, 12).getTime(), {
|
|
34
|
+
locale: 'en',
|
|
35
|
+
todayLabel: 'Today',
|
|
36
|
+
now,
|
|
37
|
+
}),
|
|
38
|
+
).toBe('September 15, 2025');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('names the day in the given time zone', () => {
|
|
42
|
+
const options = {
|
|
43
|
+
locale: 'en',
|
|
44
|
+
todayLabel: 'Today',
|
|
45
|
+
now,
|
|
46
|
+
};
|
|
47
|
+
expect(
|
|
48
|
+
formatDividerDate(lateEveningUtc, {
|
|
49
|
+
...options,
|
|
50
|
+
timeZone: 'UTC',
|
|
51
|
+
}),
|
|
52
|
+
).toBe('September 15');
|
|
53
|
+
expect(
|
|
54
|
+
formatDividerDate(lateEveningUtc, {
|
|
55
|
+
...options,
|
|
56
|
+
timeZone: 'Europe/Kyiv',
|
|
57
|
+
}),
|
|
58
|
+
).toBe('September 16');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('does not throw on app-only locale codes', () => {
|
|
62
|
+
expect(() =>
|
|
63
|
+
formatDividerDate(new Date(2025, 0, 2).getTime(), {
|
|
64
|
+
locale: 'kz',
|
|
65
|
+
todayLabel: 'Бүгін',
|
|
66
|
+
now,
|
|
67
|
+
}),
|
|
68
|
+
).not.toThrow();
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe('formatMessageTime', () => {
|
|
73
|
+
afterEach(() => {
|
|
74
|
+
localStorage.removeItem('user-timezone-setting');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('is 24-hour HH:mm', () => {
|
|
78
|
+
expect(formatMessageTime(new Date(2026, 9, 1, 9, 5).getTime())).toBe(
|
|
79
|
+
'09:05',
|
|
80
|
+
);
|
|
81
|
+
expect(formatMessageTime(new Date(2026, 9, 1, 22, 40).getTime())).toBe(
|
|
82
|
+
'22:40',
|
|
83
|
+
);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('uses the given time zone', () => {
|
|
87
|
+
expect(formatMessageTime(lateEveningUtc, 'UTC')).toBe('21:30');
|
|
88
|
+
expect(formatMessageTime(lateEveningUtc, 'Europe/Kyiv')).toBe('00:30');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// the same setting the rest of the app formats dates with
|
|
92
|
+
it('defaults to the operator’s time zone setting', () => {
|
|
93
|
+
localStorage.setItem('user-timezone-setting', 'Asia/Tokyo');
|
|
94
|
+
expect(formatMessageTime(lateEveningUtc)).toBe('06:30');
|
|
95
|
+
});
|
|
96
|
+
});
|
|
@@ -0,0 +1,214 @@
|
|
|
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
|
+
});
|
|
@@ -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
|
+
});
|