@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 +1 -1
- package/src/v2/scripts/__tests__/getMessageStatus.spec.ts +90 -0
- package/src/v2/scripts/getMessageStatus.ts +2 -1
- package/src/v2/scripts/isSelfMessage.ts +29 -0
- package/src/v2/ui/chat-history/__tests__/chat-history.spec.ts +99 -0
- package/src/v2/ui/chat-history/chat-history.vue +2 -2
- package/src/v2/ui/chat-history/components/chat-message.vue +14 -6
- package/src/v2/ui/chat-history/components/chat-system-notice.vue +18 -7
- package/types/.tsbuildinfo +1 -1
- package/types/v2/scripts/isSelfMessage.d.ts +10 -0
package/package.json
CHANGED
|
@@ -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
|
|
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-
|
|
6
|
+
<wt-tooltip
|
|
7
7
|
class="chat-message__avatar"
|
|
8
|
-
|
|
9
|
-
:
|
|
10
|
-
|
|
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-
|
|
8
|
+
<wt-tooltip
|
|
9
9
|
v-if="notice.actor"
|
|
10
10
|
class="chat-system-notice__avatar"
|
|
11
|
-
|
|
12
|
-
:
|
|
13
|
-
|
|
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
|
-
() =>
|
|
64
|
+
() => actorRealName.value || t('systemNotice.system'),
|
|
54
65
|
);
|
|
55
66
|
|
|
56
67
|
const text = computed(() =>
|