@webitel/ui-chats 26.8.4 → 26.8.6

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webitel/ui-chats",
3
- "version": "26.8.4",
3
+ "version": "26.8.6",
4
4
  "description": "Reusable Webitel Frontend Code for Chats UI",
5
5
  "workspaces": [
6
6
  "../../",
@@ -211,4 +211,94 @@ describe('getMessageStatus', () => {
211
211
  });
212
212
  expect(getMessageStatus(own('3'), t, operator.id)).toBe('read');
213
213
  });
214
+
215
+ // A re-joined operator gets a new member id, but the thread snapshot (and so
216
+ // selfMemberId) still holds the old one; their contact is the stable identity.
217
+ describe('after the operator rejoined with a new member id', () => {
218
+ const oldMember = {
219
+ id: 'm-me-old',
220
+ contact: {
221
+ name: 'Dania',
222
+ type: 'webitel',
223
+ sub: '164',
224
+ iss: 'webitel',
225
+ },
226
+ };
227
+ const newMember = {
228
+ id: 'm-me-new',
229
+ contact: {
230
+ name: 'Dania',
231
+ type: 'webitel',
232
+ sub: '164',
233
+ iss: 'webitel',
234
+ },
235
+ };
236
+ const t = thread({
237
+ members: [
238
+ oldMember,
239
+ client,
240
+ ],
241
+ readStates: [
242
+ {
243
+ memberId: client.id,
244
+ readUpToSeq: '5',
245
+ },
246
+ ],
247
+ });
248
+
249
+ it('still treats their messages as their own', () => {
250
+ expect(
251
+ getMessageStatus(
252
+ message({
253
+ sender: newMember,
254
+ seq: '3',
255
+ }),
256
+ t,
257
+ oldMember.id,
258
+ ),
259
+ ).toBe('read');
260
+ });
261
+
262
+ it('does not claim another operator’s message', () => {
263
+ const other = {
264
+ id: 'm-other',
265
+ contact: {
266
+ name: 'Ola',
267
+ type: 'webitel',
268
+ sub: '289',
269
+ iss: 'webitel',
270
+ },
271
+ };
272
+ expect(
273
+ getMessageStatus(
274
+ message({
275
+ sender: other,
276
+ }),
277
+ t,
278
+ oldMember.id,
279
+ ),
280
+ ).toBeNull();
281
+ });
282
+
283
+ it('does not match the same subject from another issuer', () => {
284
+ const lookalike = {
285
+ id: 'm-x',
286
+ contact: {
287
+ name: 'Dania',
288
+ type: 'telegram',
289
+ sub: '164',
290
+ iss: 'telegram',
291
+ },
292
+ };
293
+ expect(
294
+ getMessageStatus(
295
+ message({
296
+ sender: lookalike,
297
+ }),
298
+ t,
299
+ oldMember.id,
300
+ ),
301
+ ).toBeNull();
302
+ });
303
+ });
214
304
  });
@@ -1,5 +1,6 @@
1
1
  import type { ChatMessageStatus, MessageModel, ThreadModel } from '../types';
2
2
  import { isContactCentreSide } from './isContactCentreSide';
3
+ import { isSelfMessage } from './isSelfMessage';
3
4
  import { toSeq } from './toTimestamp';
4
5
 
5
6
  /**
@@ -14,7 +15,7 @@ export const getMessageStatus = (
14
15
  selfMemberId: string,
15
16
  ): ChatMessageStatus | null => {
16
17
  if (message.deleted || message.system) return null;
17
- if (!message.sender?.id || message.sender.id !== selfMemberId) return null;
18
+ if (!isSelfMessage(message, thread, selfMemberId)) return null;
18
19
 
19
20
  const seq = toSeq(message.seq);
20
21
  if (seq === null) return 'sent';
@@ -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
+ };
@@ -40,6 +40,7 @@
40
40
  v-if="hasAction(ChatComposerAction.Emoji)"
41
41
  :popup-teleport-to="`#${composerId}`"
42
42
  :rounded="false"
43
+ variant="text"
43
44
  size="sm"
44
45
  :title="t('composer.emoji')"
45
46
  @insert-emoji="insertText"
@@ -54,6 +55,7 @@
54
55
  class="chat-composer__send"
55
56
  icon="chat-send"
56
57
  color="secondary"
58
+ size="sm"
57
59
  :loading="isSending"
58
60
  :disabled="isLocked || !canSend"
59
61
  :title="t('composer.send')"
@@ -102,6 +102,105 @@ describe('ChatHistory', () => {
102
102
  expect(rows[1].find('.message-status').exists()).toBe(true);
103
103
  });
104
104
 
105
+ describe('avatar tooltips', () => {
106
+ const hover = async (
107
+ activator: ReturnType<ReturnType<typeof setup>['find']>,
108
+ ) => {
109
+ await activator.trigger('mouseenter');
110
+ await flushPromises();
111
+ };
112
+
113
+ it('names the author of a message on hover', async () => {
114
+ const wrapper = setup({
115
+ messages: [
116
+ message({
117
+ sender: client,
118
+ }),
119
+ ],
120
+ });
121
+ const activator = wrapper.find('.chat-message .wt-tooltip__activator');
122
+
123
+ expect(wrapper.find('.wt-tooltip__floating').exists()).toBe(false);
124
+ await hover(activator);
125
+
126
+ expect(wrapper.find('.wt-tooltip__floating').text()).toBe(
127
+ 'Emily Johnson',
128
+ );
129
+ });
130
+
131
+ it('names the actor of a system notice on hover', async () => {
132
+ const wrapper = setup({
133
+ messages: [
134
+ message({
135
+ sender: operator,
136
+ system: {
137
+ type: 'transferred',
138
+ },
139
+ }),
140
+ ],
141
+ });
142
+ await hover(wrapper.find('.chat-system-notice .wt-tooltip__activator'));
143
+
144
+ expect(wrapper.find('.wt-tooltip__floating').text()).toBe(
145
+ 'Alina Timoshenko',
146
+ );
147
+ });
148
+
149
+ // an empty tooltip bubble is worse than none
150
+ it('shows no tooltip for an author without a name', async () => {
151
+ const nameless = {
152
+ id: 'm-x',
153
+ contact: {
154
+ type: 'telegram',
155
+ },
156
+ };
157
+ const wrapper = setup({
158
+ messages: [
159
+ message({
160
+ sender: nameless,
161
+ }),
162
+ ],
163
+ });
164
+ await hover(wrapper.find('.chat-message .wt-tooltip__activator'));
165
+
166
+ expect(wrapper.find('.wt-tooltip__floating').exists()).toBe(false);
167
+ });
168
+ });
169
+
170
+ // a rejoined operator gets a new member id, but their contact stays the same
171
+ it('still recognises the operator’s messages after they rejoined under a new member id', () => {
172
+ const me = {
173
+ id: 'm-me',
174
+ contact: {
175
+ name: 'Dania',
176
+ type: 'webitel',
177
+ sub: '164',
178
+ iss: 'webitel',
179
+ },
180
+ };
181
+ const rejoined = {
182
+ id: 'm-me-2',
183
+ contact: {
184
+ ...me.contact,
185
+ },
186
+ };
187
+ const wrapper = setup({
188
+ thread: thread({
189
+ members: [
190
+ me,
191
+ client,
192
+ ],
193
+ }),
194
+ selfMemberId: me.id,
195
+ messages: [
196
+ message({
197
+ sender: rejoined,
198
+ }),
199
+ ],
200
+ });
201
+ expect(wrapper.find('.message-status').exists()).toBe(true);
202
+ });
203
+
105
204
  it('shows a tombstone for deleted messages', () => {
106
205
  const wrapper = setup({
107
206
  messages: [
@@ -82,6 +82,7 @@ import { useChatScroll } from '../../../shared/composables/useChatScroll';
82
82
  import { useObserveHeightUntilStable } from '../../../shared/composables/useObserveHeightUntilStable';
83
83
  import { useChatsV2I18n } from '../../locale/useChatsV2I18n';
84
84
  import { collectGalleryImages } from '../../scripts/attachments';
85
+ import { isSelfMessage } from '../../scripts/isSelfMessage';
85
86
  import { toHistoryItems } from '../../scripts/toHistoryItems';
86
87
  import {
87
88
  type ChatHistoryProps,
@@ -136,8 +137,7 @@ const {
136
137
  chatContainer: scrollContainer,
137
138
  chatContent: scrollContent,
138
139
  messages: computed(() => props.messages),
139
- isSelf: (message) =>
140
- !!message.sender?.id && message.sender.id === props.selfMemberId,
140
+ isSelf: (message) => isSelfMessage(message, props.thread, props.selfMemberId),
141
141
  // a divider must not anchor: a same-day older page keeps its element in place
142
142
  itemClass: 'chat-history__entry',
143
143
  chatId: computed(() => props.thread.id),
@@ -224,8 +224,7 @@ watch(
224
224
  width: 100%;
225
225
  overflow-x: hidden;
226
226
  overflow-y: auto;
227
- padding-right: var(--scrollbar-width);
228
- scrollbar-gutter: stable both-edges;
227
+ scrollbar-gutter: stable;
229
228
  }
230
229
 
231
230
  .chat-history__content {
@@ -233,6 +232,8 @@ watch(
233
232
  flex-direction: column;
234
233
  gap: var(--wt-ws-chat-window-sizes-root-gap);
235
234
  padding: var(--wt-ws-chat-window-sizes-root-padding-y) var(--wt-ws-chat-window-sizes-root-padding-x);
235
+ /* the scrollbar sits a root gap away from the messages, as in DES-730 */
236
+ padding-right: calc(var(--wt-ws-chat-window-sizes-root-padding-x) + var(--wt-ws-chat-window-sizes-root-gap));
236
237
  }
237
238
 
238
239
  .chat-history__sentinel {
@@ -33,4 +33,10 @@ const label = computed(() =>
33
33
  display: flex;
34
34
  justify-content: center;
35
35
  }
36
+
37
+ /* DES-730 draws the chip a step lighter than the default secondary; the
38
+ surface palette does not flip with the theme, so dark keeps the default */
39
+ :global(:root:not(.theme--dark)) .chat-date-divider {
40
+ --p-chip-secondary-background: var(--p-surface-50);
41
+ }
36
42
  </style>
@@ -3,12 +3,20 @@
3
3
  class="chat-message"
4
4
  :class="{ 'chat-message--outgoing': isOutgoing }"
5
5
  >
6
- <wt-avatar
6
+ <wt-tooltip
7
7
  class="chat-message__avatar"
8
- size="sm"
9
- :src="avatarUrl"
10
- :username="authorName"
11
- />
8
+ placement="top"
9
+ :disabled="!authorName"
10
+ >
11
+ <template #activator>
12
+ <wt-avatar
13
+ size="sm"
14
+ :src="avatarUrl"
15
+ :username="authorName"
16
+ />
17
+ </template>
18
+ {{ authorName }}
19
+ </wt-tooltip>
12
20
  <div class="chat-message__bubble">
13
21
  <p
14
22
  v-if="props.message.deleted"
@@ -32,19 +40,19 @@
32
40
  {{ t('history.unsupportedMessage') }}
33
41
  </p>
34
42
  </template>
35
- <div class="chat-message__meta typo-caption">
36
- <span v-if="time">{{ time }}</span>
43
+ <div class="chat-message__meta typo-body-2">
37
44
  <message-status
38
45
  v-if="status"
39
46
  :status="status"
40
47
  />
48
+ <span v-if="time">{{ time }}</span>
41
49
  </div>
42
50
  </div>
43
51
  </div>
44
52
  </template>
45
53
 
46
54
  <script setup lang="ts">
47
- import { WtAvatar } from '@webitel/ui-sdk/components';
55
+ import { WtAvatar, WtTooltip } from '@webitel/ui-sdk/components';
48
56
  import { computed } from 'vue';
49
57
 
50
58
  import { useChatsV2I18n } from '../../../locale/useChatsV2I18n';
@@ -96,6 +104,8 @@ const status = computed(() =>
96
104
 
97
105
  <style scoped>
98
106
  .chat-message {
107
+ --chat-message-tail-size: 8px;
108
+
99
109
  display: flex;
100
110
  align-items: flex-start;
101
111
  gap: var(--wt-ws-chat-window-sizes-message-item-gap);
@@ -114,25 +124,49 @@ const status = computed(() =>
114
124
  display: flex;
115
125
  flex-direction: column;
116
126
  gap: var(--wt-ws-chat-window-sizes-message-item-chat-message-gap);
127
+ position: relative;
117
128
  max-width: 70%;
118
129
  min-width: 0;
119
130
  padding: var(--wt-ws-chat-window-sizes-message-item-chat-message-padding-y) var(--wt-ws-chat-window-sizes-message-item-chat-message-padding-x);
120
131
  border-radius: var(--wt-ws-chat-window-sizes-message-item-chat-message-border-radius);
121
- background: var(--wt-ws-chat-window-colors-message-item-chat-message-client-background);
132
+ background: var(--chat-message-bubble-background);
122
133
  color: var(--wt-ws-chat-window-colors-message-item-chat-message-client-color);
134
+ --chat-message-bubble-background: var(--wt-ws-chat-window-colors-message-item-chat-message-client-background);
123
135
  }
124
136
 
125
- /* the corner next to the avatar is square, as in DES-730 */
137
+ /* the corner next to the avatar is square and carries an 8px tail, as in DES-730 */
126
138
  .chat-message:not(.chat-message--outgoing) .chat-message__bubble {
139
+ margin-left: var(--chat-message-tail-size);
127
140
  border-top-left-radius: 0;
128
141
  }
129
142
 
130
143
  .chat-message--outgoing .chat-message__bubble {
144
+ margin-right: var(--chat-message-tail-size);
131
145
  border-top-right-radius: 0;
132
- background: var(--wt-ws-chat-window-colors-message-item-chat-message-agent-background);
146
+ --chat-message-bubble-background: var(--wt-ws-chat-window-colors-message-item-chat-message-agent-background);
133
147
  color: var(--wt-ws-chat-window-colors-message-item-chat-message-agent-color);
134
148
  }
135
149
 
150
+ /* the tail is the bubble colour left over once a circle is cut out of the
151
+ square beside the bubble's square corner */
152
+ .chat-message__bubble::before {
153
+ position: absolute;
154
+ top: 0;
155
+ width: var(--chat-message-tail-size);
156
+ height: var(--chat-message-tail-size);
157
+ content: '';
158
+ }
159
+
160
+ .chat-message:not(.chat-message--outgoing) .chat-message__bubble::before {
161
+ right: 100%;
162
+ background: radial-gradient(circle 7.333px at 0.667px 7.333px, transparent 7.2px, var(--chat-message-bubble-background) 7.45px);
163
+ }
164
+
165
+ .chat-message--outgoing .chat-message__bubble::before {
166
+ left: 100%;
167
+ background: radial-gradient(circle 7.333px at 7.333px 7.333px, transparent 7.2px, var(--chat-message-bubble-background) 7.45px);
168
+ }
169
+
136
170
  .chat-message__placeholder {
137
171
  margin: 0;
138
172
  font-style: italic;
@@ -5,13 +5,21 @@
5
5
  >
6
6
  <span class="chat-system-notice__line" />
7
7
  <span class="chat-system-notice__content typo-caption">
8
- <wt-avatar
8
+ <wt-tooltip
9
9
  v-if="notice.actor"
10
10
  class="chat-system-notice__avatar"
11
- size="2xs"
12
- :src="props.resolveAvatarUrl?.(notice.actor)"
13
- :username="actorName"
14
- />
11
+ placement="top"
12
+ :disabled="!actorRealName"
13
+ >
14
+ <template #activator>
15
+ <wt-avatar
16
+ size="2xs"
17
+ :src="props.resolveAvatarUrl?.(notice.actor)"
18
+ :username="actorName"
19
+ />
20
+ </template>
21
+ {{ actorRealName }}
22
+ </wt-tooltip>
15
23
  <span class="chat-system-notice__text">{{ text }}</span>
16
24
  <span
17
25
  v-if="time"
@@ -23,7 +31,7 @@
23
31
  </template>
24
32
 
25
33
  <script setup lang="ts">
26
- import { WtAvatar } from '@webitel/ui-sdk/components';
34
+ import { WtAvatar, WtTooltip } from '@webitel/ui-sdk/components';
27
35
  import { computed } from 'vue';
28
36
 
29
37
  import { useChatsV2I18n } from '../../../locale/useChatsV2I18n';
@@ -49,8 +57,11 @@ const { t } = useChatsV2I18n();
49
57
 
50
58
  const notice = computed(() => resolveSystemNotice(props.message, props.thread));
51
59
 
60
+ // the actor's own name; the tooltip stays off for the generic "system" fallback
61
+ const actorRealName = computed(() => participantName(notice.value.actor));
62
+
52
63
  const actorName = computed(
53
- () => participantName(notice.value.actor) || t('systemNotice.system'),
64
+ () => actorRealName.value || t('systemNotice.system'),
54
65
  );
55
66
 
56
67
  const text = computed(() =>
@@ -8,6 +8,7 @@
8
8
  </div>
9
9
  <wt-rounded-action
10
10
  icon="arrow-down"
11
+ size="sm"
11
12
  rounded
12
13
  @click="emit('scroll')"
13
14
  />
@@ -34,7 +35,7 @@ const emit = defineEmits<{
34
35
  <style scoped>
35
36
  .scroll-to-bottom-btn {
36
37
  position: absolute;
37
- right: calc(var(--scrollbar-width) + var(--spacing-xs));
38
+ right: calc(var(--scrollbar-width) + var(--wt-ws-chat-window-sizes-root-gap) + var(--spacing-xs));
38
39
  bottom: var(--spacing-xs);
39
40
  display: flex;
40
41
  flex-direction: column;