@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.
- 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/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 +304 -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 +59 -0
- package/src/v2/scripts/isContactCentreSide.ts +14 -0
- package/src/v2/scripts/isSelfMessage.ts +29 -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 +334 -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 +156 -0
- package/src/v2/ui/chat-history/components/chat-system-notice.vue +111 -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/isSelfMessage.d.ts +10 -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,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,59 @@
|
|
|
1
|
+
import type { ChatMessageStatus, MessageModel, ThreadModel } from '../types';
|
|
2
|
+
import { isContactCentreSide } from './isContactCentreSide';
|
|
3
|
+
import { isSelfMessage } from './isSelfMessage';
|
|
4
|
+
import { toSeq } from './toTimestamp';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Sent / delivered / read for the operator's own messages, against the
|
|
8
|
+
* client's read horizon: the lowest horizon over every client member, so a
|
|
9
|
+
* message counts as read only once all clients have read it.
|
|
10
|
+
* `null` when no ticks should show.
|
|
11
|
+
*/
|
|
12
|
+
export const getMessageStatus = (
|
|
13
|
+
message: MessageModel,
|
|
14
|
+
thread: ThreadModel,
|
|
15
|
+
selfMemberId: string,
|
|
16
|
+
): ChatMessageStatus | null => {
|
|
17
|
+
if (message.deleted || message.system) return null;
|
|
18
|
+
if (!isSelfMessage(message, thread, selfMemberId)) return null;
|
|
19
|
+
|
|
20
|
+
const seq = toSeq(message.seq);
|
|
21
|
+
if (seq === null) return 'sent';
|
|
22
|
+
|
|
23
|
+
const membersById = new Map(
|
|
24
|
+
(thread.members ?? []).flatMap((member) =>
|
|
25
|
+
member.id
|
|
26
|
+
? [
|
|
27
|
+
[
|
|
28
|
+
member.id,
|
|
29
|
+
member,
|
|
30
|
+
] as const,
|
|
31
|
+
]
|
|
32
|
+
: [],
|
|
33
|
+
),
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
const clientStates = (thread.readStates ?? []).filter((state) => {
|
|
37
|
+
const member =
|
|
38
|
+
state.member ??
|
|
39
|
+
(state.memberId ? membersById.get(state.memberId) : undefined);
|
|
40
|
+
return member !== undefined && !isContactCentreSide(member);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
if (!clientStates.length) return 'sent';
|
|
44
|
+
|
|
45
|
+
const horizons = clientStates.map((state) => {
|
|
46
|
+
const read = toSeq(state.readUpToSeq) ?? 0;
|
|
47
|
+
// reading a message implies it was delivered, even if the delivered
|
|
48
|
+
// horizon lags behind
|
|
49
|
+
const delivered = Math.max(toSeq(state.deliveredUpToSeq) ?? 0, read);
|
|
50
|
+
return {
|
|
51
|
+
read,
|
|
52
|
+
delivered,
|
|
53
|
+
};
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
if (horizons.every((horizon) => seq <= horizon.read)) return 'read';
|
|
57
|
+
if (horizons.every((horizon) => seq <= horizon.delivered)) return 'delivered';
|
|
58
|
+
return 'sent';
|
|
59
|
+
};
|
|
@@ -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,29 @@
|
|
|
1
|
+
import type { MessageModel, ThreadModel } from '../types';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Whether the operator using the UI sent this message.
|
|
5
|
+
*
|
|
6
|
+
* Member ids are per membership: an operator who is removed and added again
|
|
7
|
+
* gets a new one, while `thread.members` (and so `selfMemberId`) may still
|
|
8
|
+
* hold the old. So an id match is not enough; the stable identity is the
|
|
9
|
+
* contact — same subject from the same issuer as the operator's own member.
|
|
10
|
+
*/
|
|
11
|
+
export const isSelfMessage = (
|
|
12
|
+
message: MessageModel,
|
|
13
|
+
thread: ThreadModel,
|
|
14
|
+
selfMemberId: string,
|
|
15
|
+
): boolean => {
|
|
16
|
+
const sender = message.sender;
|
|
17
|
+
if (!sender?.id || !selfMemberId) return false;
|
|
18
|
+
if (sender.id === selfMemberId) return true;
|
|
19
|
+
|
|
20
|
+
const self = thread.members?.find((member) => member.id === selfMemberId);
|
|
21
|
+
const selfContact = self?.contact;
|
|
22
|
+
const senderContact = sender.contact;
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
!!selfContact?.sub &&
|
|
26
|
+
selfContact.sub === senderContact?.sub &&
|
|
27
|
+
selfContact.iss === senderContact?.iss
|
|
28
|
+
);
|
|
29
|
+
};
|
|
@@ -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 || '';
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { getUserTimeZone } from '@webitel/api-services/utils';
|
|
2
|
+
import type { MessageModel } from '../types';
|
|
3
|
+
|
|
4
|
+
import { isSameDayInZone } from './formatDate';
|
|
5
|
+
import { toTimestamp } from './toTimestamp';
|
|
6
|
+
|
|
7
|
+
export type ChatHistoryItem =
|
|
8
|
+
| {
|
|
9
|
+
kind: 'divider';
|
|
10
|
+
key: string;
|
|
11
|
+
/** timestamp of the first item of that day */
|
|
12
|
+
date: number;
|
|
13
|
+
}
|
|
14
|
+
| {
|
|
15
|
+
kind: 'message' | 'system';
|
|
16
|
+
key: string;
|
|
17
|
+
message: MessageModel;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Messages (oldest → newest) as render rows, with a divider before the first
|
|
22
|
+
* item of each calendar day in `timeZone` (the operator's setting by default).
|
|
23
|
+
* Items without a usable `createdAt` never open a new day.
|
|
24
|
+
*/
|
|
25
|
+
export const toHistoryItems = (
|
|
26
|
+
messages: readonly MessageModel[],
|
|
27
|
+
{
|
|
28
|
+
timeZone = getUserTimeZone(),
|
|
29
|
+
}: {
|
|
30
|
+
timeZone?: string;
|
|
31
|
+
} = {},
|
|
32
|
+
): ChatHistoryItem[] => {
|
|
33
|
+
const items: ChatHistoryItem[] = [];
|
|
34
|
+
// timestamp of the item that opened the current day
|
|
35
|
+
let currentDayStart: number | null = null;
|
|
36
|
+
|
|
37
|
+
for (const message of messages) {
|
|
38
|
+
const timestamp = toTimestamp(message.createdAt);
|
|
39
|
+
|
|
40
|
+
if (timestamp !== null) {
|
|
41
|
+
if (
|
|
42
|
+
currentDayStart === null ||
|
|
43
|
+
!isSameDayInZone(currentDayStart, timestamp, timeZone)
|
|
44
|
+
) {
|
|
45
|
+
items.push({
|
|
46
|
+
kind: 'divider',
|
|
47
|
+
// keyed by the day's first message: a day can recur out of order
|
|
48
|
+
key: `divider-${message.id}`,
|
|
49
|
+
date: timestamp,
|
|
50
|
+
});
|
|
51
|
+
currentDayStart = timestamp;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
items.push({
|
|
56
|
+
kind: message.system ? 'system' : 'message',
|
|
57
|
+
key: message.id,
|
|
58
|
+
message,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return items;
|
|
63
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** Backend timestamps are Unix ms as strings; `null` when absent or unusable. */
|
|
2
|
+
export const toTimestamp = (
|
|
3
|
+
value: string | number | undefined | null,
|
|
4
|
+
): number | null => {
|
|
5
|
+
if (value === undefined || value === null || value === '') return null;
|
|
6
|
+
const parsed = Number(value);
|
|
7
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Per-thread message sequence numbers arrive as strings. Compare them as
|
|
12
|
+
* numbers: as strings, "10" sorts before "9".
|
|
13
|
+
*/
|
|
14
|
+
export const toSeq = (
|
|
15
|
+
value: string | number | null | undefined,
|
|
16
|
+
): number | null => {
|
|
17
|
+
// typed string | undefined, but JSON can carry null, and Number(null) is 0
|
|
18
|
+
if (value === undefined || value === null || value === '') return null;
|
|
19
|
+
const parsed = Number(value);
|
|
20
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
21
|
+
};
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { MessageModel, ThreadModel } from '@webitel/chat-web-sdk';
|
|
2
|
+
|
|
3
|
+
export type { MessageModel, ThreadModel };
|
|
4
|
+
|
|
5
|
+
export const ChatThreadMode = {
|
|
6
|
+
/** offered to the operator, not accepted yet: no composer, waiting line */
|
|
7
|
+
Awaiting: 'awaiting',
|
|
8
|
+
Active: 'active',
|
|
9
|
+
/** closed or otherwise not writable: no composer */
|
|
10
|
+
Readonly: 'readonly',
|
|
11
|
+
} as const;
|
|
12
|
+
|
|
13
|
+
export type ChatThreadMode =
|
|
14
|
+
(typeof ChatThreadMode)[keyof typeof ChatThreadMode];
|
|
15
|
+
|
|
16
|
+
export const ChatComposerAction = {
|
|
17
|
+
Attach: 'attach',
|
|
18
|
+
Emoji: 'emoji',
|
|
19
|
+
Send: 'send',
|
|
20
|
+
} as const;
|
|
21
|
+
|
|
22
|
+
export type ChatComposerAction =
|
|
23
|
+
(typeof ChatComposerAction)[keyof typeof ChatComposerAction];
|
|
24
|
+
|
|
25
|
+
export type ChatMessageStatus = 'sent' | 'delivered' | 'read';
|
|
26
|
+
|
|
27
|
+
export type ChatNoticeTone = 'positive' | 'neutral' | 'negative';
|
|
28
|
+
|
|
29
|
+
/** A thread member as it arrives on `message.sender` and `thread.members[]`. */
|
|
30
|
+
export type ChatParticipant = NonNullable<MessageModel['sender']>;
|
|
31
|
+
|
|
32
|
+
export type ChatImage = NonNullable<MessageModel['images']>[number];
|
|
33
|
+
|
|
34
|
+
export type ChatDocument = NonNullable<MessageModel['documents']>[number];
|
|
35
|
+
|
|
36
|
+
export type ResolveAvatarUrl = (member: ChatParticipant) => string | undefined;
|
|
37
|
+
|
|
38
|
+
export type ChatSendHandler = (text: string) => Promise<unknown>;
|
|
39
|
+
|
|
40
|
+
export type ChatAttachHandler = (files: File[]) => Promise<unknown>;
|
|
41
|
+
|
|
42
|
+
export type ChatLoadMoreHandler = () => Promise<unknown>;
|
|
43
|
+
|
|
44
|
+
export interface ChatComposerSlotProps {
|
|
45
|
+
/** inserts at the cursor (or appends) and updates the draft */
|
|
46
|
+
insertText: (text: string) => void;
|
|
47
|
+
focus: () => void;
|
|
48
|
+
disabled: boolean;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface ChatHistoryProps {
|
|
52
|
+
thread: ThreadModel;
|
|
53
|
+
/** oldest → newest; current and earlier sessions */
|
|
54
|
+
messages: MessageModel[];
|
|
55
|
+
/** thread-member id of the operator using the UI */
|
|
56
|
+
selfMemberId: string;
|
|
57
|
+
mode?: ChatThreadMode;
|
|
58
|
+
hasMore?: boolean;
|
|
59
|
+
onLoadMore?: ChatLoadMoreHandler;
|
|
60
|
+
resolveAvatarUrl?: ResolveAvatarUrl;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface ChatComposerProps {
|
|
64
|
+
onSend?: ChatSendHandler;
|
|
65
|
+
onAttach?: ChatAttachHandler;
|
|
66
|
+
actions?: ChatComposerAction[];
|
|
67
|
+
submitOnEnter?: boolean;
|
|
68
|
+
disabled?: boolean;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface ChatThreadProps extends ChatHistoryProps, ChatComposerProps {
|
|
72
|
+
/** controlled draft (v-model:draft); omit to let the composer own it */
|
|
73
|
+
draft?: string;
|
|
74
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { mount } from '@vue/test-utils';
|
|
2
|
+
import { createI18n } from 'vue-i18n';
|
|
3
|
+
|
|
4
|
+
import { messages } from '../../../locale';
|
|
5
|
+
|
|
6
|
+
export const chatsI18n = () =>
|
|
7
|
+
createI18n({
|
|
8
|
+
legacy: false,
|
|
9
|
+
locale: 'en',
|
|
10
|
+
fallbackLocale: 'en',
|
|
11
|
+
messages,
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
export const mountWithChats: typeof mount = ((component, options = {}) =>
|
|
15
|
+
mount(component, {
|
|
16
|
+
...options,
|
|
17
|
+
global: {
|
|
18
|
+
...options.global,
|
|
19
|
+
plugins: [
|
|
20
|
+
...(options.global?.plugins ?? []),
|
|
21
|
+
chatsI18n(),
|
|
22
|
+
],
|
|
23
|
+
},
|
|
24
|
+
})) as typeof mount;
|
|
25
|
+
|
|
26
|
+
export const deferred = <T = void>() => {
|
|
27
|
+
let resolve!: (value: T) => void;
|
|
28
|
+
let reject!: (reason?: unknown) => void;
|
|
29
|
+
const promise = new Promise<T>((res, rej) => {
|
|
30
|
+
resolve = res;
|
|
31
|
+
reject = rej;
|
|
32
|
+
});
|
|
33
|
+
return {
|
|
34
|
+
promise,
|
|
35
|
+
resolve,
|
|
36
|
+
reject,
|
|
37
|
+
};
|
|
38
|
+
};
|