From cb09dc168f5b41a3e09f38134e34c5ae5bc45cbc Mon Sep 17 00:00:00 2001 From: MartinCupela <32706194+MartinCupela@users.noreply.github.com> Date: Fri, 14 Jun 2024 12:17:24 +0200 Subject: [PATCH] feat: allow to configure date and time format over i18n (#2419) --- .../React/guides/date-time-formatting.mdx | 208 ++++++++++++++++ docusaurus/sidebars-react.json | 3 +- .../DateSeparator/DateSeparator.tsx | 18 +- .../__tests__/DateSeparator.test.js | 6 +- .../EventComponent/EventComponent.tsx | 20 +- .../__tests__/EventComponent.test.js | 5 +- src/components/Message/MessageTimestamp.tsx | 14 +- src/components/Message/Timestamp.tsx | 23 +- .../__tests__/MessageTimestamp.test.js | 1 - .../MessageList/VirtualizedMessageList.tsx | 4 + .../VirtualizedMessageListComponents.tsx | 2 + src/i18n/Streami18n.ts | 21 +- src/i18n/__tests__/Streami18n.test.js | 21 ++ src/i18n/__tests__/utils.test.js | 235 +++++++++++++++++- src/i18n/de.json | 3 + src/i18n/en.json | 3 + src/i18n/es.json | 3 + src/i18n/fr.json | 3 + src/i18n/hi.json | 3 + src/i18n/index.ts | 1 + src/i18n/it.json | 3 + src/i18n/ja.json | 3 + src/i18n/ko.json | 3 + src/i18n/nl.json | 3 + src/i18n/pt.json | 3 + src/i18n/ru.json | 3 + src/i18n/tr.json | 3 + src/i18n/utils.ts | 92 ++++++- 28 files changed, 662 insertions(+), 48 deletions(-) create mode 100644 docusaurus/docs/React/guides/date-time-formatting.mdx diff --git a/docusaurus/docs/React/guides/date-time-formatting.mdx b/docusaurus/docs/React/guides/date-time-formatting.mdx new file mode 100644 index 0000000000..f40af2d56e --- /dev/null +++ b/docusaurus/docs/React/guides/date-time-formatting.mdx @@ -0,0 +1,208 @@ +--- +id: date-time-formatting +title: Date and time formatting +keywords: [date, time, datetime, timestamp, format, formatting] +--- + +In this guide we will learn how date and time formatting can be customized within SDK's components. + +## SDK components displaying date & time + +The following components provided by the SDK display datetime: + +- `DateSeparator`- component separating groups of messages in message lists +- `EventComponent` - component that renders system messages (`message.type === 'system'`) +- `Timestamp` - component to display non-system message timestamp + +## Format customization + +The datetime format customization can be done on multiple levels: + +1. Override the default component prop values +2. Supply custom formatting function +3. Format date via i18n + +### Override the component props defaults + +All the mentioned components accept timestamp formatter props: + +```ts +export type TimestampFormatterOptions = { + /* If true, call the `Day.js` calendar function to get the date string to display (e.g. "Yesterday at 3:58 PM"). */ + calendar?: boolean | null; + /* Object specifying date display formats for dates formatted with calendar extension. Active only if calendar prop enabled. */ + calendarFormats?: Record | null; + /* Overrides the default timestamp format if calendar is disabled. */ + format?: string | null; +}; +``` + +If calendar formatting is enabled, the dates are formatted with time-relative words ("yesterday at ...", "last ..."). The calendar strings can be further customized with `calendarFormats` object. The `calendarFormats` object has to cover all the formatting cases as shows the example below: + +``` +{ + lastDay: '[gestern um] LT', + lastWeek: '[letzten] dddd [um] LT', + nextDay: '[morgen um] LT', + nextWeek: 'dddd [um] LT', + sameDay: '[heute um] LT', + sameElse: 'L', +} +``` + +:::important +If any of the `calendarFormats` keys are missing, then the underlying library will fall back to hard-coded english equivalents +::: + +If `calendar` formatting is enabled, the `format` prop would be ignored. So to apply the `format` string, the `calendar` has to be disabled (applies to `DateSeparator` and `MessageTimestamp`. + +All the components can be overridden through `Channel` component context: + +```tsx +import { + Channel, + DateSeparatorProps, + DateSeparator, + EventComponentProps, + EventComponent, + MessageTimestampProps, + MessageTimestamp, +} from 'stream-chat-react'; + +const CustomDateSeparator = (props: DateSeparatorProps) => ( + // calendar is enabled by default +); + +const CustomSystemMessage = (props: EventComponentProps) => ( + // calendar is disabled by default +); + +const CustomMessageTimestamp = (props: MessageTimestampProps) => ( + // calendar is enabled by default +); + + + ... +; +``` + +### Custom formatting function + +Custom formatting function can be passed to `MessageList` or `VirtualizedMessageList` via prop `formatDate` (`(date: Date) => string;`). The `Message` component passes down the function to be consumed by the children via `MessageComponentContext`: + +```jsx +import { useMessageContext } from 'stream-chat-react'; +const CustomComponent = () => { + const { formatDate } = useMessageContext(); +}; +``` + +By default, the function is consumed by the `MessageTimestamp` component. This means the formatting via `formatDate` is reduced only to timestamp shown by a message in the message list. Components `DateSeparator`, `EventComponent` would ignore the custom formatting. + +### Date & time formatting with i18n service + +Until now, the datetime values could be customized within the `Channel` component at best. Formatting via i18n service allows for SDK wide configuration. The configuration is stored with other translations in JSON files. Formatting with i18n service has the following advantages: + +- it is centralized +- it takes into consideration the locale out of the box +- allows for high granularity - formatting per string, not component (opposed to props approach) +- allows for high re-usability - apply the same configuration in multiple places via the same translation key +- allows for custom formatting logic + +#### Change the default configuration + +The default datetime formatting configuration is stored in the JSON translation files. The default translation keys are namespaced with prefix `timestamp/` followed by the component name. For example, the message date formatting can be targeted via `timestamp/MessageTimestamp`, because the underlying component is called `MessageTimestamp`. + +##### Overriding the prop defaults + +The default date and time rendering components in the SDK were created with default prop values that override the configuration parameters provided over JSON translations. Therefore, if we wanted to configure the formatting from JSON translation files, we need to nullify the prop defaults first. An example follows: + +```tsx +import { + DateSeparatorProps, + DateSeparator, + EventComponentProps, + EventComponent, + MessageTimestampProps, + MessageTimestamp, +} from 'stream-chat-react'; + +const CustomDateSeparator = (props: DateSeparatorProps) => ( + // calendarFormats, neither format have default value +); + +const SystemMessage = (props: EventComponentProps) => ( + // calendar neither calendarFormats have default value +); + +const CustomMessageTimestamp = (props: MessageTimestampProps) => ( + // calendarFormats do not have default value +); +``` + +Now we can apply custom configuration in all the translation JSON files. It could look similar to the following key-value pair example. + +```json +{ + "timestamp/SystemMessage": "{{ timestamp | timestampFormatter(format: YYYY) }}" +} +``` + +Besides overriding the formatting parameters above, we can customize the translation key via `timestampTranslationKey` prop in all of the above mentioned components (`DateSeparator`, `EventComponent`, `MessageTimestamp`). + +```tsx +import { MessageTimestampProps, MessageTimestamp } from 'stream-chat-react'; + +const CustomMessageTimestamp = (props: MessageTimestampProps) => ( + +); +``` + +##### Understanding the formatting syntax + +Once the default prop values are nullified, we override the default formatting rules in the JSON translation value. We can take a look at an example of German translation for SystemMessage: + +``` +"timestamp/SystemMessage": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: {\"lastDay\": \"[gestern um] LT\", \"lastWeek\": \"[letzten] dddd [um] LT\", \"nextDay\": \"[morgen um] LT\", \"nextWeek\": \"dddd [um] LT\", \"sameDay\": \"[heute um] LT\", \"sameElse\": \"L\"}) }}", +``` + +Let's dissect the example: + +- The curly brackets (`{{`, `}}`) indicate the place where a value will be interpolated (inserted) into the string. +- variable `timestamp` is the name of variable which value will be inserted into the string +- value separator `|` signals the separation between the interpolated value and the formatting function name +- `timestampFormatter` is the name of the formatting function that is used to convert the `timestamp` value into desired format +- the `timestampFormatter` can be passed the same parameters as the React components (`calendar`, `calendarFormats`, `format`) as if the function was called with these values. The values can be simple scalar values as well as objects (note `calendarFormats` should be an object) + +:::note +The described rules follow the formatting rules required by the i18n library used under the hood - `i18next`. You can learn more about the rules in [the formatting section of the `i18next` documentation](https://www.i18next.com/translation-function/formatting#basic-usage). +::: + +#### Custom datetime formatter functions + +Besides overriding the configuration parameters, we can override the default `timestampFormatter` function by providing custom `Streami18n` instance: + +```tsx +import { Chat, Streami18n, useCreateChatClient } from 'stream-chat-react'; + +const i18n = new Streami18n({ + formatters: { + timestampFormatter: () => (val: string | Date) => { + return new Date(val).getTime() + ''; + }, + }, +}); + +export const ChatApp = ({ apiKey, userId, userToken }) => { + const chatClient = useCreateChatClient({ + apiKey, + tokenOrProvider: userToken, + userData: { id: userId }, + }); + return ; +}; +``` diff --git a/docusaurus/sidebars-react.json b/docusaurus/sidebars-react.json index 99d1787f33..e7e4ba1ecc 100644 --- a/docusaurus/sidebars-react.json +++ b/docusaurus/sidebars-react.json @@ -140,7 +140,8 @@ "guides/typescript_and_generics", "guides/channel_read_state", "guides/video-integration/video-integration-stream", - "guides/sdk-state-management" + "guides/sdk-state-management", + "guides/date-time-formatting" ] }, { "Release Guides": ["release-guides/upgrade-to-v10", "release-guides/upgrade-to-v11"] }, diff --git a/src/components/DateSeparator/DateSeparator.tsx b/src/components/DateSeparator/DateSeparator.tsx index a01d8c09d1..7a7d36d2f6 100644 --- a/src/components/DateSeparator/DateSeparator.tsx +++ b/src/components/DateSeparator/DateSeparator.tsx @@ -1,9 +1,9 @@ import React from 'react'; import { useTranslationContext } from '../../context/TranslationContext'; -import { getDateString } from '../../i18n/utils'; +import { getDateString, TimestampFormatterOptions } from '../../i18n/utils'; -export type DateSeparatorProps = { +export type DateSeparatorProps = TimestampFormatterOptions & { /** The date to format */ date: Date; /** Override the default formatting of the date. This is a function that has access to the original date object. */ @@ -15,15 +15,25 @@ export type DateSeparatorProps = { }; const UnMemoizedDateSeparator = (props: DateSeparatorProps) => { - const { date: messageCreatedAt, formatDate, position = 'right', unread } = props; + const { + calendar = true, + date: messageCreatedAt, + formatDate, + position = 'right', + unread, + ...restTimestampFormatterOptions + } = props; const { t, tDateTimeParser } = useTranslationContext('DateSeparator'); const formattedDate = getDateString({ - calendar: true, + calendar, + ...restTimestampFormatterOptions, formatDate, messageCreatedAt, + t, tDateTimeParser, + timestampTranslationKey: 'timestamp/DateSeparator', }); return ( diff --git a/src/components/DateSeparator/__tests__/DateSeparator.test.js b/src/components/DateSeparator/__tests__/DateSeparator.test.js index cd750d4840..f40271094b 100644 --- a/src/components/DateSeparator/__tests__/DateSeparator.test.js +++ b/src/components/DateSeparator/__tests__/DateSeparator.test.js @@ -2,7 +2,7 @@ import React from 'react'; import renderer from 'react-test-renderer'; import Dayjs from 'dayjs'; import calendar from 'dayjs/plugin/calendar'; -import { cleanup, render } from '@testing-library/react'; +import { cleanup, render, screen } from '@testing-library/react'; import '@testing-library/jest-dom'; import { DateSeparator } from '../DateSeparator'; @@ -35,9 +35,9 @@ describe('DateSeparator', () => { it('should render New text if unread prop is true', () => { const { Component, t } = withContext({ date: now, unread: true }); - const { queryByText } = render(Component); + render(Component); - expect(queryByText('New - 03/30/2020')).toBeInTheDocument(); + expect(screen.getByText('New - 03/30/2020')).toBeInTheDocument(); expect(t).toHaveBeenCalledWith('New'); }); diff --git a/src/components/EventComponent/EventComponent.tsx b/src/components/EventComponent/EventComponent.tsx index e3b3290f2a..9b6b7e891e 100644 --- a/src/components/EventComponent/EventComponent.tsx +++ b/src/components/EventComponent/EventComponent.tsx @@ -7,11 +7,11 @@ import { useTranslationContext } from '../../context/TranslationContext'; import type { StreamMessage } from '../../context/ChannelStateContext'; import type { DefaultStreamChatGenerics } from '../../types/types'; -import { getDateString } from '../../i18n/utils'; +import { getDateString, TimestampFormatterOptions } from '../../i18n/utils'; export type EventComponentProps< StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics -> = { +> = TimestampFormatterOptions & { /** Message object */ message: StreamMessage; /** Custom UI component to display user avatar, defaults to and accepts same props as: [Avatar](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Avatar/Avatar.tsx) */ @@ -26,9 +26,9 @@ const UnMemoizedEventComponent = < >( props: EventComponentProps, ) => { - const { Avatar = DefaultAvatar, message } = props; + const { calendar, calendarFormats, format = 'dddd L', Avatar = DefaultAvatar, message } = props; - const { tDateTimeParser } = useTranslationContext('EventComponent'); + const { t, tDateTimeParser } = useTranslationContext('EventComponent'); const { created_at = '', event, text, type } = message; const getDateOptions = { messageCreatedAt: created_at.toString(), tDateTimeParser }; @@ -41,8 +41,16 @@ const UnMemoizedEventComponent = <
- {getDateString({ ...getDateOptions, format: 'dddd' })} - at {getDateString({ ...getDateOptions, format: 'hh:mm A' })} + + {getDateString({ + ...getDateOptions, + calendar, + calendarFormats, + format, + t, + timestampTranslationKey: 'timestamp/SystemMessage', + })} +
); diff --git a/src/components/EventComponent/__tests__/EventComponent.test.js b/src/components/EventComponent/__tests__/EventComponent.test.js index 5ddf99a0e5..1dcda7b637 100644 --- a/src/components/EventComponent/__tests__/EventComponent.test.js +++ b/src/components/EventComponent/__tests__/EventComponent.test.js @@ -49,11 +49,8 @@ describe('EventComponent', () => { className="str-chat__message--system__date" > - Friday - + Friday 03/13/2020 - at - 10:18 AM `); diff --git a/src/components/Message/MessageTimestamp.tsx b/src/components/Message/MessageTimestamp.tsx index cdc5e2c899..2ae7c0b468 100644 --- a/src/components/Message/MessageTimestamp.tsx +++ b/src/components/Message/MessageTimestamp.tsx @@ -1,23 +1,19 @@ import React from 'react'; - -import type { StreamMessage } from '../../context/ChannelStateContext'; -import type { DefaultStreamChatGenerics } from '../../types/types'; - import { useMessageContext } from '../../context/MessageContext'; import { Timestamp as DefaultTimestamp } from './Timestamp'; import { useComponentContext } from '../../context'; +import type { StreamMessage } from '../../context/ChannelStateContext'; +import type { DefaultStreamChatGenerics } from '../../types/types'; +import type { TimestampFormatterOptions } from '../../i18n/utils'; + export const defaultTimestampFormat = 'h:mmA'; export type MessageTimestampProps< StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics -> = { - /* If true, call the `Day.js` calendar function to get the date string to display. */ - calendar?: boolean; +> = TimestampFormatterOptions & { /* Adds a CSS class name to the component's outer `time` container. */ customClass?: string; - /* Overrides the default timestamp format */ - format?: string; /* The `StreamChat` message object, which provides necessary data to the underlying UI components (overrides the value from `MessageContext`) */ message?: StreamMessage; }; diff --git a/src/components/Message/Timestamp.tsx b/src/components/Message/Timestamp.tsx index 9b5dcff35b..f26284e3aa 100644 --- a/src/components/Message/Timestamp.tsx +++ b/src/components/Message/Timestamp.tsx @@ -2,15 +2,11 @@ import React, { useMemo } from 'react'; import { useMessageContext } from '../../context/MessageContext'; import { isDate, useTranslationContext } from '../../context/TranslationContext'; -import { getDateString } from '../../i18n/utils'; +import { getDateString, TimestampFormatterOptions } from '../../i18n/utils'; -export interface TimestampProps { - /* If true, call the `Day.js` calendar function to get the date string to display. */ - calendar?: boolean; +export interface TimestampProps extends TimestampFormatterOptions { /* Adds a CSS class name to the component's outer `time` container. */ customClass?: string; - /* Overrides the default timestamp format */ - format?: string; /* Timestamp to display */ timestamp?: Date | string; } @@ -18,10 +14,16 @@ export interface TimestampProps { export const defaultTimestampFormat = 'h:mmA'; export function Timestamp(props: TimestampProps) { - const { timestamp, calendar = false, customClass = '', format = defaultTimestampFormat } = props; + const { + calendar, + calendarFormats, + customClass, + format = defaultTimestampFormat, + timestamp, + } = props; const { formatDate } = useMessageContext('MessageTimestamp'); - const { tDateTimeParser } = useTranslationContext('MessageTimestamp'); + const { t, tDateTimeParser } = useTranslationContext('MessageTimestamp'); const normalizedTimestamp = timestamp && isDate(timestamp) ? timestamp.toISOString() : timestamp; @@ -29,12 +31,15 @@ export function Timestamp(props: TimestampProps) { () => getDateString({ calendar, + calendarFormats, format, formatDate, messageCreatedAt: normalizedTimestamp, + t, tDateTimeParser, + timestampTranslationKey: 'timestamp/Timestamp', }), - [formatDate, calendar, tDateTimeParser, format, normalizedTimestamp], + [calendar, calendarFormats, format, formatDate, normalizedTimestamp, t, tDateTimeParser], ); if (!when) { diff --git a/src/components/Message/__tests__/MessageTimestamp.test.js b/src/components/Message/__tests__/MessageTimestamp.test.js index 48eb8b6513..85242349f5 100644 --- a/src/components/Message/__tests__/MessageTimestamp.test.js +++ b/src/components/Message/__tests__/MessageTimestamp.test.js @@ -27,7 +27,6 @@ describe('', () => { .toJSON(); expect(tree).toMatchInlineSnapshot(`