@webitel/ui-chats 26.8.4 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webitel/ui-chats",
3
- "version": "26.8.4",
3
+ "version": "26.8.5",
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
+ };
@@ -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),
@@ -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"
@@ -44,7 +52,7 @@
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';
@@ -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(() =>