@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.
Files changed (50) hide show
  1. package/package.json +17 -1
  2. package/src/adapters/chat-web-sdk/toChatMessage.ts +111 -0
  3. package/src/adapters/index.ts +12 -0
  4. package/src/types/index.ts +15 -0
  5. package/src/ui/index.ts +2 -0
  6. package/src/ui/messaging/components/chat-date-divider.vue +1 -1
  7. package/src/ui/messaging/components/scroll-to-bottom-btn.vue +0 -2
  8. package/src/ui/messaging/components/the-messages-container.vue +56 -46
  9. package/src/ui/messaging/composables/useChatScroll.ts +161 -81
  10. package/src/ui/messaging/composables/useObserveHeightUntilStable.ts +49 -0
  11. package/src/ui/messaging/composables/useScrollToBottomBtn.ts +51 -0
  12. package/src/ui/messaging/modules/message/components/chat-message.vue +1 -2
  13. package/src/ui/messaging/modules/message/components/details/chat-message-document.vue +1 -2
  14. package/src/ui/messaging/modules/message/components/details/chat-message-image.vue +0 -2
  15. package/src/ui/messaging/modules/message/components/details/chat-message-player.vue +1 -2
  16. package/src/ui/messaging/modules/message/components/details/chat-message-size-exceeded-error.vue +1 -1
  17. package/src/ui/messaging/modules/message/components/details/chat-message-time.vue +1 -1
  18. package/src/ui/messaging/types/ChatMessage.types.ts +3 -2
  19. package/src/ui/the-chat-container.vue +7 -0
  20. package/src/ui/utils/ResultCallbacks.types.ts +1 -1
  21. package/src/ui/utils/emitter.ts +1 -1
  22. package/types/adapters/chat-web-sdk/toChatMessage.d.ts +23 -0
  23. package/types/adapters/index.d.ts +8 -0
  24. package/types/types/index.d.ts +8 -0
  25. package/types/ui/chat-footer/modules/user-input/enums/ChatAction.enum.d.ts +6 -5
  26. package/types/ui/index.d.ts +7 -3
  27. package/types/ui/messaging/components/the-messages-container.vue.d.ts +5 -1
  28. package/types/ui/messaging/composables/useChatScroll.d.ts +13 -3
  29. package/types/ui/messaging/composables/useObserveHeightUntilStable.d.ts +11 -0
  30. package/types/ui/messaging/composables/useScrollToBottomBtn.d.ts +9 -0
  31. package/types/ui/messaging/modules/message/composables/useChatMessageFile.d.ts +2 -0
  32. package/types/ui/messaging/types/ChatMessage.types.d.ts +33 -32
  33. package/types/ui/the-chat-container.vue.d.ts +39 -49
  34. package/types/ui/utils/ResultCallbacks.types.d.ts +1 -1
  35. package/types/ui/utils/emitter.d.ts +1 -1
  36. package/types/ui/chat-container.vue.d.ts +0 -76
  37. package/types/ui/chat-footer/modules/user-input/types/ChatAction.types.d.ts +0 -10
  38. package/types/ui/chat-input/components/actions/attach-files-action.vue.d.ts +0 -29
  39. package/types/ui/chat-input/components/actions/emoji-picker-action.vue.d.ts +0 -24
  40. package/types/ui/chat-input/components/actions/send-message-action.vue.d.ts +0 -29
  41. package/types/ui/chat-input/components/chat-input-actions-bar.vue.d.ts +0 -43
  42. package/types/ui/chat-input/components/chat-input-actions-wrapper.vue.d.ts +0 -34
  43. package/types/ui/chat-input/components/chat-input.vue.d.ts +0 -42
  44. package/types/ui/chat-input/components/chat-text-field.vue.d.ts +0 -32
  45. package/types/ui/chat-input/enums/ChatAction.enum.d.ts +0 -6
  46. package/types/ui/media-viewer/media-viewer.vue.d.ts +0 -11
  47. package/types/ui/messaging/components/chat-messages-container.vue.d.ts +0 -11
  48. package/types/ui/messaging/components/the-chat-messages-container.vue.d.ts +0 -40
  49. package/types/ui/messaging/composebles/useChatScroll.d.ts +0 -12
  50. 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.40",
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<Date>(() => {
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
 
@@ -17,8 +17,6 @@
17
17
  </template>
18
18
 
19
19
  <script setup lang="ts">
20
- import { defineEmits } from 'vue';
21
-
22
20
  const props = withDefaults(
23
21
  defineProps<{
24
22
  newMessageCount?: number;
@@ -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
- <chat-observer
13
- v-if="props.next"
14
- :next="props.next"
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
- <template #before-message>
29
- <chat-date-divider
30
- v-if="showChatDate(index)"
31
- :date="message.createdAt"
32
- />
33
- </template>
34
- </chat-message>
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 messagesContainer = useTemplateRef('messages-container');
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
- handleChatResize,
102
- } = useChatScroll(
103
- messagesContainer,
104
- computed(() => props.messages), // props values reactivity https://stackoverflow.com/questions/72408463/use-props-in-composables-vue3 @author ye.pohranichna
105
- computed(() => props.isLoading),
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
- onMounted(async () => {
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
- export const useChatScroll = (
14
- element: Ref<HTMLElement | null> = null,
15
- messages: Ref<ChatMessageType[]> | ComputedRef<ChatMessageType[]>,
16
- isLoading?: Ref<boolean> | ComputedRef<boolean>,
17
- ) => {
18
- /* @author ye.pohranichna
19
- why 136px? because: https://webitel.atlassian.net/browse/WTEL-7136 */
20
- const defaultThreshold = 136;
21
- const { arrivedState } = useScroll(element);
22
-
23
- const newUnseenMessagesCount = ref<number>(0);
24
- const showScrollToBottomBtn = ref<boolean>(false);
25
- /* @author ye.pohranichna
26
- the distance where the scrollToBottomBtn must be shown/hide. */
27
- const threshold = ref<number>(defaultThreshold);
28
- const isLoadingNextMessages = ref<boolean>(false);
29
- const lastVisibleMessageEl = ref<HTMLElement | null>(null);
30
-
31
- const isLastMessageIsMy = computed<boolean>(
32
- () => lastMessage.value?.member?.self,
33
- );
34
- const lastMessage = computed<ChatMessageType>(() => messages.value?.at(-1));
35
- const handleChatScroll = () => {
36
- const wrapper = element.value;
37
- if (!wrapper) return;
38
-
39
- handleShowScrollToBottomBtn(wrapper);
40
- };
41
-
42
- const handleChatResize = () => {
43
- const wrapper = element.value;
44
- if (!wrapper) return;
45
-
46
- updateThreshold(wrapper);
47
- handleShowScrollToBottomBtn(wrapper);
48
- };
49
-
50
- const handleShowScrollToBottomBtn = (el: HTMLElement) => {
51
- if (arrivedState.bottom) {
52
- resetScrollToBottomBtn();
53
- return;
54
- /* @author ye.pohranichna
55
- quit the function because we are already at the bottom */
56
- }
57
-
58
- const { scrollTop, scrollHeight, clientHeight } = el;
59
- const distanceFromBottom = scrollHeight - (scrollTop + clientHeight);
60
- showScrollToBottomBtn.value = distanceFromBottom > threshold.value;
61
- };
62
-
63
- const resetScrollToBottomBtn = (): void => {
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 (arrivedState.bottom || isLastMessageIsMy.value) {
77
- scrollToBottom('smooth');
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
- element?.value.scrollTo({
85
- top: element?.value.scrollHeight,
86
- behavior: behavior === 'instant' ? 'auto' : 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 (!element.value?.children) return;
96
- lastVisibleMessageEl.value =
97
- element.value?.getElementsByClassName('chat-message')[0] ?? null;
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 | undefined,
94
+ canLoadMore: boolean,
102
95
  onLoadNextMessages: () => void,
103
96
  ) => {
104
- if (isLoadingNextMessages.value || isLoading?.value || !canLoadMore) return;
97
+ if (isLoadingNextMessages || isLoading?.value || !canLoadMore) return;
105
98
 
106
- isLoadingNextMessages.value = true;
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
- if (loading || !isLoadingNextMessages.value) return;
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
- element.value?.scrollTo({
131
- top: lastVisibleMessageEl.value?.offsetTop,
180
+ chatContainer.value?.scrollTo({
181
+ top: lastVisibleMessageEl?.offsetTop,
132
182
  behavior: 'auto',
133
183
  });
134
184
 
135
- isLoadingNextMessages.value = false;
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
  };