@webitel/ui-chats 26.8.3 → 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.
Files changed (105) hide show
  1. package/package.json +9 -2
  2. package/src/locale/en/en.ts +3 -0
  3. package/src/locale/es/es.ts +3 -0
  4. package/src/locale/kz/kz.ts +3 -0
  5. package/src/locale/pl/pl.ts +3 -0
  6. package/src/locale/ro/ro.ts +3 -0
  7. package/src/locale/ru/ru.ts +3 -0
  8. package/src/locale/uk/uk.ts +3 -0
  9. package/src/locale/uz/uz.ts +3 -0
  10. package/src/locale/vi/vi.ts +3 -0
  11. package/src/shared/composables/__tests__/useChatScroll.spec.ts +164 -0
  12. package/src/shared/composables/__tests__/useObserveHeightUntilStable.spec.ts +48 -0
  13. package/src/{ui/messaging → shared}/composables/useChatScroll.ts +37 -9
  14. package/src/{ui/messaging → shared}/composables/useObserveHeightUntilStable.ts +2 -0
  15. package/src/ui/index.ts +2 -2
  16. package/src/ui/messaging/components/the-messages-container.vue +2 -2
  17. package/src/v2/index.ts +4 -0
  18. package/src/v2/locale/en.ts +29 -0
  19. package/src/v2/locale/es.ts +28 -0
  20. package/src/v2/locale/kz.ts +28 -0
  21. package/src/v2/locale/pl.ts +28 -0
  22. package/src/v2/locale/ro.ts +28 -0
  23. package/src/v2/locale/ru.ts +27 -0
  24. package/src/v2/locale/uk.ts +27 -0
  25. package/src/v2/locale/useChatsV2I18n.ts +14 -0
  26. package/src/v2/locale/uz.ts +28 -0
  27. package/src/v2/locale/vi.ts +28 -0
  28. package/src/v2/scripts/__tests__/attachments.spec.ts +168 -0
  29. package/src/v2/scripts/__tests__/fixtures.ts +56 -0
  30. package/src/v2/scripts/__tests__/formatDate.spec.ts +96 -0
  31. package/src/v2/scripts/__tests__/getMessageStatus.spec.ts +214 -0
  32. package/src/v2/scripts/__tests__/isContactCentreSide.spec.ts +21 -0
  33. package/src/v2/scripts/__tests__/resolveSystemNotice.spec.ts +157 -0
  34. package/src/v2/scripts/__tests__/toHistoryItems.spec.ts +100 -0
  35. package/src/v2/scripts/attachments.ts +144 -0
  36. package/src/v2/scripts/formatDate.ts +74 -0
  37. package/src/v2/scripts/getMessageStatus.ts +58 -0
  38. package/src/v2/scripts/isContactCentreSide.ts +14 -0
  39. package/src/v2/scripts/resolveSystemNotice.ts +119 -0
  40. package/src/v2/scripts/toHistoryItems.ts +63 -0
  41. package/src/v2/scripts/toTimestamp.ts +21 -0
  42. package/src/v2/types/index.ts +74 -0
  43. package/src/v2/ui/__tests__/mountWithChats.ts +38 -0
  44. package/src/v2/ui/chat-composer/__tests__/chat-composer.spec.ts +221 -0
  45. package/src/v2/ui/chat-composer/chat-composer.vue +212 -0
  46. package/src/v2/ui/chat-history/__tests__/chat-history.spec.ts +235 -0
  47. package/src/v2/ui/chat-history/chat-history.vue +262 -0
  48. package/src/v2/ui/chat-history/components/chat-date-divider.vue +36 -0
  49. package/src/v2/ui/chat-history/components/chat-message.vue +148 -0
  50. package/src/v2/ui/chat-history/components/chat-system-notice.vue +100 -0
  51. package/src/v2/ui/chat-history/components/message-attachments.vue +162 -0
  52. package/src/v2/ui/chat-history/components/message-status.vue +31 -0
  53. package/src/v2/ui/chat-history/components/message-text.vue +37 -0
  54. package/src/v2/ui/chat-history/components/scroll-to-bottom-btn.vue +55 -0
  55. package/src/v2/ui/chat-history/gallery.ts +8 -0
  56. package/src/v2/ui/chat-thread/__tests__/chat-thread.spec.ts +122 -0
  57. package/src/v2/ui/chat-thread/chat-thread.vue +111 -0
  58. package/types/.tsbuildinfo +1 -1
  59. package/types/locale/en/en.d.ts +25 -0
  60. package/types/locale/es/es.d.ts +25 -0
  61. package/types/locale/index.d.ts +225 -0
  62. package/types/locale/kz/kz.d.ts +25 -0
  63. package/types/locale/pl/pl.d.ts +25 -0
  64. package/types/locale/ro/ro.d.ts +25 -0
  65. package/types/locale/ru/ru.d.ts +25 -0
  66. package/types/locale/uk/uk.d.ts +25 -0
  67. package/types/locale/uz/uz.d.ts +25 -0
  68. package/types/locale/vi/vi.d.ts +25 -0
  69. package/types/{ui/messaging → shared}/composables/useChatScroll.d.ts +14 -4
  70. package/types/ui/index.d.ts +2 -2
  71. package/types/v2/index.d.ts +4 -0
  72. package/types/v2/locale/en.d.ts +27 -0
  73. package/types/v2/locale/es.d.ts +26 -0
  74. package/types/v2/locale/kz.d.ts +26 -0
  75. package/types/v2/locale/pl.d.ts +26 -0
  76. package/types/v2/locale/ro.d.ts +26 -0
  77. package/types/v2/locale/ru.d.ts +26 -0
  78. package/types/v2/locale/uk.d.ts +26 -0
  79. package/types/v2/locale/useChatsV2I18n.d.ts +5 -0
  80. package/types/v2/locale/uz.d.ts +26 -0
  81. package/types/v2/locale/vi.d.ts +26 -0
  82. package/types/v2/scripts/__tests__/fixtures.d.ts +7 -0
  83. package/types/v2/scripts/attachments.d.ts +30 -0
  84. package/types/v2/scripts/formatDate.d.ts +17 -0
  85. package/types/v2/scripts/getMessageStatus.d.ts +8 -0
  86. package/types/v2/scripts/isContactCentreSide.d.ts +8 -0
  87. package/types/v2/scripts/resolveSystemNotice.d.ts +23 -0
  88. package/types/v2/scripts/toHistoryItems.d.ts +19 -0
  89. package/types/v2/scripts/toTimestamp.d.ts +7 -0
  90. package/types/v2/types/index.d.ts +54 -0
  91. package/types/v2/ui/__tests__/mountWithChats.d.ts +8 -0
  92. package/types/v2/ui/chat-composer/chat-composer.vue.d.ts +31 -0
  93. package/types/v2/ui/chat-history/chat-history.vue.d.ts +11 -0
  94. package/types/v2/ui/chat-history/components/chat-date-divider.vue.d.ts +6 -0
  95. package/types/v2/ui/chat-history/components/chat-message.vue.d.ts +10 -0
  96. package/types/v2/ui/chat-history/components/chat-system-notice.vue.d.ts +9 -0
  97. package/types/v2/ui/chat-history/components/message-attachments.vue.d.ts +7 -0
  98. package/types/v2/ui/chat-history/components/message-status.vue.d.ts +7 -0
  99. package/types/v2/ui/chat-history/components/message-text.vue.d.ts +6 -0
  100. package/types/v2/ui/chat-history/components/scroll-to-bottom-btn.vue.d.ts +12 -0
  101. package/types/v2/ui/chat-history/gallery.d.ts +6 -0
  102. package/types/v2/ui/chat-thread/chat-thread.vue.d.ts +26 -0
  103. /package/src/{ui/messaging → shared}/composables/useScrollToBottomBtn.ts +0 -0
  104. /package/types/{ui/messaging → shared}/composables/useObserveHeightUntilStable.d.ts +0 -0
  105. /package/types/{ui/messaging → shared}/composables/useScrollToBottomBtn.d.ts +0 -0
@@ -0,0 +1,100 @@
1
+ <template>
2
+ <div
3
+ class="chat-system-notice"
4
+ :class="`chat-system-notice--${notice.tone}`"
5
+ >
6
+ <span class="chat-system-notice__line" />
7
+ <span class="chat-system-notice__content typo-caption">
8
+ <wt-avatar
9
+ v-if="notice.actor"
10
+ class="chat-system-notice__avatar"
11
+ size="2xs"
12
+ :src="props.resolveAvatarUrl?.(notice.actor)"
13
+ :username="actorName"
14
+ />
15
+ <span class="chat-system-notice__text">{{ text }}</span>
16
+ <span
17
+ v-if="time"
18
+ class="chat-system-notice__time"
19
+ >• {{ time }}</span>
20
+ </span>
21
+ <span class="chat-system-notice__line" />
22
+ </div>
23
+ </template>
24
+
25
+ <script setup lang="ts">
26
+ import { WtAvatar } from '@webitel/ui-sdk/components';
27
+ import { computed } from 'vue';
28
+
29
+ import { useChatsV2I18n } from '../../../locale/useChatsV2I18n';
30
+ import { formatMessageTime } from '../../../scripts/formatDate';
31
+ import {
32
+ participantName,
33
+ resolveSystemNotice,
34
+ } from '../../../scripts/resolveSystemNotice';
35
+ import { toTimestamp } from '../../../scripts/toTimestamp';
36
+ import type {
37
+ MessageModel,
38
+ ResolveAvatarUrl,
39
+ ThreadModel,
40
+ } from '../../../types';
41
+
42
+ const props = defineProps<{
43
+ message: MessageModel;
44
+ thread: ThreadModel;
45
+ resolveAvatarUrl?: ResolveAvatarUrl;
46
+ }>();
47
+
48
+ const { t } = useChatsV2I18n();
49
+
50
+ const notice = computed(() => resolveSystemNotice(props.message, props.thread));
51
+
52
+ const actorName = computed(
53
+ () => participantName(notice.value.actor) || t('systemNotice.system'),
54
+ );
55
+
56
+ const text = computed(() =>
57
+ notice.value.textKey
58
+ ? t(notice.value.textKey, {
59
+ actor: actorName.value,
60
+ })
61
+ : (notice.value.fallbackText ?? ''),
62
+ );
63
+
64
+ const time = computed(() => {
65
+ const timestamp = toTimestamp(props.message.createdAt);
66
+ return timestamp === null ? '' : formatMessageTime(timestamp);
67
+ });
68
+ </script>
69
+
70
+ <style scoped>
71
+ .chat-system-notice {
72
+ display: flex;
73
+ align-items: center;
74
+ gap: var(--spacing-xs);
75
+ color: var(--text-disabled-color);
76
+ font-style: italic;
77
+ }
78
+
79
+ .chat-system-notice__line {
80
+ flex: 1;
81
+ height: 1px;
82
+ background: var(--p-divider-border-color);
83
+ }
84
+
85
+ .chat-system-notice__content {
86
+ display: flex;
87
+ align-items: center;
88
+ gap: var(--spacing-2xs);
89
+ max-width: 70%;
90
+ text-align: center;
91
+ }
92
+
93
+ .chat-system-notice--positive {
94
+ color: var(--text-success-color);
95
+ }
96
+
97
+ .chat-system-notice--negative {
98
+ color: var(--text-error-color);
99
+ }
100
+ </style>
@@ -0,0 +1,162 @@
1
+ <template>
2
+ <div class="message-attachments">
3
+ <div
4
+ v-if="attachments.images.length"
5
+ class="message-attachments__images"
6
+ :class="{ 'message-attachments__images--single': attachments.images.length === 1 }"
7
+ >
8
+ <button
9
+ v-for="{ key, image } of attachments.images"
10
+ :key="key"
11
+ type="button"
12
+ class="message-attachments__image"
13
+ :style="aspectRatio(image)"
14
+ @click="gallery?.open(key)"
15
+ >
16
+ <img
17
+ :src="image.url"
18
+ alt=""
19
+ loading="lazy"
20
+ >
21
+ </button>
22
+ </div>
23
+
24
+ <template
25
+ v-for="{ key, document, kind } of attachments.media"
26
+ :key="key"
27
+ >
28
+ <wt-vidstack-player
29
+ v-if="kind === 'video'"
30
+ class="message-attachments__media"
31
+ :src="document.url"
32
+ :mime="document.mime"
33
+ :title="document.name"
34
+ :size="ComponentSize.SM"
35
+ countdown-time-mode
36
+ static
37
+ hide-expand
38
+ stretch
39
+ />
40
+ <wt-player
41
+ v-else
42
+ class="message-attachments__media"
43
+ :src="document.url"
44
+ :autoplay="false"
45
+ :closable="false"
46
+ countdown-time-mode
47
+ hide-volume-slider
48
+ hide-mute-button
49
+ />
50
+ </template>
51
+
52
+ <a
53
+ v-for="file of attachments.files"
54
+ :key="file.key"
55
+ class="message-attachments__file"
56
+ :href="file.url"
57
+ target="_blank"
58
+ rel="noopener"
59
+ download
60
+ :title="t('history.download')"
61
+ >
62
+ <wt-icon icon="docs" />
63
+ <span class="message-attachments__file-name typo-body-2">{{ file.name }}</span>
64
+ <span
65
+ v-if="file.size"
66
+ class="message-attachments__file-size typo-caption"
67
+ >{{ prettifyFileSize(file.size) }}</span>
68
+ <wt-icon icon="download" />
69
+ </a>
70
+ </div>
71
+ </template>
72
+
73
+ <script setup lang="ts">
74
+ import { WtIcon, WtPlayer, WtVidstackPlayer } from '@webitel/ui-sdk/components';
75
+ import { ComponentSize } from '@webitel/ui-sdk/enums';
76
+ import { prettifyFileSize } from '@webitel/ui-sdk/scripts';
77
+ import { computed, inject } from 'vue';
78
+
79
+ import { useChatsV2I18n } from '../../../locale/useChatsV2I18n';
80
+ import { classifyAttachments } from '../../../scripts/attachments';
81
+ import type { ChatImage, MessageModel } from '../../../types';
82
+ import { ChatGalleryKey } from '../gallery';
83
+
84
+ const props = defineProps<{
85
+ message: MessageModel;
86
+ }>();
87
+
88
+ const { t } = useChatsV2I18n();
89
+ const gallery = inject(ChatGalleryKey, null);
90
+
91
+ const attachments = computed(() => classifyAttachments(props.message));
92
+
93
+ // Reserve the box before the image loads, so a late load cannot shift the
94
+ // scroll position.
95
+ const aspectRatio = (image: ChatImage) => ({
96
+ aspectRatio:
97
+ image.width && image.height ? `${image.width} / ${image.height}` : '1 / 1',
98
+ });
99
+ </script>
100
+
101
+ <style scoped>
102
+ .message-attachments {
103
+ display: flex;
104
+ flex-direction: column;
105
+ gap: var(--spacing-xs);
106
+ }
107
+
108
+ .message-attachments__images {
109
+ display: grid;
110
+ grid-template-columns: repeat(2, minmax(0, 1fr));
111
+ gap: var(--spacing-3xs);
112
+ }
113
+
114
+ .message-attachments__images--single {
115
+ grid-template-columns: minmax(0, 1fr);
116
+ }
117
+
118
+ .message-attachments__image {
119
+ display: block;
120
+ width: 100%;
121
+ max-height: 320px;
122
+ padding: 0;
123
+ border: 0;
124
+ border-radius: var(--border-radius);
125
+ overflow: hidden;
126
+ cursor: zoom-in;
127
+ background: none;
128
+ }
129
+
130
+ .message-attachments__image img {
131
+ display: block;
132
+ width: 100%;
133
+ height: 100%;
134
+ object-fit: cover;
135
+ }
136
+
137
+ .message-attachments__media {
138
+ width: 100%;
139
+ }
140
+
141
+ .message-attachments__file {
142
+ display: flex;
143
+ align-items: center;
144
+ gap: var(--spacing-xs);
145
+ padding: var(--spacing-xs);
146
+ border-radius: var(--border-radius);
147
+ color: inherit;
148
+ text-decoration: none;
149
+ }
150
+
151
+ .message-attachments__file-name {
152
+ flex: 1;
153
+ min-width: 0;
154
+ overflow: hidden;
155
+ text-overflow: ellipsis;
156
+ white-space: nowrap;
157
+ }
158
+
159
+ .message-attachments__file-size {
160
+ color: var(--text-disabled-color);
161
+ }
162
+ </style>
@@ -0,0 +1,31 @@
1
+ <template>
2
+ <wt-icon
3
+ class="message-status"
4
+ :class="`message-status--${props.status}`"
5
+ :icon="props.status === 'sent' ? 'tick' : 'chat-message-status-read'"
6
+ size="sm"
7
+ :aria-label="props.status"
8
+ />
9
+ </template>
10
+
11
+ <script setup lang="ts">
12
+ import { WtIcon } from '@webitel/ui-sdk/components';
13
+
14
+ import type { ChatMessageStatus } from '../../../types';
15
+
16
+ const props = defineProps<{
17
+ status: ChatMessageStatus;
18
+ }>();
19
+ </script>
20
+
21
+ <style scoped>
22
+ /* wt-icon paints with fill: var(--icon-color) */
23
+ .message-status {
24
+ flex-shrink: 0;
25
+ --icon-color: currentColor;
26
+ }
27
+
28
+ .message-status--read {
29
+ --icon-color: var(--info-color);
30
+ }
31
+ </style>
@@ -0,0 +1,37 @@
1
+ <template>
2
+ <p
3
+ class="message-text typo-body-1"
4
+ v-html="linkedText"
5
+ />
6
+ </template>
7
+
8
+ <script setup lang="ts">
9
+ import Autolinker from 'autolinker';
10
+ import { computed } from 'vue';
11
+
12
+ const props = defineProps<{
13
+ text: string;
14
+ }>();
15
+
16
+ // Autolinker keeps literal "<" signs (DEV-2848) and sanitizes the rest
17
+ const linkedText = computed(() =>
18
+ Autolinker.link(props.text, {
19
+ newWindow: true,
20
+ sanitizeHtml: true,
21
+ className: 'message-text__link',
22
+ }),
23
+ );
24
+ </script>
25
+
26
+ <style scoped>
27
+ .message-text {
28
+ margin: 0;
29
+ white-space: pre-wrap;
30
+ overflow-wrap: anywhere;
31
+ }
32
+
33
+ .message-text :deep(.message-text__link) {
34
+ color: var(--text-link-color);
35
+ text-decoration: underline;
36
+ }
37
+ </style>
@@ -0,0 +1,55 @@
1
+ <template>
2
+ <div class="scroll-to-bottom-btn">
3
+ <div
4
+ v-show="props.newMessageCount"
5
+ class="scroll-to-bottom-btn__counter typo-body-2"
6
+ >
7
+ {{ props.newMessageCount }}
8
+ </div>
9
+ <wt-rounded-action
10
+ icon="arrow-down"
11
+ rounded
12
+ @click="emit('scroll')"
13
+ />
14
+ </div>
15
+ </template>
16
+
17
+ <script setup lang="ts">
18
+ import { WtRoundedAction } from '@webitel/ui-sdk/components';
19
+
20
+ const props = withDefaults(
21
+ defineProps<{
22
+ newMessageCount?: number;
23
+ }>(),
24
+ {
25
+ newMessageCount: 0,
26
+ },
27
+ );
28
+
29
+ const emit = defineEmits<{
30
+ scroll: [];
31
+ }>();
32
+ </script>
33
+
34
+ <style scoped>
35
+ .scroll-to-bottom-btn {
36
+ position: absolute;
37
+ right: calc(var(--scrollbar-width) + var(--spacing-xs));
38
+ bottom: var(--spacing-xs);
39
+ display: flex;
40
+ flex-direction: column;
41
+ align-items: center;
42
+ gap: var(--spacing-2xs);
43
+ }
44
+
45
+ .scroll-to-bottom-btn__counter {
46
+ display: flex;
47
+ align-items: center;
48
+ justify-content: center;
49
+ width: 24px;
50
+ height: 24px;
51
+ border-radius: 50%;
52
+ background: var(--success-color);
53
+ color: var(--text-on-brand-color);
54
+ }
55
+ </style>
@@ -0,0 +1,8 @@
1
+ import type { InjectionKey } from 'vue';
2
+
3
+ export interface ChatGallery {
4
+ /** opens the history-wide gallery at the image with this key */
5
+ open: (imageKey: string) => void;
6
+ }
7
+
8
+ export const ChatGalleryKey: InjectionKey<ChatGallery> = Symbol('ChatGallery');
@@ -0,0 +1,122 @@
1
+ import { flushPromises } from '@vue/test-utils';
2
+ import { describe, expect, it, vi } from 'vitest';
3
+
4
+ import { message, operator, thread } from '../../../scripts/__tests__/fixtures';
5
+ import { ChatThreadMode } from '../../../types';
6
+ import { deferred, mountWithChats } from '../../__tests__/mountWithChats';
7
+ import ChatThread from '../chat-thread.vue';
8
+
9
+ vi.mock('@webitel/ui-sdk/components', async (importOriginal) => ({
10
+ ...(await importOriginal<typeof import('@webitel/ui-sdk/components')>()),
11
+ WtChatEmoji: {
12
+ name: 'WtChatEmoji',
13
+ emits: [
14
+ 'insert-emoji',
15
+ ],
16
+ template:
17
+ '<button class="emoji-stub" @click="$emit(\'insert-emoji\', \'🙂\')" />',
18
+ },
19
+ WtGalleria: {
20
+ name: 'WtGalleria',
21
+ props: [
22
+ 'visible',
23
+ 'activeIndex',
24
+ 'value',
25
+ ],
26
+ template: '<div class="galleria-stub" />',
27
+ },
28
+ WtVidstackPlayer: {
29
+ name: 'WtVidstackPlayer',
30
+ template: '<div class="player-stub" />',
31
+ },
32
+ WtPlayer: {
33
+ name: 'WtPlayer',
34
+ template: '<div class="player-stub" />',
35
+ },
36
+ }));
37
+
38
+ const setup = (props = {}) =>
39
+ mountWithChats(ChatThread, {
40
+ attachTo: document.body,
41
+ props: {
42
+ thread: thread(),
43
+ messages: [
44
+ message(),
45
+ ],
46
+ selfMemberId: operator.id,
47
+ ...props,
48
+ },
49
+ });
50
+
51
+ describe('ChatThread', () => {
52
+ it('shows the composer only in active mode', async () => {
53
+ const wrapper = setup({
54
+ mode: ChatThreadMode.Active,
55
+ });
56
+ expect(wrapper.find('.chat-composer').exists()).toBe(true);
57
+ await wrapper.setProps({
58
+ mode: ChatThreadMode.Readonly,
59
+ });
60
+ expect(wrapper.find('.chat-composer').exists()).toBe(false);
61
+ await wrapper.setProps({
62
+ mode: ChatThreadMode.Awaiting,
63
+ });
64
+ expect(wrapper.find('.chat-composer').exists()).toBe(false);
65
+ });
66
+
67
+ // Review Focus #3
68
+ it('drops the draft when the thread changes', async () => {
69
+ const wrapper = setup();
70
+ await wrapper.find('textarea').setValue('half-written');
71
+ await wrapper.setProps({
72
+ thread: thread({
73
+ id: 'thread-2',
74
+ }),
75
+ });
76
+ expect(wrapper.find('textarea').element.value).toBe('');
77
+ });
78
+
79
+ // Review Focus #3
80
+ it('does not let a late send from the previous thread clear the new draft', async () => {
81
+ const pending = deferred();
82
+ const wrapper = setup({
83
+ onSend: vi.fn(() => pending.promise),
84
+ });
85
+ await wrapper.find('textarea').setValue('first');
86
+ await wrapper.find('.chat-composer__send').trigger('click');
87
+ await wrapper.setProps({
88
+ thread: thread({
89
+ id: 'thread-2',
90
+ }),
91
+ });
92
+ await wrapper.find('textarea').setValue('second');
93
+ pending.resolve();
94
+ await flushPromises();
95
+ expect(wrapper.find('textarea').element.value).toBe('second');
96
+ });
97
+
98
+ // spec §4: per-thread drafts via v-model:draft="drafts[thread.id]", undefined until typed
99
+ it('reports draft changes to a v-model:draft bound to an undefined value', async () => {
100
+ const onUpdateDraft = vi.fn();
101
+ const wrapper = setup({
102
+ draft: undefined,
103
+ 'onUpdate:draft': onUpdateDraft,
104
+ });
105
+ await wrapper.find('textarea').setValue('per-thread');
106
+ expect(onUpdateDraft).toHaveBeenLastCalledWith('per-thread');
107
+ });
108
+
109
+ it('forwards the actions slot to the composer', () => {
110
+ const wrapper = mountWithChats(ChatThread, {
111
+ props: {
112
+ thread: thread(),
113
+ messages: [],
114
+ selfMemberId: operator.id,
115
+ },
116
+ slots: {
117
+ actions: '<button class="quick-reply" />',
118
+ },
119
+ });
120
+ expect(wrapper.find('.chat-composer .quick-reply').exists()).toBe(true);
121
+ });
122
+ });
@@ -0,0 +1,111 @@
1
+ <template>
2
+ <section class="chat-thread">
3
+ <chat-history
4
+ class="chat-thread__history"
5
+ :thread="props.thread"
6
+ :messages="props.messages"
7
+ :self-member-id="props.selfMemberId"
8
+ :mode="props.mode"
9
+ :has-more="props.hasMore"
10
+ :on-load-more="props.onLoadMore"
11
+ :resolve-avatar-url="props.resolveAvatarUrl"
12
+ @seen="emit('seen', $event)"
13
+ />
14
+ <!-- keyed by thread: switching chats drops the draft and any pending state -->
15
+ <chat-composer
16
+ v-if="props.mode === ChatThreadMode.Active"
17
+ :key="props.thread.id"
18
+ class="chat-thread__composer"
19
+ :on-send="props.onSend"
20
+ :on-attach="props.onAttach"
21
+ :actions="props.actions"
22
+ :submit-on-enter="props.submitOnEnter"
23
+ :disabled="props.disabled"
24
+ v-bind="draftBinding()"
25
+ >
26
+ <template #actions="slotProps">
27
+ <slot
28
+ name="actions"
29
+ v-bind="slotProps"
30
+ />
31
+ </template>
32
+ </chat-composer>
33
+ </section>
34
+ </template>
35
+
36
+ <script setup lang="ts">
37
+ import { getCurrentInstance } from 'vue';
38
+
39
+ import {
40
+ ChatComposerAction,
41
+ type ChatComposerSlotProps,
42
+ ChatThreadMode,
43
+ type ChatThreadProps,
44
+ type MessageModel,
45
+ } from '../../types';
46
+ import ChatComposer from '../chat-composer/chat-composer.vue';
47
+ import ChatHistory from '../chat-history/chat-history.vue';
48
+
49
+ const props = withDefaults(defineProps<ChatThreadProps>(), {
50
+ mode: ChatThreadMode.Active,
51
+ hasMore: false,
52
+ actions: () => [
53
+ ChatComposerAction.Attach,
54
+ ChatComposerAction.Emoji,
55
+ ChatComposerAction.Send,
56
+ ],
57
+ submitOnEnter: true,
58
+ disabled: false,
59
+ draft: undefined,
60
+ });
61
+
62
+ const emit = defineEmits<{
63
+ seen: [
64
+ message: MessageModel,
65
+ ];
66
+ 'update:draft': [
67
+ draft: string,
68
+ ];
69
+ }>();
70
+
71
+ defineSlots<{
72
+ actions?: (props: ChatComposerSlotProps) => unknown;
73
+ }>();
74
+
75
+ const instance = getCurrentInstance();
76
+
77
+ // Forward the draft only when the app binds v-model:draft; otherwise the
78
+ // composer keeps its own, which the :key above resets per thread. Binding is
79
+ // read from the vnode, not the value: drafts[thread.id] is undefined until
80
+ // the operator types. Called from render, so it follows each parent render.
81
+ function draftBinding() {
82
+ const bound = instance?.vnode.props ?? {};
83
+ const isControlled = 'draft' in bound || 'onUpdate:draft' in bound;
84
+ return isControlled
85
+ ? {
86
+ draft: props.draft ?? '',
87
+ 'onUpdate:draft': (value: string) => emit('update:draft', value),
88
+ }
89
+ : {};
90
+ }
91
+ </script>
92
+
93
+ <style scoped>
94
+ .chat-thread {
95
+ display: flex;
96
+ flex-direction: column;
97
+ gap: var(--wt-ws-chat-window-sizes-root-gap);
98
+ min-height: 0;
99
+ height: 100%;
100
+ }
101
+
102
+ .chat-thread__history {
103
+ flex: 1;
104
+ min-height: 0;
105
+ }
106
+
107
+ .chat-thread__composer {
108
+ flex: 0 0 auto;
109
+ max-height: 50%;
110
+ }
111
+ </style>