From 0ad70167956d0392fd8d9d8af07a8c9bc7664790 Mon Sep 17 00:00:00 2001 From: Khushal Agarwal Date: Mon, 17 Jun 2024 14:31:58 +0530 Subject: [PATCH] feat: global date time formatting through i18n (#2552) * feat: global date time formatting through i18n * fix: build and lint issues * fix: StickyHeader code * fix: broken translations for date time * fix: broken translations for date time * fix: build issues * fix: remove timestampTranslationKey prop * tests: fix broken tests * tests: fix broken tests * fix: memoize the getDatestring logic * fix: memoize the getDatestring logic * fix: memoize the getDatestring logic * fix: translations test * fix: add tests * fix: use i18n-parser for build-translations and improve related scripts * fix: use i18n-parser for build-translations and improve related scripts * fix: prettier config * debug commit * debug commit * Delete package/src/i18n/pt-BR.json * debug commit * debug commit * set keepRemoved true * fix: build config * fix: build config --- .../guides/date-time-formatting.mdx | 129 +++++++++++++++++ .../message-edited-timestamp.mdx | 32 +---- .../ui-components/message-footer.mdx | 10 ++ docusaurus/sidebars-react-native.json | 1 + package/package.json | 3 +- .../Attachment/__tests__/Giphy.test.js | 2 +- package/src/components/Channel/Channel.tsx | 7 +- .../Channel/hooks/useCreateMessagesContext.ts | 2 + .../ChannelPreview/ChannelPreviewStatus.tsx | 18 ++- .../hooks/useLatestMessagePreview.ts | 35 +---- package/src/components/Chat/Chat.tsx | 2 +- .../components/Chat/__tests__/Chat.test.js | 2 +- .../components/ImageGalleryHeader.tsx | 16 ++- .../MessageSimple/MessageEditedTimestamp.tsx | 15 +- .../Message/MessageSimple/MessageFooter.tsx | 17 ++- .../MessageSimple/MessageTimestamp.tsx | 49 +++---- .../__tests__/MessageStatus.test.js | 2 +- .../src/components/MessageList/DateHeader.tsx | 2 +- .../MessageList/InlineDateSeparator.tsx | 35 +++-- .../components/MessageList/MessageList.tsx | 35 +---- .../components/MessageList/MessageSystem.tsx | 28 ++-- .../components/MessageList/StickyHeader.tsx | 42 ++++++ .../__tests__/MessageSystem.test.js | 11 +- .../__tests__/ScrollToBottomButton.test.js | 2 +- .../__tests__/TypingIndicator.test.js | 2 +- .../Thread/__tests__/Thread.test.js | 2 +- .../__snapshots__/Thread.test.js.snap | 2 +- package/src/components/index.ts | 1 + .../channelContext/ChannelContext.tsx | 3 +- .../messagesContext/MessagesContext.tsx | 6 + .../overlayContext/OverlayContext.tsx | 2 +- package/src/hooks/useStreami18n.ts | 2 +- package/src/i18n/en.json | 7 + package/src/i18n/es.json | 7 + package/src/i18n/fr.json | 7 + package/src/i18n/he.json | 7 + package/src/i18n/hi.json | 7 + package/src/i18n/it.json | 7 + package/src/i18n/ja.json | 7 + package/src/i18n/ko.json | 7 + package/src/i18n/nl.json | 7 + package/src/i18n/pt-br.json | 7 + package/src/i18n/ru.json | 7 + package/src/i18n/tr.json | 7 + package/src/index.ts | 5 +- .../src/utils/__tests__/Streami18n.test.js | 24 +++- package/src/utils/getDateString.ts | 67 --------- package/src/utils/{ => i18n}/Streami18n.ts | 131 ++++++++---------- package/src/utils/i18n/calendarFormats.ts | 110 +++++++++++++++ package/src/utils/i18n/getDateString.ts | 76 ++++++++++ .../src/utils/i18n/predefinedFormatters.ts | 63 +++++++++ package/yarn.lock | 39 +++--- 52 files changed, 787 insertions(+), 329 deletions(-) create mode 100644 docusaurus/docs/reactnative/guides/date-time-formatting.mdx create mode 100644 package/src/components/MessageList/StickyHeader.tsx delete mode 100644 package/src/utils/getDateString.ts rename package/src/utils/{ => i18n}/Streami18n.ts (90%) create mode 100644 package/src/utils/i18n/calendarFormats.ts create mode 100644 package/src/utils/i18n/getDateString.ts create mode 100644 package/src/utils/i18n/predefinedFormatters.ts diff --git a/docusaurus/docs/reactnative/guides/date-time-formatting.mdx b/docusaurus/docs/reactnative/guides/date-time-formatting.mdx new file mode 100644 index 0000000000..073c1161f8 --- /dev/null +++ b/docusaurus/docs/reactnative/guides/date-time-formatting.mdx @@ -0,0 +1,129 @@ +--- +id: date-time-formatting +title: Date and time formatting +--- + +In this guide we will learn how date a time formatting can be customized within SDK's components. + +## SDK components displaying date & time + +The following components provided by the SDK display datetime: + +- `ChannelPreviewStatus` - Component showing last message date and time in `ChannelList`. +- `ImageGalleryHeader` - Component showing the header in the `ImageGallery`. +- `InlineDateSeparator` - Component separating groups of messages in `MessageList`. +- `MessageEditedTimestamp` - Component showing edited message time when clicked on an edited message. +- `MessageSystem` - Component showing system message. +- `MessageTimestamp` - Component showing message timestamp. +- `StickyHeader` - Component showing sticky header on the top of the `MessageList`/`Channel`. + +## Format Customization + +The datetime format can be customized by providing date format through the `i18n` JSON. + +### Date & time formatting with i18n service + +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. + +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`. + +We can apply custom configuration in all the translation JSON files. It could look similar to the following example key-value pair. + +```json +"timestamp/SystemMessage": "{{ timestamp | timestampFormatter(format: YYYY) }}", +``` + +Besides overriding the formatting parameters above, we can customize the translation key via `timestampTranslationKey` prop. All the above components (`ChannelPreviewStatus`, `ImageGalleryHeader`, `InlineDateSeparator`, `MessageEditedTimestamp`, `MessageSystem`, `MessageTimestamp`, `StickyHeader`) accept this prop. + +```tsx +import { MessageTimestampProps, MessageTimestamp } from 'stream-chat-react-native'; + +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: + +```json +"timestamp/MessageSystem": "{{ timestamp | timestampFormatter(calendar: true) }}", +``` + +or with custom calendar formats: + +```json +"timestamp/MessageSystem": "{{ 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\"}) }}", +``` + +or with custom format: + +```json +"timestamp/MessageTimestamp": "{{ timestamp | timestampFormatter(format: LT) }}", +``` + +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. +- The `|` character is a pipe that separates the variable from the formatting function. +- The `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. + +**Params**: + +- `calendar` - This is a boolean field to decide if the date format should be in calendar format or not. The default value is `false`. +- `calendarFormats` - This is an object that contains the formats for the calendar. The default value is `{ sameDay: 'LT', nextDay: 'LT', nextWeek: 'dddd', lastDay: 'dddd', lastWeek: 'dddd', sameElse: 'L' }`. +- `format` - This is a string that contains the format of the date. + +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: + +```js +{ + 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. + +:::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 } from 'stream-chat-react-native'; + +const chatClient = 'Your Chat client here'; + +const i18n = new Streami18n({ + formatters: { + timestampFormatter: () => (val: string | Date) => { + return new Date(val).getTime() + ''; + }, + }, +}); + +export const ChatApp = ({ apiKey, userId, userToken }) => { + return ; +}; +``` diff --git a/docusaurus/docs/reactnative/ui-components/message-edited-timestamp.mdx b/docusaurus/docs/reactnative/ui-components/message-edited-timestamp.mdx index 904b7185c7..c09292a6f7 100644 --- a/docusaurus/docs/reactnative/ui-components/message-edited-timestamp.mdx +++ b/docusaurus/docs/reactnative/ui-components/message-edited-timestamp.mdx @@ -11,38 +11,16 @@ This is the default component provided to the prop [`MessageEditedTimestamp`](.. ## Props -###
_overrides the value from [MessageContext](../../contexts/message-context#message)_
`message` {#message} +###
_overrides the value from [MessageContext](../../contexts/message-context#message)_
`message` `{#message}` -### `calendar` +## UI components -Whether to show the time in Calendar time format. Calendar time displays time relative to a today's date. +### `MessageTimestamp` -| Type | Default | -| ---------------------- | ----------- | -| `Boolean`\|`undefined` | `undefined` | - -### `format` - -Format of the date. - -| Type | Default | -| --------------------- | ----------- | -| `String`\|`undefined` | `undefined` | - -### `formatDate` - -Function to format the date. - -| Type | Default | -| ----------------------- | ----------- | -| `Function`\|`undefined` | `undefined` | - -### `timestamp` - -The date to be shown after formatting. +The Component that renders the message timestamp. | Type | Default | | ----------------------------- | ----------- | -| `String`\|`Date`\|`undefined` | `undefined` | +| `ComponentType` \|`undefined` | `undefined` | diff --git a/docusaurus/docs/reactnative/ui-components/message-footer.mdx b/docusaurus/docs/reactnative/ui-components/message-footer.mdx index d43a276d27..45ada17548 100644 --- a/docusaurus/docs/reactnative/ui-components/message-footer.mdx +++ b/docusaurus/docs/reactnative/ui-components/message-footer.mdx @@ -77,3 +77,13 @@ Weather message is deleted or not. In case of deleted message, `'Only visible to ###
_overrides the value from [MessageContext](../../contexts/message-context#showmessagestatus)_
`showMessageStatus` {#showmessagestatus} + +## UI Components + +### `MessageTimestamp` + +The Component that renders the message timestamp. + +| Type | Default | +| ----------------------------- | ----------- | +| `ComponentType` \|`undefined` | `undefined` | diff --git a/docusaurus/sidebars-react-native.json b/docusaurus/sidebars-react-native.json index b4879ebd97..fc4f57a716 100644 --- a/docusaurus/sidebars-react-native.json +++ b/docusaurus/sidebars-react-native.json @@ -129,6 +129,7 @@ ], "Advanced Guides": [ "guides/audio-messages-support", + "guides/date-time-formatting", "customization/typescript", "basics/troubleshooting", "basics/stream_chat_with_navigation", diff --git a/package/package.json b/package/package.json index f2ed02acb4..4194a051a8 100644 --- a/package/package.json +++ b/package/package.json @@ -70,7 +70,8 @@ "@gorhom/bottom-sheet": "4.4.8", "dayjs": "1.10.5", "emoji-regex": "^10.3.0", - "i18next": "20.2.4", + "i18next": "^21.6.14", + "intl-pluralrules": "^2.0.1", "linkifyjs": "^4.1.1", "lodash-es": "4.17.21", "mime-types": "^2.1.34", diff --git a/package/src/components/Attachment/__tests__/Giphy.test.js b/package/src/components/Attachment/__tests__/Giphy.test.js index 1b7681af4f..df2060d89f 100644 --- a/package/src/components/Attachment/__tests__/Giphy.test.js +++ b/package/src/components/Attachment/__tests__/Giphy.test.js @@ -23,7 +23,7 @@ import { generateMember } from '../../../mock-builders/generator/member'; import { generateMessage } from '../../../mock-builders/generator/message'; import { generateUser } from '../../../mock-builders/generator/user'; import { getTestClientWithUser } from '../../../mock-builders/mock'; -import { Streami18n } from '../../../utils/Streami18n'; +import { Streami18n } from '../../../utils/i18n/Streami18n'; import { ImageLoadingFailedIndicator } from '../../Attachment/ImageLoadingFailedIndicator'; import { ImageLoadingIndicator } from '../../Attachment/ImageLoadingIndicator'; import { Channel } from '../../Channel/Channel'; diff --git a/package/src/components/Channel/Channel.tsx b/package/src/components/Channel/Channel.tsx index 25a38793ea..b42bfe0c50 100644 --- a/package/src/components/Channel/Channel.tsx +++ b/package/src/components/Channel/Channel.tsx @@ -127,6 +127,7 @@ import { MessageReplies as MessageRepliesDefault } from '../Message/MessageSimpl import { MessageRepliesAvatars as MessageRepliesAvatarsDefault } from '../Message/MessageSimple/MessageRepliesAvatars'; import { MessageSimple as MessageSimpleDefault } from '../Message/MessageSimple/MessageSimple'; import { MessageStatus as MessageStatusDefault } from '../Message/MessageSimple/MessageStatus'; +import { MessageTimestamp as MessageTimestampDefault } from '../Message/MessageSimple/MessageTimestamp'; import { ReactionList as ReactionListDefault } from '../Message/MessageSimple/ReactionList'; import { AttachButton as AttachButtonDefault } from '../MessageInput/AttachButton'; import { CommandsButton as CommandsButtonDefault } from '../MessageInput/CommandsButton'; @@ -156,6 +157,7 @@ import { MessageList as MessageListDefault } from '../MessageList/MessageList'; import { MessageSystem as MessageSystemDefault } from '../MessageList/MessageSystem'; import { NetworkDownIndicator as NetworkDownIndicatorDefault } from '../MessageList/NetworkDownIndicator'; import { ScrollToBottomButton as ScrollToBottomButtonDefault } from '../MessageList/ScrollToBottomButton'; +import { StickyHeader as StickyHeaderDefault } from '../MessageList/StickyHeader'; import { TypingIndicator as TypingIndicatorDefault } from '../MessageList/TypingIndicator'; import { TypingIndicatorContainer as TypingIndicatorContainerDefault } from '../MessageList/TypingIndicatorContainer'; import { OverlayReactionList as OverlayReactionListDefault } from '../MessageOverlay/OverlayReactionList'; @@ -305,6 +307,7 @@ export type ChannelPropsWithContext< | 'MessageStatus' | 'MessageSystem' | 'MessageText' + | 'MessageTimestamp' | 'myMessageTheme' | 'onLongPressMessage' | 'onPressInMessage' @@ -542,6 +545,7 @@ const ChannelWithContext = < MessageStatus = MessageStatusDefault, MessageSystem = MessageSystemDefault, MessageText, + MessageTimestamp = MessageTimestampDefault, MoreOptionsButton = MoreOptionsButtonDefault, myMessageTheme, NetworkDownIndicator = NetworkDownIndicatorDefault, @@ -573,7 +577,7 @@ const ChannelWithContext = < ShowThreadMessageInChannelButton = ShowThreadMessageInChannelButtonDefault, StartAudioRecordingButton = AudioRecordingButtonDefault, stateUpdateThrottleInterval = defaultThrottleInterval, - StickyHeader, + StickyHeader = StickyHeaderDefault, supportedReactions = reactionData, t, thread: threadProps, @@ -2328,6 +2332,7 @@ const ChannelWithContext = < MessageStatus, MessageSystem, MessageText, + MessageTimestamp, myMessageTheme, onLongPressMessage, onPressInMessage, diff --git a/package/src/components/Channel/hooks/useCreateMessagesContext.ts b/package/src/components/Channel/hooks/useCreateMessagesContext.ts index 87c33be1e1..caeab52edc 100644 --- a/package/src/components/Channel/hooks/useCreateMessagesContext.ts +++ b/package/src/components/Channel/hooks/useCreateMessagesContext.ts @@ -69,6 +69,7 @@ export const useCreateMessagesContext = < MessageStatus, MessageSystem, MessageText, + MessageTimestamp, myMessageTheme, onLongPressMessage, onPressInMessage, @@ -165,6 +166,7 @@ export const useCreateMessagesContext = < MessageStatus, MessageSystem, MessageText, + MessageTimestamp, myMessageTheme, onLongPressMessage, onPressInMessage, diff --git a/package/src/components/ChannelPreview/ChannelPreviewStatus.tsx b/package/src/components/ChannelPreview/ChannelPreviewStatus.tsx index ec0a4142ed..21b904ae2c 100644 --- a/package/src/components/ChannelPreview/ChannelPreviewStatus.tsx +++ b/package/src/components/ChannelPreview/ChannelPreviewStatus.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useMemo } from 'react'; import { StyleSheet, Text, View } from 'react-native'; import { ChannelPreviewProps } from './ChannelPreview'; @@ -6,9 +6,11 @@ import type { ChannelPreviewMessengerPropsWithContext } from './ChannelPreviewMe import { MessageReadStatus } from './hooks/useLatestMessagePreview'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; +import { useTranslationContext } from '../../contexts/translationContext/TranslationContext'; import { Check, CheckAll } from '../../icons'; import type { DefaultStreamChatGenerics } from '../../types/types'; +import { getDateString } from '../../utils/i18n/getDateString'; const styles = StyleSheet.create({ date: { @@ -35,6 +37,7 @@ export const ChannelPreviewStatus = < props: ChannelPreviewStatusProps, ) => { const { formatLatestMessageDate, latestMessagePreview } = props; + const { t, tDateTimeParser } = useTranslationContext(); const { theme: { channelPreview: { checkAllIcon, checkIcon, date }, @@ -44,6 +47,17 @@ export const ChannelPreviewStatus = < const created_at = latestMessagePreview.messageObject?.created_at; const latestMessageDate = created_at ? new Date(created_at) : new Date(); + + const formattedDate = useMemo( + () => + getDateString({ + date: created_at, + t, + tDateTimeParser, + timestampTranslationKey: 'timestamp/ChannelPreviewStatus', + }), + [created_at, t, tDateTimeParser], + ); const status = latestMessagePreview.status; return ( @@ -56,7 +70,7 @@ export const ChannelPreviewStatus = < {formatLatestMessageDate && latestMessageDate ? formatLatestMessageDate(latestMessageDate).toString() - : latestMessagePreview.created_at.toString()} + : formattedDate} ); diff --git a/package/src/components/ChannelPreview/hooks/useLatestMessagePreview.ts b/package/src/components/ChannelPreview/hooks/useLatestMessagePreview.ts index 0238cf9f5f..29c69a5622 100644 --- a/package/src/components/ChannelPreview/hooks/useLatestMessagePreview.ts +++ b/package/src/components/ChannelPreview/hooks/useLatestMessagePreview.ts @@ -1,13 +1,10 @@ import { useEffect, useState } from 'react'; +import { TFunction } from 'i18next'; import type { Channel, ChannelState, MessageResponse, StreamChat, UserResponse } from 'stream-chat'; import { useChatContext } from '../../../contexts/chatContext/ChatContext'; -import { - isDayOrMoment, - TDateTimeParser, - useTranslationContext, -} from '../../../contexts/translationContext/TranslationContext'; +import { useTranslationContext } from '../../../contexts/translationContext/TranslationContext'; import { useTranslatedMessage } from '../../../hooks/useTranslatedMessage'; import type { DefaultStreamChatGenerics } from '../../../types/types'; @@ -21,13 +18,13 @@ type LatestMessage< export type LatestMessagePreview< StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics, > = { - created_at: string | number | Date; messageObject: LatestMessage | undefined; previews: { bold: boolean; text: string; }[]; status: number; + created_at?: string | Date; }; const getMessageSenderName = < @@ -131,22 +128,6 @@ const getLatestMessageDisplayText = < ]; }; -const getLatestMessageDisplayDate = < - StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics, ->( - message: LatestMessage | undefined, - tDateTimeParser: TDateTimeParser, -) => { - const parserOutput = tDateTimeParser(message?.created_at); - if (isDayOrMoment(parserOutput)) { - if (parserOutput.isSame(new Date(), 'day')) { - return parserOutput.format('LT'); - } - return parserOutput.format('L'); - } - return parserOutput; -}; - export enum MessageReadStatus { NOT_SENT_BY_CURRENT_USER = 0, UNREAD = 1, @@ -190,13 +171,12 @@ const getLatestMessagePreview = < channel: Channel; client: StreamChat; readEvents: boolean; - t: (key: string) => string; - tDateTimeParser: TDateTimeParser; + t: TFunction; lastMessage?: | ReturnType['formatMessage']> | MessageResponse; }) => { - const { channel, client, lastMessage, readEvents, t, tDateTimeParser } = params; + const { channel, client, lastMessage, readEvents, t } = params; const messages = channel.state.messages; @@ -219,7 +199,7 @@ const getLatestMessagePreview = < const message = lastMessage !== undefined ? lastMessage : channelStateLastMessage; return { - created_at: getLatestMessageDisplayDate(message, tDateTimeParser), + created_at: message?.created_at, messageObject: message, previews: getLatestMessageDisplayText(channel, client, message, t), status: getLatestMessageReadStatus(channel, client, message, readEvents), @@ -240,7 +220,7 @@ export const useLatestMessagePreview = < forceUpdate: number, ) => { const { client } = useChatContext(); - const { t, tDateTimeParser } = useTranslationContext(); + const { t } = useTranslationContext(); const channelConfigExists = typeof channel?.getConfig === 'function'; @@ -286,7 +266,6 @@ export const useLatestMessagePreview = < lastMessage: translatedLastMessage, readEvents, t, - tDateTimeParser, }), ), [channelLastMessageString, forceUpdate, readEvents, readStatus], diff --git a/package/src/components/Chat/Chat.tsx b/package/src/components/Chat/Chat.tsx index 8f35376dee..017bce93f0 100644 --- a/package/src/components/Chat/Chat.tsx +++ b/package/src/components/Chat/Chat.tsx @@ -27,8 +27,8 @@ import { SDK } from '../../native'; import { QuickSqliteClient } from '../../store/QuickSqliteClient'; import type { DefaultStreamChatGenerics } from '../../types/types'; import { DBSyncManager } from '../../utils/DBSyncManager'; +import type { Streami18n } from '../../utils/i18n/Streami18n'; import { StreamChatRN } from '../../utils/StreamChatRN'; -import type { Streami18n } from '../../utils/Streami18n'; import { version } from '../../version.json'; init(); diff --git a/package/src/components/Chat/__tests__/Chat.test.js b/package/src/components/Chat/__tests__/Chat.test.js index 2df56e57f4..07b7850f75 100644 --- a/package/src/components/Chat/__tests__/Chat.test.js +++ b/package/src/components/Chat/__tests__/Chat.test.js @@ -10,7 +10,7 @@ import { useTranslationContext } from '../../../contexts/translationContext/Tran import dispatchConnectionChangedEvent from '../../../mock-builders/event/connectionChanged'; import dispatchConnectionRecoveredEvent from '../../../mock-builders/event/connectionRecovered'; import { getTestClient } from '../../../mock-builders/mock'; -import { Streami18n } from '../../../utils/Streami18n'; +import { Streami18n } from '../../../utils/i18n/Streami18n'; import { Chat } from '../Chat'; const ChatContextConsumer = ({ fn }) => { diff --git a/package/src/components/ImageGallery/components/ImageGalleryHeader.tsx b/package/src/components/ImageGallery/components/ImageGalleryHeader.tsx index 834eb6ba61..f83b1eacb8 100644 --- a/package/src/components/ImageGallery/components/ImageGalleryHeader.tsx +++ b/package/src/components/ImageGallery/components/ImageGalleryHeader.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useMemo, useState } from 'react'; import { Pressable, SafeAreaView, StyleSheet, Text, View, ViewStyle } from 'react-native'; import Animated, { Extrapolate, interpolate, useAnimatedStyle } from 'react-native-reanimated'; @@ -8,7 +8,7 @@ import { useTranslationContext } from '../../../contexts/translationContext/Tran import { Close } from '../../../icons'; import type { DefaultStreamChatGenerics } from '../../../types/types'; -import { getDateString } from '../../../utils/getDateString'; +import { getDateString } from '../../../utils/i18n/getDateString'; import type { Photo } from '../ImageGallery'; const ReanimatedSafeAreaView = Animated.createAnimatedComponent @@ -70,6 +70,7 @@ type Props; visible: Animated.SharedValue; photo?: Photo; + /* Lookup key in the language corresponding translations sheet to perform date formatting */ }; export const ImageGalleryHeader = < @@ -98,7 +99,16 @@ export const ImageGalleryHeader = < const { t, tDateTimeParser } = useTranslationContext(); const { setOverlay } = useOverlayContext(); - const date = getDateString({ calendar: true, date: photo?.created_at, tDateTimeParser }); + const date = useMemo( + () => + getDateString({ + date: photo?.created_at, + t, + tDateTimeParser, + timestampTranslationKey: 'timestamp/ImageGalleryHeader', + }), + [photo?.created_at, t, tDateTimeParser], + ); const headerStyle = useAnimatedStyle(() => ({ opacity: opacity.value, diff --git a/package/src/components/Message/MessageSimple/MessageEditedTimestamp.tsx b/package/src/components/Message/MessageSimple/MessageEditedTimestamp.tsx index 7db27bf123..2ce2963a96 100644 --- a/package/src/components/Message/MessageSimple/MessageEditedTimestamp.tsx +++ b/package/src/components/Message/MessageSimple/MessageEditedTimestamp.tsx @@ -1,12 +1,11 @@ import React from 'react'; import { StyleSheet, Text, View } from 'react-native'; -import { MessageTimestamp, MessageTimestampProps } from './MessageTimestamp'; - import { MessageContextValue, useMessageContext, } from '../../../contexts/messageContext/MessageContext'; +import { MessagesContextValue } from '../../../contexts/messagesContext/MessagesContext'; import { useTheme } from '../../../contexts/themeContext/ThemeContext'; import { useTranslationContext } from '../../../contexts/translationContext/TranslationContext'; import { DefaultStreamChatGenerics } from '../../../types/types'; @@ -14,14 +13,15 @@ import { isEditedMessage } from '../../../utils/utils'; export type MessageEditedTimestampProps< StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics, -> = Partial, 'message'>> & MessageTimestampProps; +> = Partial, 'message'>> & + Partial, 'MessageTimestamp'>>; export const MessageEditedTimestamp = < StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics, >( props: MessageEditedTimestampProps, ) => { - const { message: propMessage, timestamp } = props; + const { message: propMessage, MessageTimestamp } = props; const { theme: { colors: { grey }, @@ -41,7 +41,12 @@ export const MessageEditedTimestamp = < return ( {t('Edited') + ' '} - + {MessageTimestamp && ( + + )} ); }; diff --git a/package/src/components/Message/MessageSimple/MessageFooter.tsx b/package/src/components/Message/MessageSimple/MessageFooter.tsx index 3f048e5a7b..b16e2dcf6f 100644 --- a/package/src/components/Message/MessageSimple/MessageFooter.tsx +++ b/package/src/components/Message/MessageSimple/MessageFooter.tsx @@ -5,8 +5,6 @@ import type { Attachment } from 'stream-chat'; import type { MessageStatusProps } from './MessageStatus'; -import { MessageTimestamp } from './MessageTimestamp'; - import type { ChannelContextValue } from '../../../contexts/channelContext/ChannelContext'; import { Alignment, @@ -46,7 +44,10 @@ type MessageFooterPropsWithContext< > & Pick< MessagesContextValue, - 'deletedMessagesVisibilityType' | 'MessageEditedTimestamp' | 'MessageStatus' + | 'deletedMessagesVisibilityType' + | 'MessageEditedTimestamp' + | 'MessageStatus' + | 'MessageTimestamp' > & MessageFooterComponentProps; @@ -98,6 +99,7 @@ const MessageFooterWithContext = < message, MessageEditedTimestamp, MessageStatus, + MessageTimestamp, otherAttachments, showMessageStatus, } = props; @@ -118,7 +120,7 @@ const MessageFooterWithContext = < {deletedMessagesVisibilityType === 'sender' && ( )} - + ); } @@ -137,7 +139,7 @@ const MessageFooterWithContext = < {message.user.name} ) : null} {showMessageStatus && } - + {isEditedMessage(message) && !isEditedMessageOpen && ( <> @@ -160,7 +162,7 @@ const MessageFooterWithContext = < )} {isEditedMessageOpen && ( - + )} ); @@ -270,7 +272,7 @@ export const MessageFooter = < showMessageStatus, } = useMessageContext(); - const { deletedMessagesVisibilityType, MessageEditedTimestamp, MessageStatus } = + const { deletedMessagesVisibilityType, MessageEditedTimestamp, MessageStatus, MessageTimestamp } = useMessagesContext(); return ( @@ -284,6 +286,7 @@ export const MessageFooter = < message, MessageEditedTimestamp, MessageStatus, + MessageTimestamp, otherAttachments, showMessageStatus, }} diff --git a/package/src/components/Message/MessageSimple/MessageTimestamp.tsx b/package/src/components/Message/MessageSimple/MessageTimestamp.tsx index f1ccc2afd4..79958a18bd 100644 --- a/package/src/components/Message/MessageSimple/MessageTimestamp.tsx +++ b/package/src/components/Message/MessageSimple/MessageTimestamp.tsx @@ -1,27 +1,14 @@ -import React from 'react'; +import React, { useMemo } from 'react'; import { StyleSheet, Text } from 'react-native'; import { useTheme } from '../../../contexts/themeContext/ThemeContext'; import { - TDateTimeParserInput, TranslationContextValue, useTranslationContext, } from '../../../contexts/translationContext/TranslationContext'; -import { getDateString } from '../../../utils/getDateString'; +import { getDateString } from '../../../utils/i18n/getDateString'; export type MessageTimestampProps = Partial> & { - /** - * Whether to show the time in Calendar time format. Calendar time displays time relative to a today's date. - */ - calendar?: boolean; - /** - * The format in which the date should be displayed. - */ - format?: string; - /** - * A function to format the date. - */ - formatDate?: (date: TDateTimeParserInput) => string; /** * Already Formatted date */ @@ -30,17 +17,21 @@ export type MessageTimestampProps = Partial { const { - calendar, - format, - formatDate, formattedDate, tDateTimeParser: propsTDateTimeParser, timestamp, + timestampTranslationKey = 'timestamp/MessageTimestamp', } = props; + const { t, tDateTimeParser: contextTDateTimeParser } = useTranslationContext(); + const tDateTimeParser = propsTDateTimeParser || contextTDateTimeParser; const { theme: { @@ -50,7 +41,17 @@ export const MessageTimestamp = (props: MessageTimestampProps) => { }, }, } = useTheme(); - const { tDateTimeParser: contextTDateTimeParser } = useTranslationContext(); + + const dateString = useMemo( + () => + getDateString({ + date: timestamp, + t, + tDateTimeParser, + timestampTranslationKey, + }), + [timestamp, t, tDateTimeParser, timestampTranslationKey], + ); if (formattedDate) { return ( @@ -58,16 +59,6 @@ export const MessageTimestamp = (props: MessageTimestampProps) => { ); } - if (!timestamp) return null; - - const dateString = getDateString({ - calendar, - date: timestamp, - format, - formatDate, - tDateTimeParser: propsTDateTimeParser || contextTDateTimeParser, - }); - if (!dateString) return null; return {dateString.toString()}; diff --git a/package/src/components/Message/MessageSimple/__tests__/MessageStatus.test.js b/package/src/components/Message/MessageSimple/__tests__/MessageStatus.test.js index 650d9de55e..9049de8d4f 100644 --- a/package/src/components/Message/MessageSimple/__tests__/MessageStatus.test.js +++ b/package/src/components/Message/MessageSimple/__tests__/MessageStatus.test.js @@ -5,7 +5,7 @@ import { cleanup, render, waitFor } from '@testing-library/react-native'; import { generateMessage } from '../../../../mock-builders/generator/message'; import { generateStaticUser, generateUser } from '../../../../mock-builders/generator/user'; import { getTestClientWithUser } from '../../../../mock-builders/mock'; -import { Streami18n } from '../../../../utils/Streami18n'; +import { Streami18n } from '../../../../utils/i18n/Streami18n'; import { Chat } from '../../../Chat/Chat'; import { MessageStatus } from '../MessageStatus'; diff --git a/package/src/components/MessageList/DateHeader.tsx b/package/src/components/MessageList/DateHeader.tsx index 52c8025765..3530f97ca7 100644 --- a/package/src/components/MessageList/DateHeader.tsx +++ b/package/src/components/MessageList/DateHeader.tsx @@ -21,7 +21,7 @@ const styles = StyleSheet.create({ }); export type DateHeaderProps = { - dateString: string | number; + dateString?: string | number; }; export const DateHeader = ({ dateString }: DateHeaderProps) => { diff --git a/package/src/components/MessageList/InlineDateSeparator.tsx b/package/src/components/MessageList/InlineDateSeparator.tsx index 19444cfcca..d1f1f63e34 100644 --- a/package/src/components/MessageList/InlineDateSeparator.tsx +++ b/package/src/components/MessageList/InlineDateSeparator.tsx @@ -1,9 +1,9 @@ -import React from 'react'; +import React, { useMemo } from 'react'; import { StyleSheet, Text, View } from 'react-native'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; import { useTranslationContext } from '../../contexts/translationContext/TranslationContext'; -import { getDateString } from '../../utils/getDateString'; +import { getDateString } from '../../utils/i18n/getDateString'; const styles = StyleSheet.create({ container: { @@ -22,7 +22,13 @@ const styles = StyleSheet.create({ }, }); +/** + * Props for the `InlineDateSeparator` component. + */ export type InlineDateSeparatorProps = { + /** + * Date to be displayed. + */ date?: Date; }; @@ -33,19 +39,18 @@ export const InlineDateSeparator = ({ date }: InlineDateSeparatorProps) => { inlineDateSeparator: { container, text }, }, } = useTheme(); - const { tDateTimeParser } = useTranslationContext(); - - if (!date) { - return null; - } - - const dateFormat = date.getFullYear() === new Date().getFullYear() ? 'MMM D' : 'MMM D, YYYY'; - - const dateString = getDateString({ - date, - format: dateFormat, - tDateTimeParser, - }); + const { t, tDateTimeParser } = useTranslationContext(); + + const dateString = useMemo( + () => + getDateString({ + date, + t, + tDateTimeParser, + timestampTranslationKey: 'timestamp/InlineDateSeparator', + }), + [date, t, tDateTimeParser], + ); return ( & - Pick, 'loadMoreThread' | 'thread'> & - Pick & { + Pick, 'loadMoreThread' | 'thread'> & { /** * Besides existing (default) UX behavior of underlying FlatList of MessageList component, if you want * to attach some additional props to underlying FlatList, you can add it to following prop. @@ -278,7 +272,6 @@ const MessageListWithContext = < setTargetedMessage, StickyHeader, targetedMessage, - tDateTimeParser, thread, threadList = false, TypingIndicator, @@ -1025,18 +1018,6 @@ const MessageListWithContext = < threadList, ]); - const stickyHeaderDateFormat = - stickyHeaderDate?.getFullYear() === new Date().getFullYear() ? 'MMM D' : 'MMM D, YYYY'; - - const stickyHeaderDateString = useMemo(() => { - if (!stickyHeaderDate) return null; - return getDateString({ - date: stickyHeaderDate, - format: stickyHeaderDateFormat, - tDateTimeParser, - }); - }, [stickyHeaderDate, stickyHeaderDateFormat]); - const dismissImagePicker = () => { if (!hasMoved && selectedPicker) { setSelectedPicker(undefined); @@ -1092,13 +1073,6 @@ const MessageListWithContext = < [shouldApplyAndroidWorkaround, HeaderComponent], ); - const StickyHeaderComponent = () => { - if (!stickyHeaderDateString) return null; - if (StickyHeader) return ; - if (messageListLengthAfterUpdate) return ; - return null; - }; - // We need to omit the style related props from the additionalFlatListProps and add them directly instead of spreading let additionalFlatListPropsExcludingStyle: | Omit, 'style' | 'contentContainerStyle'> @@ -1183,7 +1157,9 @@ const MessageListWithContext = < {!loading && ( <> - + {messageListLengthAfterUpdate && StickyHeader && ( + + )} {!disableTypingIndicator && TypingIndicator && ( @@ -1253,7 +1229,6 @@ export const MessageList = < usePaginatedMessageListContext(); const { overlay } = useOverlayContext(); const { loadMoreThread, thread } = useThreadContext(); - const { t, tDateTimeParser } = useTranslationContext(); return ( = { /** Current [message object](https://getstream.io/chat/docs/#message_format) */ message: MessageType; + /** + * Additional styles for the system message container. + */ style?: StyleProp; + /* + * Lookup key in the language corresponding translations sheet to perform date formatting + */ }; /** @@ -37,14 +43,20 @@ export const MessageSystem = < }, }, } = useTheme(); - const { tDateTimeParser } = useTranslationContext(); + const { t, tDateTimeParser } = useTranslationContext(); const createdAt = message.created_at; - const formattedDate = getDateString({ - calendar: true, - date: createdAt, - tDateTimeParser, - }); + + const formattedDate = useMemo( + () => + getDateString({ + date: createdAt, + t, + tDateTimeParser, + timestampTranslationKey: 'timestamp/MessageSystem', + }), + [createdAt, t, tDateTimeParser], + ); return ( diff --git a/package/src/components/MessageList/StickyHeader.tsx b/package/src/components/MessageList/StickyHeader.tsx new file mode 100644 index 0000000000..472c6b709c --- /dev/null +++ b/package/src/components/MessageList/StickyHeader.tsx @@ -0,0 +1,42 @@ +import React, { useMemo } from 'react'; + +import { MessagesContextValue } from '../../contexts/messagesContext/MessagesContext'; +import { useTranslationContext } from '../../contexts/translationContext/TranslationContext'; + +import { DefaultStreamChatGenerics } from '../../types/types'; +import { getDateString } from '../../utils/i18n/getDateString'; + +/** + * Props for the StickyHeader component. + */ +export type StickyHeaderProps< + StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics, +> = Pick, 'DateHeader'> & { + /** + * Date to be displayed in the sticky header. + */ + date?: Date; + /** + * The formatted date string to be displayed in the sticky header. + */ + dateString?: string | number; +}; + +export const StickyHeader = ({ date, DateHeader, dateString }: StickyHeaderProps) => { + const { t, tDateTimeParser } = useTranslationContext(); + + const stickyHeaderDateString = useMemo(() => { + if (dateString) return dateString; + + return getDateString({ + date, + t, + tDateTimeParser, + timestampTranslationKey: 'timestamp/StickyHeader', + }); + }, [date]); + + if (!date) return null; + + return ; +}; diff --git a/package/src/components/MessageList/__tests__/MessageSystem.test.js b/package/src/components/MessageList/__tests__/MessageSystem.test.js index 1367142d28..d20d48f2d6 100644 --- a/package/src/components/MessageList/__tests__/MessageSystem.test.js +++ b/package/src/components/MessageList/__tests__/MessageSystem.test.js @@ -8,14 +8,20 @@ import { TranslationProvider } from '../../../contexts/translationContext/Transl import { generateMessage, generateStaticMessage } from '../../../mock-builders/generator/message'; import { generateStaticUser } from '../../../mock-builders/generator/user'; -import { Streami18n } from '../../../utils/Streami18n'; +import { Streami18n } from '../../../utils/i18n/Streami18n'; import { MessageSystem } from '../MessageSystem'; afterEach(cleanup); +let i18nInstance; + describe('MessageSystem', () => { + beforeAll(() => { + i18nInstance = new Streami18n(); + }); + afterEach(cleanup); + it('should render the message system', async () => { - const i18nInstance = new Streami18n(); const translators = await i18nInstance.getTranslators(); const message = generateMessage(); const { queryByTestId } = render( @@ -32,7 +38,6 @@ describe('MessageSystem', () => { }); it('should match the snapshot for message system', async () => { - const i18nInstance = new Streami18n(); const translators = await i18nInstance.getTranslators(); const user = generateStaticUser(0); const message = generateStaticMessage('Hello World', { user }); diff --git a/package/src/components/MessageList/__tests__/ScrollToBottomButton.test.js b/package/src/components/MessageList/__tests__/ScrollToBottomButton.test.js index 0a2d9fd9da..ad84ff3755 100644 --- a/package/src/components/MessageList/__tests__/ScrollToBottomButton.test.js +++ b/package/src/components/MessageList/__tests__/ScrollToBottomButton.test.js @@ -4,7 +4,7 @@ import { cleanup, fireEvent, render, waitFor } from '@testing-library/react-nati import { ThemeProvider } from '../../../contexts/themeContext/ThemeContext'; import { TranslationProvider } from '../../../contexts/translationContext/TranslationContext'; -import { Streami18n } from '../../../utils/Streami18n'; +import { Streami18n } from '../../../utils/i18n/Streami18n'; import { ScrollToBottomButton } from '../ScrollToBottomButton'; afterEach(cleanup); diff --git a/package/src/components/MessageList/__tests__/TypingIndicator.test.js b/package/src/components/MessageList/__tests__/TypingIndicator.test.js index 3072c9d063..3afe6fbb94 100644 --- a/package/src/components/MessageList/__tests__/TypingIndicator.test.js +++ b/package/src/components/MessageList/__tests__/TypingIndicator.test.js @@ -7,7 +7,7 @@ import { TypingProvider } from '../../../contexts/typingContext/TypingContext'; import { generateStaticUser, generateUser } from '../../../mock-builders/generator/user'; import { getTestClientWithUser } from '../../../mock-builders/mock'; -import { Streami18n } from '../../../utils/Streami18n'; +import { Streami18n } from '../../../utils/i18n/Streami18n'; import { Chat } from '../../Chat/Chat'; import { TypingIndicator } from '../TypingIndicator'; diff --git a/package/src/components/Thread/__tests__/Thread.test.js b/package/src/components/Thread/__tests__/Thread.test.js index 1fef3a5519..edb8fedefe 100644 --- a/package/src/components/Thread/__tests__/Thread.test.js +++ b/package/src/components/Thread/__tests__/Thread.test.js @@ -15,7 +15,7 @@ import { generateMember } from '../../../mock-builders/generator/member'; import { generateMessage, generateStaticMessage } from '../../../mock-builders/generator/message'; import { generateStaticUser } from '../../../mock-builders/generator/user'; import { getTestClientWithUser } from '../../../mock-builders/mock'; -import { Streami18n } from '../../../utils/Streami18n'; +import { Streami18n } from '../../../utils/i18n/Streami18n'; import { Channel } from '../../Channel/Channel'; import { Chat } from '../../Chat/Chat'; import { Thread } from '../Thread'; diff --git a/package/src/components/Thread/__tests__/__snapshots__/Thread.test.js.snap b/package/src/components/Thread/__tests__/__snapshots__/Thread.test.js.snap index d32eade312..e5dff8cd6b 100644 --- a/package/src/components/Thread/__tests__/__snapshots__/Thread.test.js.snap +++ b/package/src/components/Thread/__tests__/__snapshots__/Thread.test.js.snap @@ -1197,7 +1197,7 @@ exports[`Thread should match thread snapshot 1`] = ` ] } > - May 5, 2020 + 05/05/2020 diff --git a/package/src/components/index.ts b/package/src/components/index.ts index eb3e342f27..3a43e98a15 100644 --- a/package/src/components/index.ts +++ b/package/src/components/index.ts @@ -108,6 +108,7 @@ export * from './Message/MessageSimple/MessageRepliesAvatars'; export * from './Message/MessageSimple/MessageSimple'; export * from './Message/MessageSimple/MessageStatus'; export * from './Message/MessageSimple/MessageTextContainer'; +export * from './Message/MessageSimple/MessageTimestamp'; export * from './Message/MessageSimple/ReactionList'; export * from './Message/MessageSimple/utils/renderText'; export * from './Message/utils/messageActions'; diff --git a/package/src/contexts/channelContext/ChannelContext.tsx b/package/src/contexts/channelContext/ChannelContext.tsx index e317e3cc6d..6e276058aa 100644 --- a/package/src/contexts/channelContext/ChannelContext.tsx +++ b/package/src/contexts/channelContext/ChannelContext.tsx @@ -4,6 +4,7 @@ import type { Channel, ChannelState } from 'stream-chat'; import type { EmptyStateProps } from '../../components/Indicators/EmptyStateIndicator'; import type { LoadingProps } from '../../components/Indicators/LoadingIndicator'; +import { StickyHeaderProps } from '../../components/MessageList/StickyHeader'; import type { DefaultStreamChatGenerics, UnknownType } from '../../types/types'; import { DEFAULT_BASE_CONTEXT_VALUE } from '../utils/defaultBaseContextValue'; @@ -180,7 +181,7 @@ export type ChannelContextValue< * * **Default** [DateHeader](https://github.com/GetStream/stream-chat-react-native/blob/main/package/src/components/MessageList/DateHeader.tsx) */ - StickyHeader?: React.ComponentType<{ dateString: string | number }>; + StickyHeader?: React.ComponentType; /** * Id of message, around which Channel/MessageList gets loaded when opened. * You will see a highlighted background for targetted message, when opened. diff --git a/package/src/contexts/messagesContext/MessagesContext.tsx b/package/src/contexts/messagesContext/MessagesContext.tsx index 3f5dae13b9..ed62a3c8e4 100644 --- a/package/src/contexts/messagesContext/MessagesContext.tsx +++ b/package/src/contexts/messagesContext/MessagesContext.tsx @@ -33,6 +33,7 @@ import type { MessageRepliesAvatarsProps } from '../../components/Message/Messag import type { MessageSimpleProps } from '../../components/Message/MessageSimple/MessageSimple'; import type { MessageStatusProps } from '../../components/Message/MessageSimple/MessageStatus'; import type { MessageTextProps } from '../../components/Message/MessageSimple/MessageTextContainer'; +import { MessageTimestampProps } from '../../components/Message/MessageSimple/MessageTimestamp'; import type { ReactionListProps } from '../../components/Message/MessageSimple/ReactionList'; import type { MarkdownRules } from '../../components/Message/MessageSimple/utils/renderText'; import type { MessageActionsParams } from '../../components/Message/utils/messageActions'; @@ -217,6 +218,11 @@ export type MessagesContextValue< * Defaults to: [MessageSystem](https://getstream.io/chat/docs/sdk/reactnative/ui-components/message-system/) */ MessageSystem: React.ComponentType>; + /** + * UI component for MessageTimestamp + * Defaults to: [MessageTimestamp](https://github.com/GetStream/stream-chat-react-native/blob/develop/package/src/components/Message/MessageSimple/MessageTimestamp.tsx) + */ + MessageTimestamp: React.ComponentType; /** * UI component for OverlayReactionList */ diff --git a/package/src/contexts/overlayContext/OverlayContext.tsx b/package/src/contexts/overlayContext/OverlayContext.tsx index 7dfbd2d77b..56004f8281 100644 --- a/package/src/contexts/overlayContext/OverlayContext.tsx +++ b/package/src/contexts/overlayContext/OverlayContext.tsx @@ -8,7 +8,7 @@ import type { ImageGalleryCustomComponents } from '../../components/ImageGallery import type { MessageType } from '../../components/MessageList/hooks/useMessageList'; import type { DefaultStreamChatGenerics } from '../../types/types'; -import type { Streami18n } from '../../utils/Streami18n'; +import type { Streami18n } from '../../utils/i18n/Streami18n'; import type { AttachmentPickerContextValue } from '../attachmentPickerContext/AttachmentPickerContext'; import type { MessageOverlayContextValue } from '../messageOverlayContext/MessageOverlayContext'; import type { DeepPartial } from '../themeContext/ThemeContext'; diff --git a/package/src/hooks/useStreami18n.ts b/package/src/hooks/useStreami18n.ts index d123f930aa..46c0b1f809 100644 --- a/package/src/hooks/useStreami18n.ts +++ b/package/src/hooks/useStreami18n.ts @@ -5,7 +5,7 @@ import Dayjs from 'dayjs'; import { useIsMountedRef } from './useIsMountedRef'; import type { TranslatorFunctions } from '../contexts/translationContext/TranslationContext'; -import { Streami18n } from '../utils/Streami18n'; +import { Streami18n } from '../utils/i18n/Streami18n'; export const useStreami18n = (i18nInstance?: Streami18n) => { const [translators, setTranslators] = useState({ diff --git a/package/src/i18n/en.json b/package/src/i18n/en.json index 9f19f81a99..22e318a08c 100644 --- a/package/src/i18n/en.json +++ b/package/src/i18n/en.json @@ -74,6 +74,13 @@ "Video": "Video", "You": "You", "You can't send messages in this channel": "You can't send messages in this channel", + "timestamp/ChannelPreviewStatus": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: {\"lastDay\":\"[Yesterday]\", \"lastWeek\":\"dddd\", \"nextDay\":\"[Tomorrow]\", \"nextWeek\":\"dddd [at] LT\", \"sameDay\":\"LT\", \"sameElse\":\"L\"}) }}", + "timestamp/ImageGalleryHeader": "{{ timestamp | timestampFormatter(calendar: true) }}", + "timestamp/InlineDateSeparator": "{{ timestamp | timestampFormatter(calendar: true) }}", + "timestamp/MessageEditedTimestamp": "{{ timestamp | timestampFormatter(calendar: true) }}", + "timestamp/MessageSystem": "{{ timestamp | timestampFormatter(calendar: true) }}", + "timestamp/MessageTimestamp": "{{ timestamp | timestampFormatter(format: LT) }}", + "timestamp/StickyHeader": "{{ timestamp | timestampFormatter(calendar: true) }}", "{{ firstUser }} and {{ nonSelfUserLength }} more are typing": "{{ firstUser }} and {{ nonSelfUserLength }} more are typing", "{{ index }} of {{ photoLength }}": "{{ index }} of {{ photoLength }}", "{{ replyCount }} Replies": "{{ replyCount }} Replies", diff --git a/package/src/i18n/es.json b/package/src/i18n/es.json index b5d7916bf8..95fd991062 100644 --- a/package/src/i18n/es.json +++ b/package/src/i18n/es.json @@ -74,6 +74,13 @@ "Video": "Video", "You": "Tú", "You can't send messages in this channel": "No puedes enviar mensajes en este canal", + "timestamp/ChannelPreviewStatus": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: {\"lastDay\":\"[Ayer]\", \"lastWeek\":\"dddd\", \"nextDay\":\"[Mañana]\", \"nextWeek\":\"dddd [a las] LT\", \"sameDay\":\"LT\", \"sameElse\":\"L\"}) }}", + "timestamp/ImageGalleryHeader": "{{ timestamp | timestampFormatter(calendar: true) }}", + "timestamp/InlineDateSeparator": "{{ timestamp | timestampFormatter(calendar: true) }}", + "timestamp/MessageEditedTimestamp": "{{ timestamp | timestampFormatter(calendar: true) }}", + "timestamp/MessageSystem": "{{ timestamp | timestampFormatter(calendar: true) }}", + "timestamp/MessageTimestamp": "{{ timestamp | timestampFormatter(format: LT) }}", + "timestamp/StickyHeader": "{{ timestamp | timestampFormatter(calendar: true) }}", "{{ firstUser }} and {{ nonSelfUserLength }} more are typing": "{{ firstUser }} y {{ nonSelfUserLength }} más están escribiendo", "{{ index }} of {{ photoLength }}": "{{ index }} de {{ photoLength }}", "{{ replyCount }} Replies": "{{ replyCount }} Respuestas", diff --git a/package/src/i18n/fr.json b/package/src/i18n/fr.json index bdabdb5eaa..5a336e162a 100644 --- a/package/src/i18n/fr.json +++ b/package/src/i18n/fr.json @@ -74,6 +74,13 @@ "Video": "Vidéo", "You": "Toi", "You can't send messages in this channel": "You can't send messages in this channel", + "timestamp/ChannelPreviewStatus": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: {\"lastDay\":\"[Hier]\", \"lastWeek\":\"dddd\", \"nextDay\":\"[Demain]\", \"nextWeek\":\"dddd [à] LT\", \"sameDay\":\"LT\", \"sameElse\":\"L\"}) }}", + "timestamp/ImageGalleryHeader": "{{ timestamp | timestampFormatter(calendar: true) }}", + "timestamp/InlineDateSeparator": "{{ timestamp | timestampFormatter(calendar: true) }}", + "timestamp/MessageEditedTimestamp": "{{ timestamp | timestampFormatter(calendar: true) }}", + "timestamp/MessageSystem": "{{ timestamp | timestampFormatter(calendar: true) }}", + "timestamp/MessageTimestamp": "{{ timestamp | timestampFormatter(format: LT) }}", + "timestamp/StickyHeader": "{{ timestamp | timestampFormatter(calendar: true) }}", "{{ firstUser }} and {{ nonSelfUserLength }} more are typing": "{{ firstUser }} et {{ nonSelfUserLength }} autres sont en train d'écrire", "{{ index }} of {{ photoLength }}": "{{ index }} sur {{ photoLength }}", "{{ replyCount }} Replies": "{{ replyCount }} Réponses", diff --git a/package/src/i18n/he.json b/package/src/i18n/he.json index 9dd83b7df4..d18cddad33 100644 --- a/package/src/i18n/he.json +++ b/package/src/i18n/he.json @@ -74,6 +74,13 @@ "Video": "וִידֵאוֹ", "You": "את/ה", "You can't send messages in this channel": "את/ב לא יכול/ה לשלוח הודעות בשיחה זו", + "timestamp/ChannelPreviewStatus": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: {\"lastDay\":\"[אתמול]\",\"lastWeek\":\"dddd\",\"nextDay\":\"[מחר]\",\"nextWeek\":\"dddd [בשעה] LT\",\"sameDay\":\"LT\",\"sameElse\":\"L\"}) }}", + "timestamp/ImageGalleryHeader": "{{ timestamp | timestampFormatter(calendar: true) }}", + "timestamp/InlineDateSeparator": "{{ timestamp | timestampFormatter(calendar: true) }}", + "timestamp/MessageEditedTimestamp": "{{ timestamp | timestampFormatter(calendar: true) }}", + "timestamp/MessageSystem": "{{ timestamp | timestampFormatter(calendar: true) }}", + "timestamp/MessageTimestamp": "{{ timestamp | timestampFormatter(format: LT) }}", + "timestamp/StickyHeader": "{{ timestamp | timestampFormatter(calendar: true) }}", "{{ firstUser }} and {{ nonSelfUserLength }} more are typing": "{{ firstUser }} ו-{{ nonSelfUserLength }} משתמש/ים אחר/ים מקלידים", "{{ index }} of {{ photoLength }}": "{{ index }} מתוך {{ photoLength }}", "{{ replyCount }} Replies": "{{ replyCount }} תגובות", diff --git a/package/src/i18n/hi.json b/package/src/i18n/hi.json index ec0cabc01e..b007f5fe59 100644 --- a/package/src/i18n/hi.json +++ b/package/src/i18n/hi.json @@ -74,6 +74,13 @@ "Video": "वीडियो", "You": "आप", "You can't send messages in this channel": "आप इस चैनल में संदेश नहीं भेज सकते", + "timestamp/ChannelPreviewStatus": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: {\"lastDay\":\"[कल]\",\"lastWeek\":\"dddd\",\"nextDay\":\"[कल]\",\"nextWeek\":\"dddd [को] LT\",\"sameDay\":\"LT\",\"sameElse\":\"L\"}) }}", + "timestamp/ImageGalleryHeader": "{{ timestamp | timestampFormatter(calendar: true) }}", + "timestamp/InlineDateSeparator": "{{ timestamp | timestampFormatter(calendar: true) }}", + "timestamp/MessageEditedTimestamp": "{{ timestamp | timestampFormatter(calendar: true) }}", + "timestamp/MessageSystem": "{{ timestamp | timestampFormatter(calendar: true) }}", + "timestamp/MessageTimestamp": "{{ timestamp | timestampFormatter(format: LT) }}", + "timestamp/StickyHeader": "{{ timestamp | timestampFormatter(calendar: true) }}", "{{ firstUser }} and {{ nonSelfUserLength }} more are typing": "{{ firstUser }} और {{ nonSelfUserLength }} अधिक टाइप कर रहे हैं", "{{ index }} of {{ photoLength }}": "{{ index }} / {{ photoLength }}", "{{ replyCount }} Replies": "{{ replyCount }} रिप्लाई", diff --git a/package/src/i18n/it.json b/package/src/i18n/it.json index f6c45803a1..ef20722fd2 100644 --- a/package/src/i18n/it.json +++ b/package/src/i18n/it.json @@ -74,6 +74,13 @@ "Video": "Video", "You": "Tu", "You can't send messages in this channel": "Non puoi inviare messaggi in questo canale", + "timestamp/ChannelPreviewStatus": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: {\"lastDay\":\"[Ieri]\",\"lastWeek\":\"dddd\",\"nextDay\":\"[Domani]\",\"nextWeek\":\"dddd [alle] LT\",\"sameDay\":\"LT\",\"sameElse\":\"L\"}) }}", + "timestamp/ImageGalleryHeader": "{{ timestamp | timestampFormatter(calendar: true) }}", + "timestamp/InlineDateSeparator": "{{ timestamp | timestampFormatter(calendar: true) }}", + "timestamp/MessageEditedTimestamp": "{{ timestamp | timestampFormatter(calendar: true) }}", + "timestamp/MessageSystem": "{{ timestamp | timestampFormatter(calendar: true) }}", + "timestamp/MessageTimestamp": "{{ timestamp | timestampFormatter(format: LT) }}", + "timestamp/StickyHeader": "{{ timestamp | timestampFormatter(calendar: true) }}", "{{ firstUser }} and {{ nonSelfUserLength }} more are typing": "{{ firstUser }} e altri {{ nonSelfUserLength }} stanno scrivendo", "{{ index }} of {{ photoLength }}": "{{ index }} di {{ photoLength }}", "{{ replyCount }} Replies": "{{ replyCount }} Risposte", diff --git a/package/src/i18n/ja.json b/package/src/i18n/ja.json index 6ab5bb75f3..a94708e730 100644 --- a/package/src/i18n/ja.json +++ b/package/src/i18n/ja.json @@ -74,6 +74,13 @@ "Video": "ビデオ", "You": "あなた", "You can't send messages in this channel": "このチャンネルではメッセージを送信できません", + "timestamp/ChannelPreviewStatus": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: {\"lastDay\":\"[昨日]\",\"lastWeek\":\"dddd\",\"nextDay\":\"[明日]\",\"nextWeek\":\"dddd [の] LT\",\"sameDay\":\"LT\",\"sameElse\":\"L\"}) }}", + "timestamp/ImageGalleryHeader": "{{ timestamp | timestampFormatter(calendar: true) }}", + "timestamp/InlineDateSeparator": "{{ timestamp | timestampFormatter(calendar: true) }}", + "timestamp/MessageEditedTimestamp": "{{ timestamp | timestampFormatter(calendar: true) }}", + "timestamp/MessageSystem": "{{ timestamp | timestampFormatter(calendar: true) }}", + "timestamp/MessageTimestamp": "{{ timestamp | timestampFormatter(format: LT) }}", + "timestamp/StickyHeader": "{{ timestamp | timestampFormatter(calendar: true) }}", "{{ firstUser }} and {{ nonSelfUserLength }} more are typing": "{{ firstUser }}と{{ nonSelfUserLength }}人がタイピングしています", "{{ index }} of {{ photoLength }}": "{{ index }} / {{ photoLength }}", "{{ replyCount }} Replies": "{{ replyCount }}件の返信", diff --git a/package/src/i18n/ko.json b/package/src/i18n/ko.json index 3c0f53d7f2..ad9f371912 100644 --- a/package/src/i18n/ko.json +++ b/package/src/i18n/ko.json @@ -74,6 +74,13 @@ "Video": "동영상", "You": "당신", "You can't send messages in this channel": "이 채널에서는 메세지를 전송할 수 없습니다", + "timestamp/ChannelPreviewStatus": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: {\"lastDay\":\"[어제]\",\"lastWeek\":\"dddd\",\"nextDay\":\"[내일]\",\"nextWeek\":\"dddd [LT에]\",\"sameDay\":\"LT\",\"sameElse\":\"L\"}) }}", + "timestamp/ImageGalleryHeader": "{{ timestamp | timestampFormatter(calendar: true) }}", + "timestamp/InlineDateSeparator": "{{ timestamp | timestampFormatter(calendar: true) }}", + "timestamp/MessageEditedTimestamp": "{{ timestamp | timestampFormatter(calendar: true) }}", + "timestamp/MessageSystem": "{{ timestamp | timestampFormatter(calendar: true) }}", + "timestamp/MessageTimestamp": "{{ timestamp | timestampFormatter(format: LT) }}", + "timestamp/StickyHeader": "{{ timestamp | timestampFormatter(calendar: true) }}", "{{ firstUser }} and {{ nonSelfUserLength }} more are typing": "{{ firstUser }} 외 {{ nonSelfUserLength }}명이 입력 중입니다", "{{ index }} of {{ photoLength }}": "{{ index }} / {{ photoLength }}", "{{ replyCount }} Replies": "{{ replyCount }} 답글", diff --git a/package/src/i18n/nl.json b/package/src/i18n/nl.json index a72a3240ff..acd87a08be 100644 --- a/package/src/i18n/nl.json +++ b/package/src/i18n/nl.json @@ -74,6 +74,13 @@ "Video": "Video", "You": "U", "You can't send messages in this channel": "Je kan geen berichten sturen in dit kanaal", + "timestamp/ChannelPreviewStatus": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: {\"lastDay\":\"[Gisteren]\",\"lastWeek\":\"dddd\",\"nextDay\":\"[Morgen]\",\"nextWeek\":\"dddd [om] LT\",\"sameDay\":\"LT\",\"sameElse\":\"L\"}) }}", + "timestamp/ImageGalleryHeader": "{{ timestamp | timestampFormatter(calendar: true) }}", + "timestamp/InlineDateSeparator": "{{ timestamp | timestampFormatter(calendar: true) }}", + "timestamp/MessageEditedTimestamp": "{{ timestamp | timestampFormatter(calendar: true) }}", + "timestamp/MessageSystem": "{{ timestamp | timestampFormatter(calendar: true) }}", + "timestamp/MessageTimestamp": "{{ timestamp | timestampFormatter(format: LT) }}", + "timestamp/StickyHeader": "{{ timestamp | timestampFormatter(calendar: true) }}", "{{ firstUser }} and {{ nonSelfUserLength }} more are typing": "{{ firstUser }} en {{ nonSelfUserLength }} anderen zijn aan het typen", "{{ index }} of {{ photoLength }}": "{{ index }} van {{ photoLength }}", "{{ replyCount }} Replies": "{{ replyCount }} Antwoorden", diff --git a/package/src/i18n/pt-br.json b/package/src/i18n/pt-br.json index 4c643afc46..bc544145f0 100644 --- a/package/src/i18n/pt-br.json +++ b/package/src/i18n/pt-br.json @@ -74,6 +74,13 @@ "Video": "Vídeo", "You": "Você", "You can't send messages in this channel": "Você não pode enviar mensagens neste canal", + "timestamp/ChannelPreviewStatus": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: {\"lastDay\":\"[Ontem]\",\"lastWeek\":\"dddd\",\"nextDay\":\"[Amanhã]\",\"nextWeek\":\"dddd [às] LT\",\"sameDay\":\"LT\",\"sameElse\":\"L\"}) }}", + "timestamp/ImageGalleryHeader": "{{ timestamp | timestampFormatter(calendar: true) }}", + "timestamp/InlineDateSeparator": "{{ timestamp | timestampFormatter(calendar: true) }}", + "timestamp/MessageEditedTimestamp": "{{ timestamp | timestampFormatter(calendar: true) }}", + "timestamp/MessageSystem": "{{ timestamp | timestampFormatter(calendar: true) }}", + "timestamp/MessageTimestamp": "{{ timestamp | timestampFormatter(format: LT) }}", + "timestamp/StickyHeader": "{{ timestamp | timestampFormatter(calendar: true) }}", "{{ firstUser }} and {{ nonSelfUserLength }} more are typing": "{{ firstUser }} e mais {{ nonSelfUserLength }} pessoa(s) estão digitando", "{{ index }} of {{ photoLength }}": "{{ index }} de {{ photoLength }}", "{{ replyCount }} Replies": "{{ replyCount }} Respostas", diff --git a/package/src/i18n/ru.json b/package/src/i18n/ru.json index 7fef9463f2..1d3b07a725 100644 --- a/package/src/i18n/ru.json +++ b/package/src/i18n/ru.json @@ -74,6 +74,13 @@ "Video": "видео", "You": "Вы", "You can't send messages in this channel": "Вы не можете отправлять сообщения в этот канал", + "timestamp/ChannelPreviewStatus": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: {\"lastDay\":\"[Вчера]\",\"lastWeek\":\"dddd\",\"nextDay\":\"[Завтра]\",\"nextWeek\":\"dddd [в] LT\",\"sameDay\":\"LT\",\"sameElse\":\"L\"}) }}", + "timestamp/ImageGalleryHeader": "{{ timestamp | timestampFormatter(calendar: true) }}", + "timestamp/InlineDateSeparator": "{{ timestamp | timestampFormatter(calendar: true) }}", + "timestamp/MessageEditedTimestamp": "{{ timestamp | timestampFormatter(calendar: true) }}", + "timestamp/MessageSystem": "{{ timestamp | timestampFormatter(calendar: true) }}", + "timestamp/MessageTimestamp": "{{ timestamp | timestampFormatter(format: LT) }}", + "timestamp/StickyHeader": "{{ timestamp | timestampFormatter(calendar: true) }}", "{{ firstUser }} and {{ nonSelfUserLength }} more are typing": "{{ firstUser }} и еще {{ nonSelfUserLength }} пишут", "{{ index }} of {{ photoLength }}": "{{ index }} из {{ photoLength }}", "{{ replyCount }} Replies": "{{ replyCount }} Ответов", diff --git a/package/src/i18n/tr.json b/package/src/i18n/tr.json index aad2912960..f10c13e48a 100644 --- a/package/src/i18n/tr.json +++ b/package/src/i18n/tr.json @@ -74,6 +74,13 @@ "Video": "Video", "You": "Sen", "You can't send messages in this channel": "Bu konuşmaya mesaj gönderemezsiniz", + "timestamp/ChannelPreviewStatus": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: {\"lastDay\":\"[Dün]\",\"lastWeek\":\"dddd\",\"nextDay\":\"[Yarın]\",\"nextWeek\":\"dddd [saat] LT\",\"sameDay\":\"LT\",\"sameElse\":\"L\"}) }}", + "timestamp/ImageGalleryHeader": "{{ timestamp | timestampFormatter(calendar: true) }}", + "timestamp/InlineDateSeparator": "{{ timestamp | timestampFormatter(calendar: true) }}", + "timestamp/MessageEditedTimestamp": "{{ timestamp | timestampFormatter(calendar: true) }}", + "timestamp/MessageSystem": "{{ timestamp | timestampFormatter(calendar: true) }}", + "timestamp/MessageTimestamp": "{{ timestamp | timestampFormatter(format: LT) }}", + "timestamp/StickyHeader": "{{ timestamp | timestampFormatter(calendar: true) }}", "{{ firstUser }} and {{ nonSelfUserLength }} more are typing": "{{ firstUser }} ve {{ nonSelfUserLength }} kişi daha yazıyor", "{{ index }} of {{ photoLength }}": "{{ index }} / {{ photoLength }}", "{{ replyCount }} Replies": "{{ replyCount }} Cevap", diff --git a/package/src/index.ts b/package/src/index.ts index 2a302bc343..24edca108c 100644 --- a/package/src/index.ts +++ b/package/src/index.ts @@ -1,3 +1,6 @@ +/** i18next polyfill to handle intl format for pluralization. For more info see https://www.i18next.com/misc/json-format#i-18-next-json-v4 */ +import 'intl-pluralrules'; + export * from './components'; export * from './hooks'; export { registerNativeHandlers, NetInfo, iOS14RefreshGallerySelection } from './native'; @@ -9,7 +12,7 @@ export * from './icons'; export * from './types/types'; export * from './utils/patchMessageTextCommand'; -export * from './utils/Streami18n'; +export * from './utils/i18n/Streami18n'; export * from './utils/utils'; export * from './utils/StreamChatRN'; diff --git a/package/src/utils/__tests__/Streami18n.test.js b/package/src/utils/__tests__/Streami18n.test.js index b923ed10aa..f84f40599d 100644 --- a/package/src/utils/__tests__/Streami18n.test.js +++ b/package/src/utils/__tests__/Streami18n.test.js @@ -4,7 +4,7 @@ import localeData from 'dayjs/plugin/localeData'; import frTranslations from '../../i18n/fr.json'; import nlTranslations from '../../i18n/nl.json'; -import { Streami18n } from '../Streami18n'; +import { Streami18n } from '../i18n/Streami18n'; Dayjs.extend(localeData); @@ -183,3 +183,25 @@ describe('setLanguage - switch to french', () => { } }); }); + +describe('formatters property', () => { + it('contains the default timestampFormatter', () => { + expect(new Streami18n().formatters.timestampFormatter).toBeDefined(); + }); + it('allows to override the default timestampFormatter', async () => { + const i18n = new Streami18n({ + formatters: { timestampFormatter: () => () => 'custom' }, + translationsForLanguage: { abc: '{{ value | timestampFormatter }}' }, + }); + await i18n.init(); + expect(i18n.t('abc')).toBe('custom'); + }); + it('allows to add new custom formatter', async () => { + const i18n = new Streami18n({ + formatters: { customFormatter: () => () => 'custom' }, + translationsForLanguage: { abc: '{{ value | customFormatter }}' }, + }); + await i18n.init(); + expect(i18n.t('abc')).toBe('custom'); + }); +}); diff --git a/package/src/utils/getDateString.ts b/package/src/utils/getDateString.ts deleted file mode 100644 index 04966f8d7f..0000000000 --- a/package/src/utils/getDateString.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { - isDayOrMoment, - TDateTimeParser, - TDateTimeParserInput, -} from '../contexts/translationContext/TranslationContext'; - -interface DateFormatterOptions { - /** - * Whether to show the time in Calendar time format. Calendar time displays time relative to a today's date. - */ - calendar?: boolean; - /** - * The timestamp to be formatted. - */ - date?: string | Date; - /** - * The format in which the date should be displayed. - */ - format?: string; - /** - * A function to format the date. - */ - formatDate?: (date: TDateTimeParserInput) => string; - /** - * The datetime parsing function. - */ - tDateTimeParser?: TDateTimeParser; -} - -export const noParsingFunctionWarning = - 'MessageTimestamp was called but there is no datetime parsing function available'; - -/** - * Utility funcyion to format the date string. - */ -export function getDateString({ - calendar, - date, - format, - formatDate, - tDateTimeParser, -}: DateFormatterOptions): string | number | undefined { - if (!date || (typeof date === 'string' && !Date.parse(date))) { - return; - } - - if (typeof formatDate === 'function') { - return formatDate(new Date(date)); - } - - if (!tDateTimeParser) { - console.log(noParsingFunctionWarning); - return; - } - - const parsedTime = tDateTimeParser(date); - - if (isDayOrMoment(parsedTime)) { - /** - * parsedTime.calendar is guaranteed on the type but is only - * available when a user calls dayjs.extend(calendar) - */ - return calendar && parsedTime.calendar ? parsedTime.calendar() : parsedTime.format(format); - } - - return new Date(date).toDateString(); -} diff --git a/package/src/utils/Streami18n.ts b/package/src/utils/i18n/Streami18n.ts similarity index 90% rename from package/src/utils/Streami18n.ts rename to package/src/utils/i18n/Streami18n.ts index ae8f56cc69..5b162ba70e 100644 --- a/package/src/utils/Streami18n.ts +++ b/package/src/utils/i18n/Streami18n.ts @@ -8,19 +8,26 @@ import i18n, { FallbackLng, TFunction } from 'i18next'; import type moment from 'moment'; -import type { TDateTimeParser } from '../contexts/translationContext/TranslationContext'; -import enTranslations from '../i18n/en.json'; -import esTranslations from '../i18n/es.json'; -import frTranslations from '../i18n/fr.json'; -import heTranslations from '../i18n/he.json'; -import hiTranslations from '../i18n/hi.json'; -import itTranslations from '../i18n/it.json'; -import jaTranslations from '../i18n/ja.json'; -import koTranslations from '../i18n/ko.json'; -import nlTranslations from '../i18n/nl.json'; -import ptBRTranslations from '../i18n/pt-br.json'; -import ruTranslations from '../i18n/ru.json'; -import trTranslations from '../i18n/tr.json'; +import { calendarFormats } from './calendarFormats'; +import { + CustomFormatters, + PredefinedFormatters, + predefinedFormatters, +} from './predefinedFormatters'; + +import type { TDateTimeParser } from '../../contexts/translationContext/TranslationContext'; +import enTranslations from '../../i18n/en.json'; +import esTranslations from '../../i18n/es.json'; +import frTranslations from '../../i18n/fr.json'; +import heTranslations from '../../i18n/he.json'; +import hiTranslations from '../../i18n/hi.json'; +import itTranslations from '../../i18n/it.json'; +import jaTranslations from '../../i18n/ja.json'; +import koTranslations from '../../i18n/ko.json'; +import nlTranslations from '../../i18n/nl.json'; +import ptBRTranslations from '../../i18n/pt-br.json'; +import ruTranslations from '../../i18n/ru.json'; +import trTranslations from '../../i18n/tr.json'; import 'dayjs/locale/es'; import 'dayjs/locale/fr'; @@ -41,7 +48,7 @@ import 'dayjs/locale/tr'; */ import 'dayjs/locale/en'; -import type { DefaultStreamChatGenerics } from '../types/types'; +import type { DefaultStreamChatGenerics } from '../../types/types'; const defaultNS = 'translation'; const defaultLng = 'en'; @@ -49,6 +56,7 @@ const defaultLng = 'en'; Dayjs.extend(updateLocale); Dayjs.updateLocale('en', { + calendar: calendarFormats.en, format: { L: 'DD/MM/YYYY', LL: 'D MMMM YYYY', @@ -59,37 +67,18 @@ Dayjs.updateLocale('en', { }, }); -Dayjs.updateLocale('nl', { - calendar: { - lastDay: '[gisteren om] LT', - lastWeek: '[afgelopen] dddd [om] LT', - nextDay: '[morgen om] LT', - nextWeek: 'dddd [om] LT', - sameDay: '[vandaag om] LT', - sameElse: 'L', - }, +Dayjs.updateLocale('es', { + calendar: calendarFormats.es, }); - -Dayjs.updateLocale('it', { - calendar: { - lastDay: '[Ieri alle] LT', - lastWeek: '[lo scorso] dddd [alle] LT', - nextDay: '[Domani alle] LT', - nextWeek: 'dddd [alle] LT', - sameDay: '[Oggi alle] LT', - sameElse: 'L', - }, +Dayjs.updateLocale('fr', { + calendar: calendarFormats.fr, +}); +Dayjs.updateLocale('he', { + calendar: calendarFormats.he, }); Dayjs.updateLocale('hi', { - calendar: { - lastDay: '[कल] LT', - lastWeek: '[पिछले] dddd, LT', - nextDay: '[कल] LT', - nextWeek: 'dddd, LT', - sameDay: '[आज] LT', - sameElse: 'L', - }, + calendar: calendarFormats.hi, /** * Hindi notation for meridiems are quite fuzzy in practice. While there exists @@ -116,35 +105,26 @@ Dayjs.updateLocale('hi', { meridiemParse: /रात|सुबह|दोपहर|शाम/, }); - -Dayjs.updateLocale('fr', { - calendar: { - lastDay: '[Hier à] LT', - lastWeek: 'dddd [dernier à] LT', - nextDay: '[Demain à] LT', - nextWeek: 'dddd [à] LT', - sameDay: '[Aujourd’hui à] LT', - sameElse: 'L', - }, +Dayjs.updateLocale('it', { + calendar: calendarFormats.it, }); - -Dayjs.updateLocale('tr', { - calendar: { - lastDay: '[dün] LT', - lastWeek: '[geçen] dddd [saat] LT', - nextDay: '[yarın saat] LT', - nextWeek: '[gelecek] dddd [saat] LT', - sameDay: '[bugün saat] LT', - sameElse: 'L', - }, +Dayjs.updateLocale('ja', { + calendar: calendarFormats.ja, +}); +Dayjs.updateLocale('ko', { + calendar: calendarFormats.ko, +}); +Dayjs.updateLocale('nl', { + calendar: calendarFormats.nl, +}); +Dayjs.updateLocale('pt-br', { + calendar: calendarFormats['pt-br'], }); - Dayjs.updateLocale('ru', { - calendar: { - lastDay: '[Вчера, в] LT', - nextDay: '[Завтра, в] LT', - sameDay: '[Сегодня, в] LT', - }, + calendar: calendarFormats.ru, +}); +Dayjs.updateLocale('tr', { + calendar: calendarFormats.tr, }); const en_locale = { @@ -171,11 +151,12 @@ const en_locale = { const isDayJs = (dateTimeParser: typeof Dayjs | typeof moment): dateTimeParser is typeof Dayjs => (dateTimeParser as typeof Dayjs).extend !== undefined; -type Options = { +type Streami18nOptions = { DateTimeParser?: typeof Dayjs | typeof moment; dayjsLocaleConfigForLanguage?: Partial; debug?: boolean; disableDateTimeTranslations?: boolean; + formatters?: Partial & CustomFormatters; language?: string; logger?: (msg?: string) => void; translationsForLanguage?: Partial; @@ -184,7 +165,7 @@ type Options = { type I18NextConfig = { debug: boolean; fallbackLng: false | FallbackLng; - interpolation: { escapeValue: boolean }; + interpolation: { escapeValue: boolean; formatSeparator: string }; keySeparator: false | string; lng: string; nsSeparator: false | string; @@ -405,6 +386,7 @@ export class Streami18n { logger: (msg?: string) => void; currentLanguage: string; DateTimeParser: typeof Dayjs | typeof moment; + formatters: PredefinedFormatters & CustomFormatters = predefinedFormatters; isCustomDateTimeParser: boolean; i18nextConfig: I18NextConfig; @@ -434,7 +416,7 @@ export class Streami18n { * * @param {*} options */ - constructor(options: Options = {}, i18nextConfig: Partial = {}) { + constructor(options: Streami18nOptions = {}, i18nextConfig: Partial = {}) { const finalOptions = { ...defaultStreami18nOptions, ...options, @@ -445,6 +427,7 @@ export class Streami18n { this.currentLanguage = finalOptions.language; this.DateTimeParser = finalOptions.DateTimeParser; + this.formatters = { ...predefinedFormatters, ...options?.formatters }; try { /** @@ -491,7 +474,7 @@ export class Streami18n { this.i18nextConfig = { debug: finalOptions.debug, fallbackLng: false, - interpolation: { escapeValue: false }, + interpolation: { escapeValue: false, formatSeparator: '|' }, keySeparator: false, lng: this.currentLanguage, nsSeparator: false, @@ -556,6 +539,12 @@ export class Streami18n { this.onTFunctionOverrideListeners.forEach((listener) => listener(this.t)); } this.initialized = true; + if (this.formatters) { + Object.entries(this.formatters).forEach(([name, formatterFactory]) => { + if (!formatterFactory) return; + this.i18nInstance.services.formatter?.add(name, formatterFactory(this)); + }); + } } catch (error) { this.logger(`Something went wrong with init: ${JSON.stringify(error)}`); } diff --git a/package/src/utils/i18n/calendarFormats.ts b/package/src/utils/i18n/calendarFormats.ts new file mode 100644 index 0000000000..74995e7d02 --- /dev/null +++ b/package/src/utils/i18n/calendarFormats.ts @@ -0,0 +1,110 @@ +type CalendarFormats = { + lastDay: string; + lastWeek: string; + nextDay: string; + nextWeek: string; + sameDay: string; + sameElse: string; +}; + +/** + * Calendar formats for different languages. + */ +export const calendarFormats: Record = { + en: { + lastDay: '[Yesterday]', + lastWeek: 'dddd', + nextDay: '[Tomorrow]', + nextWeek: 'dddd [at] LT', + sameDay: '[Today]', + sameElse: 'L', + }, + es: { + lastDay: '[Ayer]', + lastWeek: 'dddd', + nextDay: '[Mañana]', + nextWeek: 'dddd [a las] LT', + sameDay: '[Hoy]', + sameElse: 'L', + }, + fr: { + lastDay: '[Hier]', + lastWeek: 'dddd', + nextDay: '[Demain]', + nextWeek: 'dddd [à] LT', + sameDay: "[Aujourd'hui]", + sameElse: 'L', + }, + he: { + lastDay: '[אתמול]', + lastWeek: 'dddd', + nextDay: '[מחר]', + nextWeek: 'dddd [בשעה] LT', + sameDay: '[היום]', + sameElse: 'L', + }, + hi: { + lastDay: '[कल]', + lastWeek: 'dddd', + nextDay: '[कल]', + nextWeek: 'dddd [को] LT', + sameDay: '[आज]', + sameElse: 'L', + }, + it: { + lastDay: '[Ieri]', + lastWeek: 'dddd', + nextDay: '[Domani]', + nextWeek: 'dddd [alle] LT', + sameDay: '[Oggi]', + sameElse: 'L', + }, + ja: { + lastDay: '[昨日]', + lastWeek: 'dddd', + nextDay: '[明日]', + nextWeek: 'dddd [の] LT', + sameDay: '[今日]', + sameElse: 'L', + }, + ko: { + lastDay: '[어제]', + lastWeek: 'dddd', + nextDay: '[내일]', + nextWeek: 'dddd [LT에]', + sameDay: '[오늘]', + sameElse: 'L', + }, + nl: { + lastDay: '[Gisteren]', + lastWeek: 'dddd', + nextDay: '[Morgen]', + nextWeek: 'dddd [om] LT', + sameDay: '[Vandaag]', + sameElse: 'L', + }, + 'pt-br': { + lastDay: '[Ontem]', + lastWeek: 'dddd', + nextDay: '[Amanhã]', + nextWeek: 'dddd [às] LT', + sameDay: '[Hoje]', + sameElse: 'L', + }, + ru: { + lastDay: '[Вчера]', + lastWeek: 'dddd', + nextDay: '[Завтра]', + nextWeek: 'dddd [в] LT', + sameDay: '[Сегодня]', + sameElse: 'L', // L is the localized date format + }, + tr: { + lastDay: '[Dün]', + lastWeek: 'dddd', + nextDay: '[Yarın]', + nextWeek: 'dddd [saat] LT', + sameDay: '[Bugün]', + sameElse: 'L', + }, +}; diff --git a/package/src/utils/i18n/getDateString.ts b/package/src/utils/i18n/getDateString.ts new file mode 100644 index 0000000000..6b16e8f943 --- /dev/null +++ b/package/src/utils/i18n/getDateString.ts @@ -0,0 +1,76 @@ +import type { TimestampFormatterOptions } from './predefinedFormatters'; + +import { + isDayOrMoment, + TranslatorFunctions, +} from '../../contexts/translationContext/TranslationContext'; + +type DateFormatterOptions = TimestampFormatterOptions & + Partial & { + /** + * The timestamp to be formatted. + */ + date?: string | Date; + /* + * Lookup key in the language corresponding translations sheet to perform date formatting + */ + timestampTranslationKey?: string; + }; + +export const noParsingFunctionWarning = + 'MessageTimestamp was called but there is no datetime parsing function available'; + +/** + * Utility function to format the date string. + */ +export function getDateString({ + calendar, + calendarFormats, + date, + format, + t, + tDateTimeParser, + timestampTranslationKey, +}: DateFormatterOptions): string | number | undefined { + if (!date || (typeof date === 'string' && !Date.parse(date))) { + return; + } + + if (!tDateTimeParser) { + console.log(noParsingFunctionWarning); + return; + } + + if (t && timestampTranslationKey) { + const options: TimestampFormatterOptions = {}; + if (typeof calendar !== 'undefined' && calendar !== null) { + options.calendar = calendar; + } + if (typeof calendarFormats !== 'undefined' && calendarFormats !== null) { + options.calendarFormats = calendarFormats; + } + if (typeof format !== 'undefined' && format !== null) { + options.format = format; + } + const translatedTimestamp = t(timestampTranslationKey, { + ...options, + timestamp: new Date(date), + }); + const translationKeyFound = timestampTranslationKey !== translatedTimestamp; + if (translationKeyFound) return translatedTimestamp; + } + + const parsedTime = tDateTimeParser(date); + + if (isDayOrMoment(parsedTime)) { + /** + * parsedTime.calendar is guaranteed on the type but is only + * available when a user calls dayjs.extend(calendar) + */ + return calendar && parsedTime.calendar + ? parsedTime.calendar(undefined, calendarFormats) + : parsedTime.format(format); + } + + return new Date(date).toDateString(); +} diff --git a/package/src/utils/i18n/predefinedFormatters.ts b/package/src/utils/i18n/predefinedFormatters.ts new file mode 100644 index 0000000000..3b1c3998ec --- /dev/null +++ b/package/src/utils/i18n/predefinedFormatters.ts @@ -0,0 +1,63 @@ +import { getDateString } from './getDateString'; +import { Streami18n } from './Streami18n'; + +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; + /* Overrides the default timestamp format if calendar is disabled. */ + format?: string; +}; + +export type FormatterFactory = ( + streamI18n: Streami18n, +) => (value: V, lng: string | undefined, options: Record) => string; + +// Here is any used, because we do not want to enforce any specific rules and +// want to leave the type declaration to the integrator +/* eslint-disable-next-line @typescript-eslint/no-explicit-any */ +export type CustomFormatters = Record>; + +export type PredefinedFormatters = { + timestampFormatter: FormatterFactory; +}; + +export const predefinedFormatters: PredefinedFormatters = { + timestampFormatter: + (streamI18n) => + ( + value, + _, + { + calendarFormats, + ...options + }: Pick & { + calendarFormats?: Record | string; + }, + ) => { + let parsedCalendarFormats; + try { + if (!options.calendar) { + parsedCalendarFormats = {}; + } else if (typeof calendarFormats === 'string') { + parsedCalendarFormats = JSON.parse(calendarFormats); + } else if (typeof calendarFormats === 'object') { + parsedCalendarFormats = calendarFormats; + } + } catch (e) { + console.error('[TIMESTAMP FORMATTER]', e); + } + + const result = getDateString({ + ...options, + calendarFormats: parsedCalendarFormats, + date: value, + tDateTimeParser: streamI18n.tDateTimeParser, + }); + if (!result || typeof result === 'number') { + return JSON.stringify(value); + } + return result; + }, +}; diff --git a/package/yarn.lock b/package/yarn.lock index d729819cbb..6ccecd67fa 100644 --- a/package/yarn.lock +++ b/package/yarn.lock @@ -1767,13 +1767,6 @@ dependencies: regenerator-runtime "^0.14.0" -"@babel/runtime@^7.12.0", "@babel/runtime@^7.8.4": - version "7.22.5" - resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.22.5.tgz#8564dd588182ce0047d55d7a75e93921107b57ec" - integrity sha512-ecjvYlnAaZ/KVneE/OdKYBYfgXV3Ptu6zQWmgEF7vwKhQnvVS6bjMD2XYgj+SNvQ1GfK/pjgokfPkC/2CO8CuA== - dependencies: - regenerator-runtime "^0.13.11" - "@babel/runtime@^7.16.3": version "7.23.9" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.23.9.tgz#47791a15e4603bb5f905bc0753801cf21d6345f7" @@ -1781,6 +1774,13 @@ dependencies: regenerator-runtime "^0.14.0" +"@babel/runtime@^7.17.2", "@babel/runtime@^7.23.2": + version "7.24.7" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.24.7.tgz#f4f0d5530e8dbdf59b3451b9b3e594b6ba082e12" + integrity sha512-UwgBRMjJP+xv857DCngvqXI3Iq6J4v0wXmwc6sapg+zyhbwmQX67LUEFrkK5tbyJ30jGuG3ZvWpBiB9LCy1kWw== + dependencies: + regenerator-runtime "^0.14.0" + "@babel/runtime@^7.20.0": version "7.24.4" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.24.4.tgz#de795accd698007a66ba44add6cc86542aff1edd" @@ -1788,12 +1788,12 @@ dependencies: regenerator-runtime "^0.14.0" -"@babel/runtime@^7.23.2": - version "7.24.7" - resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.24.7.tgz#f4f0d5530e8dbdf59b3451b9b3e594b6ba082e12" - integrity sha512-UwgBRMjJP+xv857DCngvqXI3Iq6J4v0wXmwc6sapg+zyhbwmQX67LUEFrkK5tbyJ30jGuG3ZvWpBiB9LCy1kWw== +"@babel/runtime@^7.8.4": + version "7.22.5" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.22.5.tgz#8564dd588182ce0047d55d7a75e93921107b57ec" + integrity sha512-ecjvYlnAaZ/KVneE/OdKYBYfgXV3Ptu6zQWmgEF7vwKhQnvVS6bjMD2XYgj+SNvQ1GfK/pjgokfPkC/2CO8CuA== dependencies: - regenerator-runtime "^0.14.0" + regenerator-runtime "^0.13.11" "@babel/template@^7.0.0", "@babel/template@^7.22.5", "@babel/template@^7.3.3": version "7.22.5" @@ -6867,12 +6867,12 @@ i18next-parser@^9.0.0: vinyl "~3.0.0" vinyl-fs "^4.0.0" -i18next@20.2.4: - version "20.2.4" - resolved "https://registry.yarnpkg.com/i18next/-/i18next-20.2.4.tgz#972220f19dfef0075a70890d3e8b1f7cf64c5bd6" - integrity sha512-goE1LCA/IZOGG26PkkqoOl2KWR7YP606SvokVQZ29J6QwE02KycrzNetoMUJeqYrTxs4rmiiZgZp+q8qofQL6Q== +i18next@^21.6.14: + version "21.10.0" + resolved "https://registry.yarnpkg.com/i18next/-/i18next-21.10.0.tgz#85429af55fdca4858345d0e16b584ec29520197d" + integrity sha512-YeuIBmFsGjUfO3qBmMOc0rQaun4mIpGKET5WDwvu8lU7gvwpcariZLNtL0Fzj+zazcHUrlXHiptcFhBMFaxzfg== dependencies: - "@babel/runtime" "^7.12.0" + "@babel/runtime" "^7.17.2" i18next@^23.5.1: version "23.11.5" @@ -7005,6 +7005,11 @@ internal-slot@^1.0.7: hasown "^2.0.0" side-channel "^1.0.4" +intl-pluralrules@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/intl-pluralrules/-/intl-pluralrules-2.0.1.tgz#de16c3df1e09437635829725e88ea70c9ad79569" + integrity sha512-astxTLzIdXPeN0K9Rumi6LfMpm3rvNO0iJE+h/k8Kr/is+wPbRe4ikyDjlLr6VTh/mEfNv8RjN+gu3KwDiuhqg== + invariant@^2.2.4: version "2.2.4" resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.4.tgz#610f3c92c9359ce1db616e538008d23ff35158e6"