Skip to content

Commit

Permalink
Chat: scrollable integration enhancements (#28121)
Browse files Browse the repository at this point in the history
  • Loading branch information
EugeniyKiyashko authored Sep 30, 2024
1 parent 13f8d0e commit 69e011c
Show file tree
Hide file tree
Showing 3 changed files with 184 additions and 53 deletions.
64 changes: 27 additions & 37 deletions packages/devextreme/js/__internal/ui/chat/messagelist.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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<MessageList> {
Expand All @@ -37,9 +34,7 @@ export interface Properties extends WidgetOptions<MessageList> {
class MessageList extends Widget<Properties> {
private _messageGroups?: MessageGroup[];

private _containerClientHeight = 0;

private _suppressResizeHandling?: boolean;
private _containerClientHeight!: number;

private _scrollable!: Scrollable<unknown>;

Expand All @@ -63,47 +58,43 @@ class MessageList extends Widget<Properties> {
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;
Expand Down Expand Up @@ -242,14 +233,13 @@ class MessageList extends Widget<Properties> {
}

_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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
export function getScrollTopMax(element: HTMLElement): number {
export function getScrollTopMax(element: HTMLElement | Element): number {
return element.scrollHeight - element.clientHeight;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand All @@ -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);
Expand All @@ -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');

Expand All @@ -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);
Expand All @@ -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, () => {
Expand Down

0 comments on commit 69e011c

Please sign in to comment.