@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,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
|
+
};
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { flushPromises } from '@vue/test-utils';
|
|
2
|
+
import { beforeAll, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
|
|
4
|
+
import { ChatComposerAction } from '../../../types';
|
|
5
|
+
import { deferred, mountWithChats } from '../../__tests__/mountWithChats';
|
|
6
|
+
import ChatComposer from '../chat-composer.vue';
|
|
7
|
+
|
|
8
|
+
vi.mock('@webitel/ui-sdk/components', async (importOriginal) => ({
|
|
9
|
+
...(await importOriginal<typeof import('@webitel/ui-sdk/components')>()),
|
|
10
|
+
WtChatEmoji: {
|
|
11
|
+
name: 'WtChatEmoji',
|
|
12
|
+
emits: [
|
|
13
|
+
'insert-emoji',
|
|
14
|
+
],
|
|
15
|
+
template:
|
|
16
|
+
'<button class="emoji-stub" @click="$emit(\'insert-emoji\', \'🙂\')" />',
|
|
17
|
+
},
|
|
18
|
+
WtGalleria: {
|
|
19
|
+
name: 'WtGalleria',
|
|
20
|
+
props: [
|
|
21
|
+
'visible',
|
|
22
|
+
'activeIndex',
|
|
23
|
+
'value',
|
|
24
|
+
],
|
|
25
|
+
template: '<div class="galleria-stub" />',
|
|
26
|
+
},
|
|
27
|
+
WtVidstackPlayer: {
|
|
28
|
+
name: 'WtVidstackPlayer',
|
|
29
|
+
template: '<div class="player-stub" />',
|
|
30
|
+
},
|
|
31
|
+
WtPlayer: {
|
|
32
|
+
name: 'WtPlayer',
|
|
33
|
+
template: '<div class="player-stub" />',
|
|
34
|
+
},
|
|
35
|
+
}));
|
|
36
|
+
|
|
37
|
+
// happy-dom has no execCommand; returning false sends insert-text-at-cursor
|
|
38
|
+
// down its own setRangeText + input-event fallback, as older browsers do
|
|
39
|
+
beforeAll(() => {
|
|
40
|
+
if (typeof document.execCommand !== 'function') {
|
|
41
|
+
document.execCommand = () => false;
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const setup = (props = {}) =>
|
|
46
|
+
mountWithChats(ChatComposer, {
|
|
47
|
+
props,
|
|
48
|
+
attachTo: document.body,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const type = async (wrapper: ReturnType<typeof setup>, text: string) => {
|
|
52
|
+
await wrapper.find('textarea').setValue(text);
|
|
53
|
+
};
|
|
54
|
+
const pressEnter = (wrapper: ReturnType<typeof setup>, shiftKey = false) =>
|
|
55
|
+
wrapper.find('textarea').trigger('keydown', {
|
|
56
|
+
key: 'Enter',
|
|
57
|
+
shiftKey,
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe('ChatComposer', () => {
|
|
61
|
+
it('sends the draft and clears it once onSend resolves', async () => {
|
|
62
|
+
const onSend = vi.fn().mockResolvedValue(undefined);
|
|
63
|
+
const wrapper = setup({
|
|
64
|
+
onSend,
|
|
65
|
+
});
|
|
66
|
+
await type(wrapper, 'Hello');
|
|
67
|
+
await wrapper.find('.chat-composer__send').trigger('click');
|
|
68
|
+
await flushPromises();
|
|
69
|
+
expect(onSend).toHaveBeenCalledWith('Hello');
|
|
70
|
+
expect(wrapper.find('textarea').element.value).toBe('');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('locks while pending, keeps the draft and passes the error on when onSend rejects', async () => {
|
|
74
|
+
const pending = deferred();
|
|
75
|
+
const onSend = vi.fn(() => pending.promise);
|
|
76
|
+
const errorHandler = vi.fn();
|
|
77
|
+
const wrapper = mountWithChats(ChatComposer, {
|
|
78
|
+
props: {
|
|
79
|
+
onSend,
|
|
80
|
+
},
|
|
81
|
+
attachTo: document.body,
|
|
82
|
+
global: {
|
|
83
|
+
config: {
|
|
84
|
+
errorHandler,
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
await type(wrapper, 'Hello');
|
|
89
|
+
await wrapper.find('.chat-composer__send').trigger('click');
|
|
90
|
+
expect(wrapper.find('textarea').element.disabled).toBe(true);
|
|
91
|
+
// browsers drop focus from a disabled field; happy-dom does not, so do it here
|
|
92
|
+
wrapper.find('textarea').element.blur();
|
|
93
|
+
|
|
94
|
+
const error = new Error('boom');
|
|
95
|
+
pending.reject(error);
|
|
96
|
+
await flushPromises();
|
|
97
|
+
expect(wrapper.find('textarea').element.disabled).toBe(false);
|
|
98
|
+
expect(wrapper.find('textarea').element.value).toBe('Hello');
|
|
99
|
+
expect(errorHandler).toHaveBeenCalledWith(
|
|
100
|
+
error,
|
|
101
|
+
expect.anything(),
|
|
102
|
+
expect.anything(),
|
|
103
|
+
); // back in the field, ready to retry
|
|
104
|
+
expect(document.activeElement).toBe(wrapper.find('textarea').element);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// Review Focus #4
|
|
108
|
+
it('does not send twice while a send is pending', async () => {
|
|
109
|
+
const pending = deferred();
|
|
110
|
+
const onSend = vi.fn(() => pending.promise);
|
|
111
|
+
const wrapper = setup({
|
|
112
|
+
onSend,
|
|
113
|
+
});
|
|
114
|
+
await type(wrapper, 'Hello');
|
|
115
|
+
await pressEnter(wrapper);
|
|
116
|
+
await pressEnter(wrapper);
|
|
117
|
+
await wrapper.find('.chat-composer__send').trigger('click');
|
|
118
|
+
expect(onSend).toHaveBeenCalledTimes(1);
|
|
119
|
+
pending.resolve();
|
|
120
|
+
await flushPromises();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('ignores an empty or whitespace draft', async () => {
|
|
124
|
+
const onSend = vi.fn().mockResolvedValue(undefined);
|
|
125
|
+
const wrapper = setup({
|
|
126
|
+
onSend,
|
|
127
|
+
});
|
|
128
|
+
await type(wrapper, ' ');
|
|
129
|
+
await pressEnter(wrapper);
|
|
130
|
+
expect(onSend).not.toHaveBeenCalled();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('sends on Enter, not on Shift+Enter, and not at all with submitOnEnter off', async () => {
|
|
134
|
+
const onSend = vi.fn().mockResolvedValue(undefined);
|
|
135
|
+
const wrapper = setup({
|
|
136
|
+
onSend,
|
|
137
|
+
});
|
|
138
|
+
await type(wrapper, 'a');
|
|
139
|
+
await pressEnter(wrapper, true);
|
|
140
|
+
expect(onSend).not.toHaveBeenCalled();
|
|
141
|
+
await pressEnter(wrapper);
|
|
142
|
+
expect(onSend).toHaveBeenCalledTimes(1);
|
|
143
|
+
|
|
144
|
+
const off = setup({
|
|
145
|
+
onSend: vi.fn(),
|
|
146
|
+
submitOnEnter: false,
|
|
147
|
+
});
|
|
148
|
+
await type(off, 'a');
|
|
149
|
+
await pressEnter(off);
|
|
150
|
+
expect(off.props('onSend')).not.toHaveBeenCalled();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('renders only the actions it is given', () => {
|
|
154
|
+
const wrapper = setup({
|
|
155
|
+
actions: [
|
|
156
|
+
ChatComposerAction.Send,
|
|
157
|
+
],
|
|
158
|
+
});
|
|
159
|
+
expect(wrapper.find('.chat-composer__attach').exists()).toBe(false);
|
|
160
|
+
expect(wrapper.find('.emoji-stub').exists()).toBe(false);
|
|
161
|
+
expect(wrapper.find('.chat-composer__send').exists()).toBe(true);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('passes picked files to onAttach', async () => {
|
|
165
|
+
const onAttach = vi.fn().mockResolvedValue(undefined);
|
|
166
|
+
const wrapper = setup({
|
|
167
|
+
onAttach,
|
|
168
|
+
});
|
|
169
|
+
const input = wrapper.find('.chat-composer__file-input');
|
|
170
|
+
const file = new File(
|
|
171
|
+
[
|
|
172
|
+
'x',
|
|
173
|
+
],
|
|
174
|
+
'a.txt',
|
|
175
|
+
{
|
|
176
|
+
type: 'text/plain',
|
|
177
|
+
},
|
|
178
|
+
);
|
|
179
|
+
Object.defineProperty(input.element, 'files', {
|
|
180
|
+
value: [
|
|
181
|
+
file,
|
|
182
|
+
],
|
|
183
|
+
configurable: true,
|
|
184
|
+
});
|
|
185
|
+
await input.trigger('change');
|
|
186
|
+
await flushPromises();
|
|
187
|
+
expect(onAttach).toHaveBeenCalledWith([
|
|
188
|
+
file,
|
|
189
|
+
]);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('lets slot content insert text into the draft', async () => {
|
|
193
|
+
const wrapper = mountWithChats(ChatComposer, {
|
|
194
|
+
attachTo: document.body,
|
|
195
|
+
slots: {
|
|
196
|
+
actions: `<template #actions="{ insertText }"><button class="quick-reply" @click="insertText('Thanks!')" /></template>`,
|
|
197
|
+
},
|
|
198
|
+
});
|
|
199
|
+
await wrapper.find('.quick-reply').trigger('click');
|
|
200
|
+
expect(wrapper.find('textarea').element.value).toContain('Thanks!');
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('inserts a picked emoji', async () => {
|
|
204
|
+
const wrapper = setup();
|
|
205
|
+
await wrapper.find('.emoji-stub').trigger('click');
|
|
206
|
+
expect(wrapper.find('textarea').element.value).toContain('🙂');
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('works as a controlled v-model:draft', async () => {
|
|
210
|
+
const wrapper = setup({
|
|
211
|
+
draft: 'from app',
|
|
212
|
+
'onUpdate:draft': (value: string) =>
|
|
213
|
+
wrapper.setProps({
|
|
214
|
+
draft: value,
|
|
215
|
+
}),
|
|
216
|
+
});
|
|
217
|
+
expect(wrapper.find('textarea').element.value).toBe('from app');
|
|
218
|
+
await type(wrapper, 'edited');
|
|
219
|
+
expect(wrapper.props('draft')).toBe('edited');
|
|
220
|
+
});
|
|
221
|
+
});
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div
|
|
3
|
+
:id="composerId"
|
|
4
|
+
class="chat-composer"
|
|
5
|
+
>
|
|
6
|
+
<wt-textarea
|
|
7
|
+
ref="field"
|
|
8
|
+
class="chat-composer__field"
|
|
9
|
+
:model-value="draft"
|
|
10
|
+
:placeholder="t('composer.placeholder')"
|
|
11
|
+
:disabled="isLocked"
|
|
12
|
+
:submit-on-enter="props.submitOnEnter"
|
|
13
|
+
autoresize
|
|
14
|
+
@update:model-value="draft = $event ?? ''"
|
|
15
|
+
@enter="send"
|
|
16
|
+
/>
|
|
17
|
+
<div class="chat-composer__actions">
|
|
18
|
+
<div class="chat-composer__actions-start">
|
|
19
|
+
<template v-if="hasAction(ChatComposerAction.Attach)">
|
|
20
|
+
<wt-button
|
|
21
|
+
class="chat-composer__attach"
|
|
22
|
+
icon="attach"
|
|
23
|
+
color="secondary"
|
|
24
|
+
variant="text"
|
|
25
|
+
size="sm"
|
|
26
|
+
:disabled="isLocked"
|
|
27
|
+
:title="t('composer.attach')"
|
|
28
|
+
@click="fileInput?.click()"
|
|
29
|
+
/>
|
|
30
|
+
<input
|
|
31
|
+
ref="file-input"
|
|
32
|
+
class="chat-composer__file-input"
|
|
33
|
+
type="file"
|
|
34
|
+
multiple
|
|
35
|
+
hidden
|
|
36
|
+
@change="handleFilesPicked"
|
|
37
|
+
>
|
|
38
|
+
</template>
|
|
39
|
+
<wt-chat-emoji
|
|
40
|
+
v-if="hasAction(ChatComposerAction.Emoji)"
|
|
41
|
+
:popup-teleport-to="`#${composerId}`"
|
|
42
|
+
:rounded="false"
|
|
43
|
+
size="sm"
|
|
44
|
+
:title="t('composer.emoji')"
|
|
45
|
+
@insert-emoji="insertText"
|
|
46
|
+
/>
|
|
47
|
+
<slot
|
|
48
|
+
name="actions"
|
|
49
|
+
v-bind="slotProps"
|
|
50
|
+
/>
|
|
51
|
+
</div>
|
|
52
|
+
<wt-button
|
|
53
|
+
v-if="hasAction(ChatComposerAction.Send)"
|
|
54
|
+
class="chat-composer__send"
|
|
55
|
+
icon="chat-send"
|
|
56
|
+
color="secondary"
|
|
57
|
+
:loading="isSending"
|
|
58
|
+
:disabled="isLocked || !canSend"
|
|
59
|
+
:title="t('composer.send')"
|
|
60
|
+
@mousedown.prevent
|
|
61
|
+
@click="send"
|
|
62
|
+
/>
|
|
63
|
+
</div>
|
|
64
|
+
</div>
|
|
65
|
+
</template>
|
|
66
|
+
|
|
67
|
+
<script setup lang="ts">
|
|
68
|
+
import { WtButton, WtChatEmoji, WtTextarea } from '@webitel/ui-sdk/components';
|
|
69
|
+
import insertTextAtCursor from 'insert-text-at-cursor';
|
|
70
|
+
import { computed, nextTick, ref, useId, useTemplateRef } from 'vue';
|
|
71
|
+
|
|
72
|
+
import { useChatsV2I18n } from '../../locale/useChatsV2I18n';
|
|
73
|
+
import {
|
|
74
|
+
ChatComposerAction,
|
|
75
|
+
type ChatComposerProps,
|
|
76
|
+
type ChatComposerSlotProps,
|
|
77
|
+
} from '../../types';
|
|
78
|
+
|
|
79
|
+
const props = withDefaults(defineProps<ChatComposerProps>(), {
|
|
80
|
+
actions: () => [
|
|
81
|
+
ChatComposerAction.Attach,
|
|
82
|
+
ChatComposerAction.Emoji,
|
|
83
|
+
ChatComposerAction.Send,
|
|
84
|
+
],
|
|
85
|
+
submitOnEnter: true,
|
|
86
|
+
disabled: false,
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const draft = defineModel<string>('draft', {
|
|
90
|
+
default: '',
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
defineSlots<{
|
|
94
|
+
actions?: (props: ChatComposerSlotProps) => unknown;
|
|
95
|
+
}>();
|
|
96
|
+
|
|
97
|
+
const { t } = useChatsV2I18n();
|
|
98
|
+
|
|
99
|
+
// unique per instance: the emoji popup teleports into its own composer
|
|
100
|
+
const composerId = `chat-composer-${useId()}`;
|
|
101
|
+
|
|
102
|
+
const field = useTemplateRef<InstanceType<typeof WtTextarea>>('field');
|
|
103
|
+
const fileInput = useTemplateRef<HTMLInputElement>('file-input');
|
|
104
|
+
|
|
105
|
+
const isSending = ref(false);
|
|
106
|
+
const isAttaching = ref(false);
|
|
107
|
+
|
|
108
|
+
const isLocked = computed(
|
|
109
|
+
() => props.disabled || isSending.value || isAttaching.value,
|
|
110
|
+
);
|
|
111
|
+
const canSend = computed(() => draft.value.trim().length > 0);
|
|
112
|
+
|
|
113
|
+
const hasAction = (action: ChatComposerAction) =>
|
|
114
|
+
props.actions.includes(action);
|
|
115
|
+
|
|
116
|
+
const textarea = (): HTMLTextAreaElement | null =>
|
|
117
|
+
(field.value?.$el as HTMLElement | undefined)?.querySelector('textarea') ??
|
|
118
|
+
null;
|
|
119
|
+
|
|
120
|
+
function focus() {
|
|
121
|
+
textarea()?.focus();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function insertText(text: string) {
|
|
125
|
+
const el = textarea();
|
|
126
|
+
if (!el || el.disabled) {
|
|
127
|
+
draft.value += text;
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
el.focus();
|
|
131
|
+
// fires an input event, so the textarea's v-model picks the change up
|
|
132
|
+
insertTextAtCursor(el, text);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function send() {
|
|
136
|
+
if (!props.onSend || isLocked.value || !canSend.value) return;
|
|
137
|
+
|
|
138
|
+
isSending.value = true;
|
|
139
|
+
try {
|
|
140
|
+
await props.onSend(draft.value);
|
|
141
|
+
draft.value = '';
|
|
142
|
+
} finally {
|
|
143
|
+
// on reject the draft stays and the error propagates to the app
|
|
144
|
+
isSending.value = false;
|
|
145
|
+
// the field lost focus while disabled; put the operator back in it,
|
|
146
|
+
// to type on after a send or to retry after a failure
|
|
147
|
+
nextTick(focus);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function handleFilesPicked(event: Event) {
|
|
152
|
+
const input = event.target as HTMLInputElement;
|
|
153
|
+
const files = Array.from(input.files ?? []);
|
|
154
|
+
// reset so picking the same file again still fires change
|
|
155
|
+
input.value = '';
|
|
156
|
+
|
|
157
|
+
if (!files.length || !props.onAttach || isLocked.value) return;
|
|
158
|
+
|
|
159
|
+
isAttaching.value = true;
|
|
160
|
+
try {
|
|
161
|
+
await props.onAttach(files);
|
|
162
|
+
} finally {
|
|
163
|
+
isAttaching.value = false;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const slotProps = computed<ChatComposerSlotProps>(() => ({
|
|
168
|
+
insertText,
|
|
169
|
+
focus,
|
|
170
|
+
disabled: isLocked.value,
|
|
171
|
+
}));
|
|
172
|
+
|
|
173
|
+
defineExpose({
|
|
174
|
+
focus,
|
|
175
|
+
insertText,
|
|
176
|
+
});
|
|
177
|
+
</script>
|
|
178
|
+
|
|
179
|
+
<style scoped>
|
|
180
|
+
.chat-composer {
|
|
181
|
+
position: relative;
|
|
182
|
+
display: flex;
|
|
183
|
+
flex-direction: column;
|
|
184
|
+
gap: var(--wt-ws-message-composer-sizes-root-gap);
|
|
185
|
+
padding: var(--wt-ws-message-composer-sizes-root-padding-y) var(--wt-ws-message-composer-sizes-root-padding-x);
|
|
186
|
+
border-radius: var(--wt-ws-message-composer-sizes-root-border-radius);
|
|
187
|
+
background: var(--wt-ws-message-composer-colors-root-background);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
.chat-composer__field {
|
|
191
|
+
min-height: 0;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
.chat-composer__field :deep(textarea) {
|
|
195
|
+
max-height: 30vh;
|
|
196
|
+
min-height: auto;
|
|
197
|
+
overflow: auto !important;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
.chat-composer__actions {
|
|
201
|
+
display: flex;
|
|
202
|
+
align-items: center;
|
|
203
|
+
justify-content: space-between;
|
|
204
|
+
gap: var(--wt-ws-message-composer-sizes-root-gap);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
.chat-composer__actions-start {
|
|
208
|
+
display: flex;
|
|
209
|
+
align-items: center;
|
|
210
|
+
gap: var(--spacing-2xs);
|
|
211
|
+
}
|
|
212
|
+
</style>
|