Skip to content

Commit

Permalink
Chat: scrollable integration enhancements
Browse files Browse the repository at this point in the history
  • Loading branch information
EugeniyKiyashko committed Sep 30, 2024
1 parent f7a8596 commit 810e323
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,13 +1,9 @@
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 { 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 @@ -26,6 +22,8 @@ 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 interface Properties extends WidgetOptions<MessageList> {
items: Message[];
currentUserId: number | string | undefined;
Expand All @@ -34,9 +32,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 @@ -60,47 +56,42 @@ 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 {
const newHeight = contentRect.height;

if (this._suppressResizeHandling
&& this._isAttached(target)
&& isElementVisible(target as HTMLElement)
) {
this._scrollContentToLastMessage();
if (!isElementInDom($(target)) || !isElementVisible(target as HTMLElement)) {
return;
}

this._suppressResizeHandling = false;
if (!isDefined(this._containerClientHeight)) {
this._scrollContentToLastMessage();
} 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 @@ -231,14 +222,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 @@ -399,8 +399,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 @@ -417,17 +417,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 @@ -450,14 +449,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 @@ -474,15 +473,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 @@ -506,20 +505,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 save last scroll position after reducing height', function(assert) {
const done = assert.async();

const items = generateMessages(31);

this.reinit({
width: 300,
height: 500,
items,
});

setTimeout(() => {
const scrollTop = this.getScrollable().scrollTop();

assert.strictEqual(scrollTop, this.getScrollOffsetMax(), '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 save last scroll position after increasing height', function(assert) {
const done = assert.async();

const items = generateMessages(31);

this.reinit({
width: 300,
height: 500,
items,
});

setTimeout(() => {
const scrollTop = this.getScrollable().scrollTop();

assert.strictEqual(scrollTop, this.getScrollOffsetMax(), '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 save last scroll position after reducing height from middle scrolltop', function(assert) {
const done = assert.async();

const items = generateMessages(31);

this.reinit({
width: 300,
height: 500,
items,
});

setTimeout(() => {
const scrollTop = this.getScrollable().scrollTop();

assert.strictEqual(scrollTop, this.getScrollOffsetMax(), '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 save its scroll position after increasing height', function(assert) {
const done = assert.async();

const items = generateMessages(31);

this.reinit({
width: 300,
height: 500,
items,
});

setTimeout(() => {
const scrollTop = this.getScrollable().scrollTop();

assert.strictEqual(scrollTop, this.getScrollOffsetMax(), '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, '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 810e323

Please sign in to comment.