From 69e011c26a814a2e07e2e1d197f13383493b2ed5 Mon Sep 17 00:00:00 2001 From: EugeniyKiyashko Date: Mon, 30 Sep 2024 22:31:50 +0400 Subject: [PATCH] Chat: scrollable integration enhancements (#28121) --- .../js/__internal/ui/chat/messagelist.ts | 64 +++---- .../scroll_view/utils/get_scroll_top_max.ts | 2 +- .../chatParts/messageList.tests.js | 171 ++++++++++++++++-- 3 files changed, 184 insertions(+), 53 deletions(-) diff --git a/packages/devextreme/js/__internal/ui/chat/messagelist.ts b/packages/devextreme/js/__internal/ui/chat/messagelist.ts index 0b0ccab353c1..09979b393874 100644 --- a/packages/devextreme/js/__internal/ui/chat/messagelist.ts +++ b/packages/devextreme/js/__internal/ui/chat/messagelist.ts @@ -1,14 +1,10 @@ -import domAdapter from '@js/core/dom_adapter'; import type { dxElementWrapper } from '@js/core/renderer'; import $ from '@js/core/renderer'; import resizeObserverSingleton from '@js/core/resize_observer'; import dateSerialization from '@js/core/utils/date_serialization'; -import { contains } from '@js/core/utils/dom'; -import { hasWindow } from '@js/core/utils/window'; +import { isElementInDom } from '@js/core/utils/dom'; +import { isDefined } from '@js/core/utils/type'; import messageLocalization from '@js/localization/message'; -import { - isReachedBottom, -} from '@js/renovation/ui/scroll_view/utils/get_boundary_props'; import { getScrollTopMax } from '@js/renovation/ui/scroll_view/utils/get_scroll_top_max'; import type { Message } from '@js/ui/chat'; import Scrollable from '@js/ui/scroll_view/ui.scrollable'; @@ -27,6 +23,7 @@ const CHAT_MESSAGELIST_EMPTY_IMAGE_CLASS = 'dx-chat-messagelist-empty-image'; const CHAT_MESSAGELIST_EMPTY_MESSAGE_CLASS = 'dx-chat-messagelist-empty-message'; const CHAT_MESSAGELIST_EMPTY_PROMPT_CLASS = 'dx-chat-messagelist-empty-prompt'; +const SCROLLABLE_CONTAINER_CLASS = 'dx-scrollable-container'; export const MESSAGEGROUP_TIMEOUT = 5 * 1000 * 60; export interface Properties extends WidgetOptions { @@ -37,9 +34,7 @@ export interface Properties extends WidgetOptions { class MessageList extends Widget { private _messageGroups?: MessageGroup[]; - private _containerClientHeight = 0; - - private _suppressResizeHandling?: boolean; + private _containerClientHeight!: number; private _scrollable!: Scrollable; @@ -63,47 +58,43 @@ class MessageList extends Widget { super._initMarkup(); this._renderScrollable(); - this._renderMessageListContent(); + } - this._attachResizeObserverSubscription(); + _renderContentImpl(): void { + super._renderContentImpl(); - this._suppressResizeHandling = true; + this._attachResizeObserverSubscription(); } _attachResizeObserverSubscription(): void { - if (hasWindow()) { - const element = this._getScrollContainer(); - - resizeObserverSingleton.unobserve(element); - resizeObserverSingleton.observe(element, (entry) => this._resizeHandler(entry)); - } - } + const element = this.$element().get(0); - _isAttached(element: Element): boolean { - return !!contains(domAdapter.getBody(), element); + resizeObserverSingleton.unobserve(element); + resizeObserverSingleton.observe(element, (entry) => this._resizeHandler(entry)); } _resizeHandler({ contentRect, target }: ResizeObserverEntry): void { + if (!isElementInDom($(target)) || !isElementVisible(target as HTMLElement)) { + return; + } + + const isInitialRendering = !isDefined(this._containerClientHeight); const newHeight = contentRect.height; - if (this._suppressResizeHandling - && this._isAttached(target) - && isElementVisible(target as HTMLElement) - ) { + if (isInitialRendering) { this._scrollContentToLastMessage(); - - this._suppressResizeHandling = false; } else { const heightChange = this._containerClientHeight - newHeight; + const isHeightDecreasing = heightChange > 0; - let { scrollTop } = target; + let scrollTop = this._scrollable.scrollTop(); - if (heightChange >= 1 || !isReachedBottom(target as HTMLDivElement, target.scrollTop, 0, 1)) { + if (isHeightDecreasing) { scrollTop += heightChange; - } - this._scrollable.scrollTo({ top: scrollTop }); + this._scrollable.scrollTo({ top: scrollTop }); + } } this._containerClientHeight = newHeight; @@ -242,14 +233,13 @@ class MessageList extends Widget { } _scrollContentToLastMessage(): void { - const scrollOffsetTopMax = getScrollTopMax(this._getScrollContainer()); - - this._scrollable.scrollTo({ top: scrollOffsetTopMax }); + this._scrollable.scrollTo({ + top: getScrollTopMax(this._scrollableContainer()), + }); } - _getScrollContainer(): HTMLElement { - // @ts-expect-error - return $(this._scrollable.container()).get(0); + _scrollableContainer(): Element { + return $(this._scrollable.element()).find(`.${SCROLLABLE_CONTAINER_CLASS}`).get(0); } _clean(): void { diff --git a/packages/devextreme/js/renovation/ui/scroll_view/utils/get_scroll_top_max.ts b/packages/devextreme/js/renovation/ui/scroll_view/utils/get_scroll_top_max.ts index 013ff9ce85f7..fb32dc17a25c 100644 --- a/packages/devextreme/js/renovation/ui/scroll_view/utils/get_scroll_top_max.ts +++ b/packages/devextreme/js/renovation/ui/scroll_view/utils/get_scroll_top_max.ts @@ -1,3 +1,3 @@ -export function getScrollTopMax(element: HTMLElement): number { +export function getScrollTopMax(element: HTMLElement | Element): number { return element.scrollHeight - element.clientHeight; } diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets/chatParts/messageList.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets/chatParts/messageList.tests.js index 8ebb7cae9483..1bcc29ba0943 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets/chatParts/messageList.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/chatParts/messageList.tests.js @@ -506,8 +506,8 @@ QUnit.module('MessageList', moduleConfig, () => { setTimeout(() => { const scrollTop = this.scrollable.scrollTop(); - assert.notEqual(scrollTop, 0); - assert.roughEqual(scrollTop, this.getScrollOffsetMax(), 1); + assert.notEqual(scrollTop, 0, 'scroll position should not be 0 after initialization'); + assert.roughEqual(scrollTop, this.getScrollOffsetMax(), 1, 'scroll position should be at the bottom after initialization'); done(); }, this._resizeTimeout); }); @@ -524,17 +524,16 @@ QUnit.module('MessageList', moduleConfig, () => { setTimeout(() => { const scrollTop = this.getScrollable().scrollTop(); - assert.notEqual(scrollTop, 0); - assert.roughEqual(scrollTop, this.getScrollOffsetMax(), 1); + assert.notEqual(scrollTop, 0, 'scroll position should not be 0 after items are updated at runtime'); + assert.roughEqual(scrollTop, this.getScrollOffsetMax(), 1, 'scroll position should be at the bottom after items are updated at runtime'); done(); }); }); [MOCK_CURRENT_USER_ID, MOCK_COMPANION_USER_ID].forEach(id => { const isCurrentUser = id === MOCK_CURRENT_USER_ID; - const textName = `Scrollable should be scrolled to last message after render ${isCurrentUser ? 'current user' : 'companion'} message`; - QUnit.test(textName, function(assert) { + QUnit.test(`Scrollable should be scrolled to last message after render ${isCurrentUser ? 'current user' : 'companion'} message`, function(assert) { const done = assert.async(); assert.expect(2); const items = generateMessages(31); @@ -557,14 +556,14 @@ QUnit.module('MessageList', moduleConfig, () => { setTimeout(() => { const scrollTop = this.getScrollable().scrollTop(); - assert.notEqual(scrollTop, 0); - assert.roughEqual(scrollTop, this.getScrollOffsetMax(), 1); + assert.notEqual(scrollTop, 0, 'scroll position should not be 0 after a new message is rendered'); + assert.roughEqual(scrollTop, this.getScrollOffsetMax(), 1, 'scroll position should be at the bottom after rendering the new message'); done(); }); }); }); - QUnit.test('should be scrolled to the last message after being rendered inside an invisible element and display correctly when shown', function(assert) { + QUnit.test('should be scrolled to the last message after showing if was initially rendered inside an invisible element', function(assert) { const done = assert.async(); $('#qunit-fixture').css('display', 'none'); @@ -581,15 +580,15 @@ QUnit.module('MessageList', moduleConfig, () => { setTimeout(() => { const scrollTop = this.getScrollable().scrollTop(); - assert.strictEqual(scrollTop, 0); + assert.strictEqual(scrollTop, 0, 'scroll position should be 0 when the element is hidden'); $('#qunit-fixture').css('display', 'block'); setTimeout(() => { const scrollTop = this.getScrollable().scrollTop(); - assert.notEqual(scrollTop, 0); - assert.roughEqual(scrollTop, this.getScrollOffsetMax(), 1); + assert.notEqual(scrollTop, 0, 'scroll position should change after the element is made visible'); + assert.roughEqual(scrollTop, this.getScrollOffsetMax(), 1, 'scroll position should be at the bottom after element becomes visible'); done(); }, this._resizeTimeout); @@ -613,20 +612,162 @@ QUnit.module('MessageList', moduleConfig, () => { setTimeout(() => { const scrollTop = this.getScrollable().scrollTop(); - assert.strictEqual(scrollTop, 0); + assert.strictEqual(scrollTop, 0, 'scroll position should be 0 while the element is detached'); $messageList.appendTo('#qunit-fixture'); setTimeout(() => { const scrollTop = this.getScrollable().scrollTop(); - assert.notEqual(scrollTop, 0); - assert.roughEqual(scrollTop, this.getScrollOffsetMax(), 1); + assert.notEqual(scrollTop, 0, 'scroll position should change after the element is attached to the DOM'); + assert.roughEqual(scrollTop, this.getScrollOffsetMax(), 1, 'scroll position should be at the bottom after attachment'); done(); }, this._resizeTimeout); }); }); + + QUnit.test('should be scrolled to the bottom after reducing height if it\'s initially scrolled to the bottom', function(assert) { + const done = assert.async(); + + const items = generateMessages(31); + + this.reinit({ + width: 300, + height: 500, + items, + }); + + setTimeout(() => { + const scrollTop = this.getScrollable().scrollTop(); + + assert.roughEqual(scrollTop, this.getScrollOffsetMax(), 1, 'scroll position should be at the bottom after initialization'); + + this.instance.option('height', 300); + + setTimeout(() => { + const scrollTop = this.getScrollable().scrollTop(); + + assert.roughEqual(scrollTop, this.getScrollOffsetMax(), 1, 'max scroll position should be saved after reducing height'); + + done(); + }, this._resizeTimeout); + }, this._resizeTimeout); + }); + + QUnit.test('should be scrolled to the bottom after increasing height if it\'s initially scrolled to the bottom', function(assert) { + const done = assert.async(); + + const items = generateMessages(31); + + this.reinit({ + width: 300, + height: 500, + items, + }); + + setTimeout(() => { + const scrollTop = this.getScrollable().scrollTop(); + + assert.roughEqual(scrollTop, this.getScrollOffsetMax(), 1, 'scroll position should be at the bottom after initialization'); + + this.instance.option('height', 700); + + setTimeout(() => { + const scrollTop = this.getScrollable().scrollTop(); + + assert.roughEqual(scrollTop, this.getScrollOffsetMax(), 1, 'max scroll position should be saved after increasing height'); + + done(); + }, this._resizeTimeout); + }, this._resizeTimeout); + }); + + QUnit.test('should update visual scroll position after reducing height if it\'s not scrolled to the bottom (fix viewport bottom point)', function(assert) { + const done = assert.async(); + + const items = generateMessages(31); + + this.reinit({ + width: 300, + height: 500, + items, + }); + + setTimeout(() => { + const scrollTop = this.getScrollable().scrollTop(); + + assert.roughEqual(scrollTop, this.getScrollOffsetMax(), 1, 'scroll position should be at the bottom after initialization'); + + this.getScrollable().scrollTo({ top: this.getScrollOffsetMax() - 200 }); + this.instance.option('height', 300); + + setTimeout(() => { + const scrollTop = this.getScrollable().scrollTop(); + + assert.roughEqual(scrollTop, this.getScrollOffsetMax() - 200, 1, 'scroll position should be set correctly after reducing height'); + + done(); + }, this._resizeTimeout); + }, this._resizeTimeout); + }); + + QUnit.test('should keep visual scroll position after increasing height if it\'s not scrolled to the bottom (fix viewport top point)', function(assert) { + const done = assert.async(); + + const items = generateMessages(31); + + this.reinit({ + width: 300, + height: 500, + items, + }); + + setTimeout(() => { + const scrollTop = this.getScrollable().scrollTop(); + + assert.roughEqual(scrollTop, this.getScrollOffsetMax(), 1, 'scroll position should be at the bottom after initialization'); + + const newScrollTop = this.getScrollOffsetMax() - 200; + this.getScrollable().scrollTo({ top: newScrollTop }); + this.instance.option('height', 600); + + setTimeout(() => { + const scrollTop = this.getScrollable().scrollTop(); + + assert.roughEqual(scrollTop, newScrollTop, 1, 'scroll position should be saved correctly after increasing height'); + + done(); + }, this._resizeTimeout); + }, this._resizeTimeout); + }); + + QUnit.test('should limit scroll position after increasing height more than scroll offset allows', function(assert) { + const done = assert.async(); + + const items = generateMessages(31); + + this.reinit({ + width: 300, + height: 500, + items, + }); + + setTimeout(() => { + const newScrollTop = this.getScrollOffsetMax() - 200; + + this.getScrollable().scrollTo({ top: newScrollTop }); + this.instance.option('height', 800); + + setTimeout(() => { + const scrollTop = this.getScrollable().scrollTop(); + + assert.roughEqual(scrollTop, this.getScrollOffsetMax(), 1.01, 'scroll position should be limited to the max scrollable offset after increasing height'); + + done(); + }, this._resizeTimeout); + }, this._resizeTimeout); + }); }); QUnit.module('localization', moduleConfig, () => {