@webitel/ui-chats 0.1.40 → 0.1.41
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 +17 -1
- package/src/adapters/chat-web-sdk/toChatMessage.ts +111 -0
- package/src/adapters/index.ts +12 -0
- package/src/types/index.ts +15 -0
- package/src/ui/index.ts +2 -0
- package/src/ui/messaging/components/chat-date-divider.vue +1 -1
- package/src/ui/messaging/components/scroll-to-bottom-btn.vue +0 -2
- package/src/ui/messaging/components/the-messages-container.vue +56 -46
- package/src/ui/messaging/composables/useChatScroll.ts +161 -81
- package/src/ui/messaging/composables/useObserveHeightUntilStable.ts +49 -0
- package/src/ui/messaging/composables/useScrollToBottomBtn.ts +51 -0
- package/src/ui/messaging/modules/message/components/chat-message.vue +1 -2
- package/src/ui/messaging/modules/message/components/details/chat-message-document.vue +1 -2
- package/src/ui/messaging/modules/message/components/details/chat-message-image.vue +0 -2
- package/src/ui/messaging/modules/message/components/details/chat-message-player.vue +1 -2
- package/src/ui/messaging/modules/message/components/details/chat-message-size-exceeded-error.vue +1 -1
- package/src/ui/messaging/modules/message/components/details/chat-message-time.vue +1 -1
- package/src/ui/messaging/types/ChatMessage.types.ts +3 -2
- package/src/ui/the-chat-container.vue +7 -0
- package/src/ui/utils/ResultCallbacks.types.ts +1 -1
- package/src/ui/utils/emitter.ts +1 -1
- package/types/adapters/chat-web-sdk/toChatMessage.d.ts +23 -0
- package/types/adapters/index.d.ts +8 -0
- package/types/types/index.d.ts +8 -0
- package/types/ui/chat-footer/modules/user-input/enums/ChatAction.enum.d.ts +6 -5
- package/types/ui/index.d.ts +7 -3
- package/types/ui/messaging/components/the-messages-container.vue.d.ts +5 -1
- package/types/ui/messaging/composables/useChatScroll.d.ts +13 -3
- package/types/ui/messaging/composables/useObserveHeightUntilStable.d.ts +11 -0
- package/types/ui/messaging/composables/useScrollToBottomBtn.d.ts +9 -0
- package/types/ui/messaging/modules/message/composables/useChatMessageFile.d.ts +2 -0
- package/types/ui/messaging/types/ChatMessage.types.d.ts +33 -32
- package/types/ui/the-chat-container.vue.d.ts +39 -49
- package/types/ui/utils/ResultCallbacks.types.d.ts +1 -1
- package/types/ui/utils/emitter.d.ts +1 -1
- package/types/ui/chat-container.vue.d.ts +0 -76
- package/types/ui/chat-footer/modules/user-input/types/ChatAction.types.d.ts +0 -10
- package/types/ui/chat-input/components/actions/attach-files-action.vue.d.ts +0 -29
- package/types/ui/chat-input/components/actions/emoji-picker-action.vue.d.ts +0 -24
- package/types/ui/chat-input/components/actions/send-message-action.vue.d.ts +0 -29
- package/types/ui/chat-input/components/chat-input-actions-bar.vue.d.ts +0 -43
- package/types/ui/chat-input/components/chat-input-actions-wrapper.vue.d.ts +0 -34
- package/types/ui/chat-input/components/chat-input.vue.d.ts +0 -42
- package/types/ui/chat-input/components/chat-text-field.vue.d.ts +0 -32
- package/types/ui/chat-input/enums/ChatAction.enum.d.ts +0 -6
- package/types/ui/media-viewer/media-viewer.vue.d.ts +0 -11
- package/types/ui/messaging/components/chat-messages-container.vue.d.ts +0 -11
- package/types/ui/messaging/components/the-chat-messages-container.vue.d.ts +0 -40
- package/types/ui/messaging/composebles/useChatScroll.d.ts +0 -12
- package/types/ui/messaging/modules/message/components/details/chat-message-avatar.vue.d.ts +0 -10
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webitel/ui-chats",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.41",
|
|
4
4
|
"description": "Reusable Webitel Frontend Code for Chats UI",
|
|
5
5
|
"workspaces": [
|
|
6
6
|
"../../",
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
"scripts": {
|
|
11
11
|
"dev": "vite",
|
|
12
12
|
"build:types": "vue-tsc -p ./tsconfig.build.json",
|
|
13
|
+
"typecheck": "vue-tsc --noEmit -p ./tsconfig.typecheck.json",
|
|
13
14
|
"lint:fix": "npx biome check --write ./src",
|
|
14
15
|
"biome:ci:gh": "biome ci ./src --reporter=github",
|
|
15
16
|
"test:unit": "node -e \"process.exit(0)\"",
|
|
@@ -26,6 +27,7 @@
|
|
|
26
27
|
"devDependencies": {
|
|
27
28
|
"@biomejs/biome": "2.4.16",
|
|
28
29
|
"@types/node": "^24.10.0",
|
|
30
|
+
"@webitel/chat-web-sdk": "^0.0.19",
|
|
29
31
|
"@vitejs/plugin-vue": "^6.0.1",
|
|
30
32
|
"@vue/tsconfig": "^0.8.1",
|
|
31
33
|
"mitt": "^3",
|
|
@@ -37,11 +39,17 @@
|
|
|
37
39
|
},
|
|
38
40
|
"peerDependencies": {
|
|
39
41
|
"@webitel/api-services": "^0.1.0",
|
|
42
|
+
"@webitel/chat-web-sdk": "^0.0.19",
|
|
40
43
|
"@webitel/styleguide": "~26.2",
|
|
41
44
|
"@webitel/ui-sdk": "~26.6",
|
|
42
45
|
"mitt": "^3",
|
|
43
46
|
"vue": "^3.5"
|
|
44
47
|
},
|
|
48
|
+
"peerDependenciesMeta": {
|
|
49
|
+
"@webitel/chat-web-sdk": {
|
|
50
|
+
"optional": true
|
|
51
|
+
}
|
|
52
|
+
},
|
|
45
53
|
"type": "module",
|
|
46
54
|
"main": "index.ts",
|
|
47
55
|
"types": "index.d.ts",
|
|
@@ -59,6 +67,14 @@
|
|
|
59
67
|
"./ui": {
|
|
60
68
|
"types": "./types/ui/index.d.ts",
|
|
61
69
|
"import": "./src/ui/index.ts"
|
|
70
|
+
},
|
|
71
|
+
"./types": {
|
|
72
|
+
"types": "./types/types/index.d.ts",
|
|
73
|
+
"import": "./src/types/index.ts"
|
|
74
|
+
},
|
|
75
|
+
"./adapters": {
|
|
76
|
+
"types": "./types/adapters/index.d.ts",
|
|
77
|
+
"import": "./src/adapters/index.ts"
|
|
62
78
|
}
|
|
63
79
|
},
|
|
64
80
|
"author": "webitel",
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import type { IMessage } from '@webitel/chat-web-sdk';
|
|
2
|
+
|
|
3
|
+
import type { ChatMember, ChatMessageFile, ChatMessageType } from '../../types';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Sub-shapes of a chat-web-sdk message, derived via indexed access so this
|
|
7
|
+
* adapter depends on a single package (`@webitel/chat-web-sdk`) and stays in
|
|
8
|
+
* sync automatically when the SDK reshapes them.
|
|
9
|
+
*/
|
|
10
|
+
type SdkMember = NonNullable<IMessage['sender']>;
|
|
11
|
+
type SdkImage = NonNullable<IMessage['images']>[number];
|
|
12
|
+
type SdkDocument = NonNullable<IMessage['documents']>[number];
|
|
13
|
+
|
|
14
|
+
export interface ToChatMessageOptions {
|
|
15
|
+
/**
|
|
16
|
+
* Decide whether a message is authored by the current agent / "self" side.
|
|
17
|
+
* Result is written to `ChatMember.self`, which drives outgoing-bubble
|
|
18
|
+
* alignment in the UI. The message alone cannot know who "self" is — that
|
|
19
|
+
* is presentation context the caller owns (e.g. compare `sender.contact`
|
|
20
|
+
* against the logged-in account).
|
|
21
|
+
*/
|
|
22
|
+
isSelf?: (message: IMessage) => boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Parse a chat-web-sdk Unix-ms timestamp (`string`) into the numeric ms the
|
|
27
|
+
* UI contract expects. Returns `0` for missing / non-numeric input.
|
|
28
|
+
*/
|
|
29
|
+
const toTimestamp = (value: string | undefined): number => {
|
|
30
|
+
if (value == null) return 0;
|
|
31
|
+
const parsed = Number(value);
|
|
32
|
+
return Number.isFinite(parsed) ? parsed : 0;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const mapImage = (image: SdkImage): ChatMessageFile => ({
|
|
36
|
+
id: image.id,
|
|
37
|
+
mime: image.mime,
|
|
38
|
+
url: image.url,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const mapDocument = (document: SdkDocument): ChatMessageFile => ({
|
|
42
|
+
id: document.id,
|
|
43
|
+
name: document.name,
|
|
44
|
+
size: document.size,
|
|
45
|
+
mime: document.mime,
|
|
46
|
+
url: document.url,
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* A UI message carries at most one `file`, while the SDK exposes separate
|
|
51
|
+
* `images[]` / `documents[]` arrays. Pick the first available attachment,
|
|
52
|
+
* preferring images.
|
|
53
|
+
*/
|
|
54
|
+
const mapAttachment = (message: IMessage): ChatMessageFile | undefined => {
|
|
55
|
+
const image = message.images?.[0];
|
|
56
|
+
if (image) return mapImage(image);
|
|
57
|
+
|
|
58
|
+
const document = message.documents?.[0];
|
|
59
|
+
if (document) return mapDocument(document);
|
|
60
|
+
|
|
61
|
+
return undefined;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const mapMember = (
|
|
65
|
+
sender: SdkMember | undefined,
|
|
66
|
+
self: boolean | undefined,
|
|
67
|
+
): ChatMember => {
|
|
68
|
+
const contact = sender?.contact;
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
id: sender?.id ?? '',
|
|
72
|
+
name: contact?.name ?? '',
|
|
73
|
+
// UI derives bot rendering from `type === 'bot'`; otherwise keep the
|
|
74
|
+
// SDK channel type ('telegram', 'webchat', …).
|
|
75
|
+
type: contact?.isBot ? 'bot' : (contact?.type ?? ''),
|
|
76
|
+
externalId: contact?.sub ?? contact?.iss,
|
|
77
|
+
self,
|
|
78
|
+
};
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Map a single `@webitel/chat-web-sdk` message (`IMessage`) into the
|
|
83
|
+
* `@webitel/ui-chats` presentation contract (`ChatMessageType`).
|
|
84
|
+
*
|
|
85
|
+
* Anti-corruption layer: keeps `ui-chats` free of any backend/SDK coupling.
|
|
86
|
+
*/
|
|
87
|
+
export const mapMessageToChatMessage = (
|
|
88
|
+
message: IMessage,
|
|
89
|
+
options: ToChatMessageOptions = {},
|
|
90
|
+
): ChatMessageType => {
|
|
91
|
+
const createdAt = toTimestamp(message.createdAt);
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
id: message.id,
|
|
95
|
+
text: message.body,
|
|
96
|
+
createdAt,
|
|
97
|
+
date: createdAt,
|
|
98
|
+
updatedAt: message.editedAt ? toTimestamp(message.editedAt) : undefined,
|
|
99
|
+
file: mapAttachment(message),
|
|
100
|
+
member: mapMember(message.sender, options.isSelf?.(message)),
|
|
101
|
+
};
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Batch variant of {@link mapMessageToChatMessage} for message history pages.
|
|
106
|
+
*/
|
|
107
|
+
export const mapMessagesToChatMessages = (
|
|
108
|
+
messages: readonly IMessage[],
|
|
109
|
+
options?: ToChatMessageOptions,
|
|
110
|
+
): ChatMessageType[] =>
|
|
111
|
+
messages.map((message) => mapMessageToChatMessage(message, options));
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Adapters mapping external data sources into the `@webitel/ui-chats`
|
|
3
|
+
* presentation contract (`ChatMessageType`, …).
|
|
4
|
+
*
|
|
5
|
+
* Consuming this entry pulls `@webitel/chat-web-sdk` (declared as an optional
|
|
6
|
+
* peerDependency). The core `@webitel/ui-chats/ui` entry stays free of it.
|
|
7
|
+
*/
|
|
8
|
+
export {
|
|
9
|
+
mapMessagesToChatMessages,
|
|
10
|
+
mapMessageToChatMessage,
|
|
11
|
+
type ToChatMessageOptions,
|
|
12
|
+
} from './chat-web-sdk/toChatMessage';
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public data contract of `@webitel/ui-chats`.
|
|
3
|
+
*
|
|
4
|
+
* These are the plain, transport-agnostic shapes the UI components accept.
|
|
5
|
+
* External data sources (e.g. `@webitel/chat-web-sdk`) must be mapped into
|
|
6
|
+
* them — see `@webitel/ui-chats/adapters`.
|
|
7
|
+
*/
|
|
8
|
+
export type {
|
|
9
|
+
ChatMember,
|
|
10
|
+
ChatMessageChatInfo,
|
|
11
|
+
ChatMessageFile,
|
|
12
|
+
ChatMessageType,
|
|
13
|
+
ChatVia,
|
|
14
|
+
ContactInfo,
|
|
15
|
+
} from '../ui/messaging/types/ChatMessage.types';
|
package/src/ui/index.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
export { ChatAction } from './chat-footer/modules/user-input/enums/ChatAction.enum';
|
|
2
|
+
export { useChatScroll } from './messaging/composables/useChatScroll';
|
|
3
|
+
export { useObserveHeightUntilStable } from './messaging/composables/useObserveHeightUntilStable';
|
|
2
4
|
export { useChatMessageFile } from './messaging/modules/message/composables/useChatMessageFile';
|
|
3
5
|
export { MessageAction } from './messaging/modules/message/enums/MessageAction.enum';
|
|
4
6
|
export type {
|
|
@@ -19,7 +19,7 @@ const props = defineProps<{
|
|
|
19
19
|
|
|
20
20
|
const { t } = useI18n();
|
|
21
21
|
|
|
22
|
-
const formattedDate = computed<
|
|
22
|
+
const formattedDate = computed<string>(() => {
|
|
23
23
|
const chatDate = formatDate(+props.date, FormatDateMode.DATE);
|
|
24
24
|
const today = formatDate(Date.now(), FormatDateMode.DATE);
|
|
25
25
|
|
|
@@ -7,31 +7,35 @@
|
|
|
7
7
|
ref="messages-container"
|
|
8
8
|
class="the-chat-messages-container__wrapper wt-scrollbar"
|
|
9
9
|
@scroll="handleChatScroll"
|
|
10
|
-
@resize="handleChatResize"
|
|
11
10
|
>
|
|
12
|
-
<
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
:loading="props.isLoading"
|
|
16
|
-
@[ChatAction.LoadNextMessages]="handleLoadNextMessages"
|
|
17
|
-
/>
|
|
18
|
-
<chat-message
|
|
19
|
-
v-for="(message, index) of props.messages"
|
|
20
|
-
:key="message.id"
|
|
21
|
-
:message="message"
|
|
22
|
-
:agent-name="props.agentName"
|
|
23
|
-
:show-avatar="showAvatar(index)"
|
|
24
|
-
:without-avatars="props.withoutAvatars"
|
|
25
|
-
:username="props.contact?.name"
|
|
26
|
-
@[MessageAction.ClickOnImage]="clickOnImage(message)"
|
|
11
|
+
<div
|
|
12
|
+
ref="chat-content"
|
|
13
|
+
class="the-chat-messages-container__content"
|
|
27
14
|
>
|
|
28
|
-
<
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
15
|
+
<chat-observer
|
|
16
|
+
v-if="props.next"
|
|
17
|
+
:next="props.next"
|
|
18
|
+
:loading="props.isLoading"
|
|
19
|
+
@[ChatAction.LoadNextMessages]="handleLoadNextMessages"
|
|
20
|
+
/>
|
|
21
|
+
<chat-message
|
|
22
|
+
v-for="(message, index) of props.messages"
|
|
23
|
+
:key="message.id"
|
|
24
|
+
:message="message"
|
|
25
|
+
:agent-name="props.agentName"
|
|
26
|
+
:show-avatar="showAvatar(index)"
|
|
27
|
+
:without-avatars="props.withoutAvatars"
|
|
28
|
+
:username="props.contact?.name?.commonName"
|
|
29
|
+
@[MessageAction.ClickOnImage]="clickOnImage(message)"
|
|
30
|
+
>
|
|
31
|
+
<template #before-message>
|
|
32
|
+
<chat-date-divider
|
|
33
|
+
v-if="showChatDate(index)"
|
|
34
|
+
:date="message.createdAt"
|
|
35
|
+
/>
|
|
36
|
+
</template>
|
|
37
|
+
</chat-message>
|
|
38
|
+
</div>
|
|
35
39
|
</div>
|
|
36
40
|
<scroll-to-bottom-btn
|
|
37
41
|
v-if="showScrollToBottomBtn"
|
|
@@ -47,17 +51,11 @@
|
|
|
47
51
|
>
|
|
48
52
|
import { WebitelContactsContact } from '@webitel/api-services/gen/models';
|
|
49
53
|
import type { Emitter } from 'mitt';
|
|
50
|
-
import {
|
|
51
|
-
computed,
|
|
52
|
-
defineProps,
|
|
53
|
-
inject,
|
|
54
|
-
nextTick,
|
|
55
|
-
onMounted,
|
|
56
|
-
useTemplateRef,
|
|
57
|
-
} from 'vue';
|
|
54
|
+
import { computed, inject, useTemplateRef } from 'vue';
|
|
58
55
|
import { ChatAction } from '../../chat-footer/modules/user-input/enums/ChatAction.enum';
|
|
59
56
|
import type { UiChatsEmitterEvents } from '../../utils/emitter';
|
|
60
57
|
import { useChatScroll } from '../composables/useChatScroll';
|
|
58
|
+
import { useObserveHeightUntilStable } from '../composables/useObserveHeightUntilStable';
|
|
61
59
|
import ChatMessage from '../modules/message/components/chat-message.vue';
|
|
62
60
|
import { useChatMessages } from '../modules/message/composables/useChatMessage';
|
|
63
61
|
import { MessageAction } from '../modules/message/enums/MessageAction.enum';
|
|
@@ -76,17 +74,22 @@ const props = withDefaults(
|
|
|
76
74
|
withoutAvatars?: boolean;
|
|
77
75
|
agentName?: string;
|
|
78
76
|
contact?: WebitelContactsContact;
|
|
77
|
+
chatId?: string;
|
|
78
|
+
isChatClosed?: boolean;
|
|
79
79
|
}>(),
|
|
80
80
|
{
|
|
81
81
|
next: false,
|
|
82
82
|
isLoading: false,
|
|
83
83
|
withoutAvatars: false,
|
|
84
|
+
chatId: '',
|
|
85
|
+
isChatClosed: false,
|
|
84
86
|
},
|
|
85
87
|
);
|
|
86
88
|
|
|
87
89
|
const emit = defineEmits<(e: typeof ChatAction.LoadNextMessages) => void>();
|
|
88
90
|
|
|
89
|
-
const
|
|
91
|
+
const chatContainer = useTemplateRef('messages-container');
|
|
92
|
+
const chatContent = useTemplateRef('chat-content');
|
|
90
93
|
|
|
91
94
|
const { showAvatar, showChatDate } = useChatMessages(
|
|
92
95
|
computed(() => props.messages),
|
|
@@ -98,11 +101,21 @@ const {
|
|
|
98
101
|
scrollToBottom,
|
|
99
102
|
loadNextMessages,
|
|
100
103
|
handleChatScroll,
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
computed(() => props.messages), // props values reactivity https://stackoverflow.com/questions/72408463/use-props-in-composables-vue3 @author ye.pohranichna
|
|
105
|
-
computed(() => props.
|
|
104
|
+
} = useChatScroll({
|
|
105
|
+
chatContainer,
|
|
106
|
+
chatContent,
|
|
107
|
+
messages: computed(() => props.messages), // props values reactivity https://stackoverflow.com/questions/72408463/use-props-in-composables-vue3 @author ye.pohranichna
|
|
108
|
+
chatId: computed(() => props.chatId),
|
|
109
|
+
isChatClosed: computed(() => props.isChatClosed),
|
|
110
|
+
isLoading: computed(() => props.isLoading),
|
|
111
|
+
onBeforeStart: ({ scrollToBottom }) => {
|
|
112
|
+
scrollToBottom();
|
|
113
|
+
startObserve();
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const { startObserve } = useObserveHeightUntilStable(chatContainer, () =>
|
|
118
|
+
scrollToBottom('instant'),
|
|
106
119
|
);
|
|
107
120
|
|
|
108
121
|
function handleLoadNextMessages() {
|
|
@@ -117,13 +130,7 @@ function clickOnImage(message: ChatMessageType) {
|
|
|
117
130
|
uiChatsEmitter?.emit('clickChatMessageImage', message);
|
|
118
131
|
}
|
|
119
132
|
|
|
120
|
-
|
|
121
|
-
/* TODO: add loader on all chat(in the-chat-container?) and remove timeout after that */
|
|
122
|
-
await nextTick();
|
|
123
|
-
setTimeout(() => {
|
|
124
|
-
scrollToBottom();
|
|
125
|
-
}, 500);
|
|
126
|
-
});
|
|
133
|
+
// TODO: add loader for all chats(in the-chat-container or in calling component?)
|
|
127
134
|
</script>
|
|
128
135
|
|
|
129
136
|
<style
|
|
@@ -138,14 +145,17 @@ onMounted(async () => {
|
|
|
138
145
|
}
|
|
139
146
|
|
|
140
147
|
.the-chat-messages-container__wrapper {
|
|
141
|
-
display: flex;
|
|
142
|
-
flex-direction: column;
|
|
143
148
|
box-sizing: border-box;
|
|
144
149
|
overflow-x: hidden;
|
|
145
150
|
overflow-y: auto;
|
|
146
151
|
width: 100%;
|
|
147
152
|
padding-right: var(--scrollbar-width); // scrollbar offset
|
|
148
153
|
scrollbar-gutter: stable both-edges;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
.the-chat-messages-container__content {
|
|
157
|
+
display: flex;
|
|
158
|
+
flex-direction: column;
|
|
149
159
|
gap: var(--spacing-xs);
|
|
150
160
|
}
|
|
151
161
|
</style>
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { useScroll } from '@vueuse/core';
|
|
1
|
+
import { useResizeObserver, useScroll } from '@vueuse/core';
|
|
2
2
|
import {
|
|
3
3
|
type ComputedRef,
|
|
4
4
|
computed,
|
|
5
5
|
nextTick,
|
|
6
|
+
onUnmounted,
|
|
6
7
|
type Ref,
|
|
7
8
|
ref,
|
|
8
9
|
watch,
|
|
@@ -10,105 +11,152 @@ import {
|
|
|
10
11
|
|
|
11
12
|
import type { ChatMessageType } from '../types/ChatMessage.types';
|
|
12
13
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
const
|
|
64
|
-
newUnseenMessagesCount.value = 0;
|
|
65
|
-
showScrollToBottomBtn.value = false;
|
|
66
|
-
};
|
|
67
|
-
|
|
68
|
-
const updateThreshold = (el: HTMLElement) => {
|
|
69
|
-
/* @author ye.pohranichna
|
|
70
|
-
need to update if clientHeight was changed
|
|
71
|
-
https://webitel.atlassian.net/browse/WTEL-7136 */
|
|
72
|
-
threshold.value = Math.max(defaultThreshold, el.clientHeight * 0.3);
|
|
73
|
-
};
|
|
14
|
+
import { useScrollToBottomBtn } from './useScrollToBottomBtn';
|
|
15
|
+
|
|
16
|
+
export interface UseChatScrollOptions {
|
|
17
|
+
chatContainer: Ref<HTMLElement | null>;
|
|
18
|
+
chatContent: Ref<HTMLElement | null>;
|
|
19
|
+
messages: Ref<ChatMessageType[]> | ComputedRef<ChatMessageType[]>;
|
|
20
|
+
chatId: ComputedRef<string>;
|
|
21
|
+
isChatClosed: ComputedRef<boolean>;
|
|
22
|
+
isLoading?: Ref<boolean> | ComputedRef<boolean>;
|
|
23
|
+
onBeforeStart?: (options: {
|
|
24
|
+
scrollToBottom: (behavior?: ScrollBehavior) => void;
|
|
25
|
+
}) => void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const useChatScroll = ({
|
|
29
|
+
chatContainer,
|
|
30
|
+
chatContent,
|
|
31
|
+
messages,
|
|
32
|
+
chatId,
|
|
33
|
+
isChatClosed,
|
|
34
|
+
isLoading,
|
|
35
|
+
onBeforeStart = (options) => {
|
|
36
|
+
options.scrollToBottom();
|
|
37
|
+
},
|
|
38
|
+
}: UseChatScrollOptions) => {
|
|
39
|
+
const { arrivedState } = useScroll(chatContainer);
|
|
40
|
+
|
|
41
|
+
const {
|
|
42
|
+
showScrollToBottomBtn,
|
|
43
|
+
handleChatScroll,
|
|
44
|
+
resetScrollToBottomBtn,
|
|
45
|
+
updateScrollToBottomBtnVisibility,
|
|
46
|
+
updateThreshold,
|
|
47
|
+
} = useScrollToBottomBtn(chatContainer, arrivedState);
|
|
48
|
+
|
|
49
|
+
let isLoadingNextMessages = false;
|
|
50
|
+
let lastVisibleMessageEl: HTMLElement | null = null;
|
|
51
|
+
let prevScrollHeight = 0;
|
|
52
|
+
let resizeObserver: ResizeObserver | null = null;
|
|
53
|
+
/**
|
|
54
|
+
* @author PolinaSukhorukova-webitel
|
|
55
|
+
*
|
|
56
|
+
* 2 tolerates subpixel scrollTop/clientHeight rounding that can leave
|
|
57
|
+
* their sum a fraction short of scrollHeight at the very bottom.
|
|
58
|
+
*/
|
|
59
|
+
const bottomThreshold = 2;
|
|
60
|
+
|
|
61
|
+
const newUnseenMessagesCount = ref(0);
|
|
62
|
+
|
|
63
|
+
const lastMessage = computed(() => messages.value?.at(-1));
|
|
64
|
+
const isLastMessageMy = computed(() => !!lastMessage.value?.member?.self);
|
|
74
65
|
|
|
75
66
|
const handleBtnAfterNewMessage = () => {
|
|
76
|
-
if (
|
|
77
|
-
scrollToBottom('
|
|
78
|
-
} else {
|
|
67
|
+
if (isLastMessageMy.value) {
|
|
68
|
+
scrollToBottom('instant');
|
|
69
|
+
} else if (!arrivedState.bottom) {
|
|
79
70
|
newUnseenMessagesCount.value += 1;
|
|
80
71
|
}
|
|
81
72
|
};
|
|
82
73
|
|
|
83
74
|
const scrollToBottom = (behavior: ScrollBehavior = 'instant') => {
|
|
84
|
-
|
|
85
|
-
top:
|
|
86
|
-
behavior
|
|
75
|
+
chatContainer.value?.scrollTo({
|
|
76
|
+
top: chatContainer.value?.scrollHeight,
|
|
77
|
+
behavior,
|
|
87
78
|
});
|
|
88
79
|
|
|
89
80
|
newUnseenMessagesCount.value = 0;
|
|
90
|
-
showScrollToBottomBtn.value = false;
|
|
91
|
-
};
|
|
92
81
|
|
|
82
|
+
resetScrollToBottomBtn();
|
|
83
|
+
};
|
|
93
84
|
const getTopMessageEl = () => {
|
|
94
85
|
// help to fix chat viewing position when new messages was loaded
|
|
95
|
-
if (!
|
|
96
|
-
lastVisibleMessageEl
|
|
97
|
-
|
|
86
|
+
if (!chatContainer.value?.children) return;
|
|
87
|
+
lastVisibleMessageEl =
|
|
88
|
+
(chatContainer.value?.getElementsByClassName(
|
|
89
|
+
'chat-message',
|
|
90
|
+
)[0] as HTMLElement) ?? null;
|
|
98
91
|
};
|
|
99
92
|
|
|
100
93
|
const loadNextMessages = (
|
|
101
|
-
canLoadMore: boolean
|
|
94
|
+
canLoadMore: boolean,
|
|
102
95
|
onLoadNextMessages: () => void,
|
|
103
96
|
) => {
|
|
104
|
-
if (isLoadingNextMessages
|
|
97
|
+
if (isLoadingNextMessages || isLoading?.value || !canLoadMore) return;
|
|
105
98
|
|
|
106
|
-
isLoadingNextMessages
|
|
99
|
+
isLoadingNextMessages = true;
|
|
107
100
|
getTopMessageEl();
|
|
108
101
|
|
|
109
102
|
onLoadNextMessages();
|
|
110
103
|
};
|
|
111
104
|
|
|
105
|
+
const startStickToBottomObserving = () => {
|
|
106
|
+
if (!chatContent.value) return;
|
|
107
|
+
|
|
108
|
+
prevScrollHeight = chatContainer.value?.scrollHeight ?? 0;
|
|
109
|
+
|
|
110
|
+
resizeObserver = new ResizeObserver(() => {
|
|
111
|
+
const el = chatContainer.value;
|
|
112
|
+
if (!el) return;
|
|
113
|
+
|
|
114
|
+
const newScrollHeight = el.scrollHeight;
|
|
115
|
+
|
|
116
|
+
const wasNearBottom =
|
|
117
|
+
el.scrollTop + el.clientHeight >= prevScrollHeight - bottomThreshold;
|
|
118
|
+
const contentGrown = newScrollHeight > prevScrollHeight;
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* @author PolinaSukhorukova-webitel
|
|
122
|
+
*
|
|
123
|
+
* arrivedState.bottom lags after programmatic scrollTo,
|
|
124
|
+
* so the distance is also checked manually.
|
|
125
|
+
*/
|
|
126
|
+
const shouldScrollToBottom =
|
|
127
|
+
contentGrown && (arrivedState.bottom || wasNearBottom);
|
|
128
|
+
|
|
129
|
+
if (shouldScrollToBottom) {
|
|
130
|
+
scrollToBottom('instant');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
prevScrollHeight = newScrollHeight;
|
|
134
|
+
updateScrollToBottomBtnVisibility(el);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
resizeObserver.observe(chatContent.value);
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const stopStickToBottomObserving = () => {
|
|
141
|
+
resizeObserver?.disconnect();
|
|
142
|
+
resizeObserver = null;
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
useResizeObserver(chatContainer, () => {
|
|
146
|
+
const el = chatContainer.value;
|
|
147
|
+
if (!el) return;
|
|
148
|
+
|
|
149
|
+
updateThreshold(el.clientHeight);
|
|
150
|
+
updateScrollToBottomBtnVisibility(el);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
const resetScrollState = () => {
|
|
154
|
+
stopStickToBottomObserving();
|
|
155
|
+
prevScrollHeight = 0;
|
|
156
|
+
newUnseenMessagesCount.value = 0;
|
|
157
|
+
resetScrollToBottomBtn();
|
|
158
|
+
};
|
|
159
|
+
|
|
112
160
|
watch(
|
|
113
161
|
() => messages.value?.length,
|
|
114
162
|
(newValue, oldValue) => {
|
|
@@ -123,25 +171,57 @@ export const useChatScroll = (
|
|
|
123
171
|
watch(
|
|
124
172
|
() => isLoading?.value,
|
|
125
173
|
async (loading) => {
|
|
126
|
-
|
|
174
|
+
// restore scroll position after older messages are prepended:
|
|
175
|
+
// scroll to the previously top message so the view doesn't jump
|
|
176
|
+
if (loading || !isLoadingNextMessages) return;
|
|
127
177
|
|
|
128
178
|
await nextTick();
|
|
129
179
|
|
|
130
|
-
|
|
131
|
-
top: lastVisibleMessageEl
|
|
180
|
+
chatContainer.value?.scrollTo({
|
|
181
|
+
top: lastVisibleMessageEl?.offsetTop,
|
|
132
182
|
behavior: 'auto',
|
|
133
183
|
});
|
|
134
184
|
|
|
135
|
-
isLoadingNextMessages
|
|
185
|
+
isLoadingNextMessages = false;
|
|
136
186
|
},
|
|
137
187
|
);
|
|
138
188
|
|
|
189
|
+
watch(
|
|
190
|
+
() => chatId.value,
|
|
191
|
+
async () => {
|
|
192
|
+
resetScrollState();
|
|
193
|
+
|
|
194
|
+
if (isChatClosed.value) return;
|
|
195
|
+
|
|
196
|
+
await nextTick();
|
|
197
|
+
|
|
198
|
+
onBeforeStart?.({
|
|
199
|
+
scrollToBottom,
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
startStickToBottomObserving();
|
|
203
|
+
},
|
|
204
|
+
{
|
|
205
|
+
immediate: true,
|
|
206
|
+
},
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
watch(
|
|
210
|
+
() => arrivedState.bottom,
|
|
211
|
+
(bottom) => {
|
|
212
|
+
if (bottom) newUnseenMessagesCount.value = 0;
|
|
213
|
+
},
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
onUnmounted(() => {
|
|
217
|
+
stopStickToBottomObserving();
|
|
218
|
+
});
|
|
219
|
+
|
|
139
220
|
return {
|
|
140
221
|
showScrollToBottomBtn,
|
|
141
222
|
newUnseenMessagesCount,
|
|
142
223
|
scrollToBottom,
|
|
143
224
|
loadNextMessages,
|
|
144
225
|
handleChatScroll,
|
|
145
|
-
handleChatResize,
|
|
146
226
|
};
|
|
147
227
|
};
|