Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Chat - implement dayHeaderFormat and messageTimestampFormat options #28234

Merged
merged 13 commits into from
Oct 29, 2024
13 changes: 12 additions & 1 deletion packages/devextreme/js/__internal/ui/chat/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import $ from '@js/core/renderer';
import { isDefined } from '@js/core/utils/type';
import type { Options as DataSourceOptions } from '@js/data/data_source';
import DataHelperMixin from '@js/data_helper';
import type { Format } from '@js/localization';
import messageLocalization from '@js/localization/message';
import type {
Message,
Expand All @@ -29,6 +30,8 @@ const TEXTEDITOR_INPUT_CLASS = 'dx-texteditor-input';
type Properties = ChatProperties & {
title: string;
showDayHeaders: boolean;
dayHeaderFormat: null | Format;
EugeniyKiyashko marked this conversation as resolved.
Show resolved Hide resolved
messageTimestampFormat: null | Format;
EugeniyKiyashko marked this conversation as resolved.
Show resolved Hide resolved
};

class Chat extends Widget<Properties> {
Expand All @@ -54,6 +57,8 @@ class Chat extends Widget<Properties> {
user: { id: new Guid().toString() },
onMessageSend: undefined,
showDayHeaders: true,
dayHeaderFormat: null,
messageTimestampFormat: null,
errors: [],
};
}
Expand Down Expand Up @@ -115,7 +120,9 @@ class Chat extends Widget<Properties> {
}

_renderMessageList(): void {
const { items = [], user, showDayHeaders } = this.option();
const {
items = [], user, showDayHeaders, dayHeaderFormat, messageTimestampFormat,
EugeniyKiyashko marked this conversation as resolved.
Show resolved Hide resolved
} = this.option();

const currentUserId = user?.id;
const $messageList = $('<div>');
Expand All @@ -128,6 +135,8 @@ class Chat extends Widget<Properties> {
showDayHeaders,
// @ts-expect-error
isLoading: this._dataController.isLoading(),
dayHeaderFormat,
messageTimestampFormat,
});
}

Expand Down Expand Up @@ -250,6 +259,8 @@ class Chat extends Widget<Properties> {
this._createMessageSendAction();
break;
case 'showDayHeaders':
case 'dayHeaderFormat':
case 'messageTimestampFormat':
this._messageList.option(name, value);
break;
default:
Expand Down
14 changes: 13 additions & 1 deletion packages/devextreme/js/__internal/ui/chat/messagegroup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import type { dxElementWrapper } from '@js/core/renderer';
import $ from '@js/core/renderer';
import dateSerialization from '@js/core/utils/date_serialization';
import { isDefined } from '@js/core/utils/type';
import type { Format } from '@js/localization';
import dateLocalization from '@js/localization/date';
import messageLocalization from '@js/localization/message';
import type { Message } from '@js/ui/chat';
import type { WidgetOptions } from '@js/ui/widget/ui.widget';
Expand All @@ -24,6 +26,7 @@ export type MessageGroupAlignment = 'start' | 'end';
export interface Properties extends WidgetOptions<MessageGroup> {
items: Message[];
alignment: MessageGroupAlignment;
messageTimestampFormat: null | Format;
}

class MessageGroup extends Widget<Properties> {
Expand All @@ -36,6 +39,7 @@ class MessageGroup extends Widget<Properties> {
...super._getDefaultOptions(),
items: [],
alignment: 'start',
messageTimestampFormat: null,
};
}

Expand Down Expand Up @@ -139,9 +143,16 @@ class MessageGroup extends Widget<Properties> {
_getTimeValue(timestamp: Date | string | number): string {
const options: Intl.DateTimeFormatOptions = { hour: '2-digit', minute: '2-digit', hour12: false };
ksercs marked this conversation as resolved.
Show resolved Hide resolved
const date = dateSerialization.deserializeDate(timestamp);
const { messageTimestampFormat } = this.option();

let formattedTime = date.toLocaleTimeString(undefined, options);

if (messageTimestampFormat) {
EugeniyKiyashko marked this conversation as resolved.
Show resolved Hide resolved
formattedTime = dateLocalization.format(date, messageTimestampFormat);
}

// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return date.toLocaleTimeString(undefined, options);
return formattedTime;
}

_optionChanged(args: OptionChanged<Properties>): void {
Expand All @@ -150,6 +161,7 @@ class MessageGroup extends Widget<Properties> {
switch (name) {
case 'items':
case 'alignment':
case 'messageTimestampFormat':
this._invalidate();
break;
default:
Expand Down
16 changes: 16 additions & 0 deletions packages/devextreme/js/__internal/ui/chat/messagelist.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import dateUtils from '@js/core/utils/date';
import dateSerialization from '@js/core/utils/date_serialization';
import { isElementInDom } from '@js/core/utils/dom';
import { isDate, isDefined } from '@js/core/utils/type';
import type { Format } from '@js/localization';
import dateLocalization from '@js/localization/date';
import messageLocalization from '@js/localization/message';
import { getScrollTopMax } from '@js/renovation/ui/scroll_view/utils/get_scroll_top_max';
import type { Message } from '@js/ui/chat';
Expand Down Expand Up @@ -35,6 +37,8 @@ export interface Properties extends WidgetOptions<MessageList> {
items: Message[];
currentUserId: number | string | undefined;
showDayHeaders: boolean;
dayHeaderFormat: null | Format;
messageTimestampFormat: null | Format;
isLoading?: boolean;
}

Expand All @@ -53,6 +57,8 @@ class MessageList extends Widget<Properties> {
items: [],
currentUserId: '',
showDayHeaders: true,
dayHeaderFormat: null,
messageTimestampFormat: null,
isLoading: false,
};
}
Expand Down Expand Up @@ -162,10 +168,12 @@ class MessageList extends Widget<Properties> {

_createMessageGroupComponent(items: Message[], userId: string | number | undefined): void {
const $messageGroup = $('<div>').appendTo(this._$content());
const { messageTimestampFormat } = this.option();

const messageGroup = this._createComponent($messageGroup, MessageGroup, {
items,
alignment: this._messageGroupAlignment(userId),
messageTimestampFormat,
});

this._messageGroups?.push(messageGroup);
Expand Down Expand Up @@ -212,6 +220,12 @@ class MessageList extends Widget<Properties> {
year: 'numeric',
}).replace(/[/-]/g, '.');

const { dayHeaderFormat } = this.option();

if (dayHeaderFormat) {
headerDate = dateLocalization.format(deserializedDate, dayHeaderFormat);
EugeniyKiyashko marked this conversation as resolved.
Show resolved Hide resolved
}

if (dateUtils.sameDate(deserializedDate, today)) {
headerDate = `${messageLocalization.format('Today')} ${headerDate}`;
}
Expand Down Expand Up @@ -415,6 +429,8 @@ class MessageList extends Widget<Properties> {
this._processItemsUpdating(value ?? [], previousValue ?? []);
break;
case 'showDayHeaders':
case 'dayHeaderFormat':
case 'messageTimestampFormat':
this._invalidate();
break;
case 'isLoading':
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,58 @@ QUnit.module('Chat', () => {

assert.strictEqual(messageList.option('showDayHeaders'), false, 'showDayHeaders is passed on runtime');
});

QUnit.test('Chat should pass dayHeaderFormat to messageList on init', function(assert) {
EugeniyKiyashko marked this conversation as resolved.
Show resolved Hide resolved
const dayHeaderFormat = 'dd of MMMM, yyyy';

this.reinit({
dayHeaderFormat,
});

const messageList = this.getMessageList();

assert.strictEqual(messageList.option('dayHeaderFormat'), dayHeaderFormat, 'dayHeaderFormat is passed on init');
});

QUnit.test('Chat should pass messageTimestampFormat to messageList on init', function(assert) {
const messageTimestampFormat = 'hh hours and mm minutes';

this.reinit({
messageTimestampFormat,
});

const messageList = this.getMessageList();

assert.strictEqual(messageList.option('messageTimestampFormat'), messageTimestampFormat, 'messageTimestampFormat is passed on init');
});

QUnit.test('Chat should pass dayHeaderFormat to messageList at runtime', function(assert) {
const dayHeaderFormat = 'dd of MMMM, yyyy';

this.reinit({
dayHeaderFormat: 'yyyy',
});

this.instance.option('dayHeaderFormat', dayHeaderFormat);

const messageList = this.getMessageList();

assert.strictEqual(messageList.option('dayHeaderFormat'), dayHeaderFormat, 'dayHeaderFormat is updated at runtime');
});

QUnit.test('Chat should pass messageTimestampFormat to messageList at runtime', function(assert) {
const messageTimestampFormat = 'hh hours and mm minutes';

this.reinit({
messageTimestampFormat: 'hh',
});

this.instance.option('messageTimestampFormat', messageTimestampFormat);

const messageList = this.getMessageList();

assert.strictEqual(messageList.option('messageTimestampFormat'), messageTimestampFormat, 'messageTimestampFormat is updated at runtime');
});
});

QUnit.module('ErrorList integration', {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,37 @@ QUnit.module('MessageGroup', moduleConfig, () => {

assert.strictEqual($time.text(), '21:34');
});

QUnit.test('time should have formatted value if messageTimestampFormat is specified on init', function(assert) {
const messageTime = new Date(2021, 9, 17, 4, 20);

this.reinit({
items: [
{ timestamp: messageTime },
],
messageTimestampFormat: 'hh_mm',
});

const $time = this.$element.find(`.${CHAT_MESSAGEGROUP_TIME_CLASS}`);

assert.strictEqual($time.text(), '04_20');
});

QUnit.test('time should have formatted value if messageTimestampFormat is specified at runtime', function(assert) {
const messageTime = new Date(2021, 9, 17, 4, 20);

this.reinit({
items: [
{ timestamp: messageTime },
],
});

this.instance.option('messageTimestampFormat', 'hh...mm');

const $time = this.$element.find(`.${CHAT_MESSAGEGROUP_TIME_CLASS}`);

assert.strictEqual($time.text(), '04...20');
});
});

QUnit.module('Author name', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -357,7 +357,7 @@ QUnit.module('MessageList', moduleConfig, () => {

let $dayHeaders = this.getDayHeaders();

assert.strictEqual($dayHeaders.length, 1, 'day header was aaded');
assert.strictEqual($dayHeaders.length, 1, 'day header was added');

this.instance.option({ items });

Expand All @@ -366,6 +366,62 @@ QUnit.module('MessageList', moduleConfig, () => {
assert.strictEqual($dayHeaders.length, 1, 'day header is not removed after invalidate');
});

[{
timestamp: new Date(),
dayHeaderPrefix: 'Today ',
scenario: 'today',
},
{
timestamp: new Date(Date.now() - MS_IN_DAY),
dayHeaderPrefix: 'Yesterday ',
scenario: 'yesterday',
}, {
timestamp: new Date('10.10.2024'),
dayHeaderPrefix: '',
scenario: '10.10.2024',
}].forEach(({ timestamp, dayHeaderPrefix, scenario }) => {
QUnit.test(`Day header should be formatted when dayHeaderFormat is specified on init (timestamp=${scenario})`, function(assert) {
const items = [{
timestamp,
text: 'A',
}];

this.reinit({
items,
dayHeaderFormat: 'dd of MMMM, yyyy',
});

const $dayHeaders = this.getDayHeaders();
Zedwag marked this conversation as resolved.
Show resolved Hide resolved
const expectedMonth = timestamp.toLocaleString(undefined, { month: 'long' });
const expectedYear = timestamp.getFullYear();
const expectedDate = timestamp.getDate();
const expectedDayHeaderText = `${dayHeaderPrefix}${expectedDate} of ${expectedMonth}, ${expectedYear}`;

assert.strictEqual($dayHeaders.text(), expectedDayHeaderText, 'day header has formatted text');
});

QUnit.test(`Day header should be formatted when dayHeaderFormat is specified at runtime (timestamp=${scenario})`, function(assert) {
const items = [{
timestamp,
text: 'A',
}];

this.reinit({
items,
});

this.instance.option('dayHeaderFormat', 'dd of MMMM, yyyy');

const $dayHeaders = this.getDayHeaders();
const expectedMonth = timestamp.toLocaleString(undefined, { month: 'long' });
const expectedYear = timestamp.getFullYear();
const expectedDate = timestamp.getDate();
const expectedDayHeaderText = `${dayHeaderPrefix}${expectedDate} of ${expectedMonth}, ${expectedYear}`;

assert.strictEqual($dayHeaders.text(), expectedDayHeaderText, 'day header has formatted text');
});
});

QUnit.test('loading indicator should be hidden if isLoading is set to false', function(assert) {
this.reinit({
items: [],
Expand Down Expand Up @@ -636,6 +692,17 @@ QUnit.module('MessageList', moduleConfig, () => {
assert.strictEqual($firstMessageGroupBubbles.length, 2, 'correct bubble count');
assert.strictEqual($secondMessageGroupBubbles.length, 1, 'correct bubble count');
});

QUnit.test('messageTimestampFormat should be passed to message group', function(assert) {
EugeniyKiyashko marked this conversation as resolved.
Show resolved Hide resolved
this.reinit({
items: [{ timestamp: '2024-09-26T14:00:00', text: 'text' }],
messageTimestampFormat: 'hh.mm',
});

const messageGroup = MessageGroup.getInstance(this.$element.find(`.${CHAT_MESSAGEGROUP_CLASS}`));

assert.strictEqual(messageGroup.option('messageTimestampFormat'), 'hh.mm');
});
});

QUnit.module('Items option change', () => {
Expand Down
Loading