From 6aca6d9f89ccec54fe306e84e5f568688d01a823 Mon Sep 17 00:00:00 2001 From: Zita Szupera Date: Mon, 31 Aug 2026 16:07:23 +0200 Subject: [PATCH 1/8] feat!: move to timestamps in response models --- src/components/Attachment/Geolocation.tsx | 18 ++++-- src/components/Attachment/ModalGallery.tsx | 3 +- .../Attachment/__tests__/Geolocation.test.tsx | 10 ++-- src/components/Channel/Channel.tsx | 6 +- .../Channel/__tests__/Channel.test.tsx | 4 +- .../ChannelListItemActionButtons.defaults.tsx | 2 +- .../ChannelListItemTimestamp.tsx | 7 ++- .../useMessageDeliveryStatus.test.tsx | 42 +++++++------- src/components/ChannelListItem/utils.a11y.ts | 7 ++- src/components/ChannelListItem/utils.tsx | 3 +- .../Message/MessageEditedIndicator.tsx | 10 +++- src/components/Message/MessageTimestamp.tsx | 8 ++- .../Message/ReminderNotification.tsx | 10 ++-- .../__tests__/MessageTimestamp.test.tsx | 21 ++++--- src/components/Message/hooks/usePinHandler.ts | 3 +- .../Message/hooks/useReactionHandler.ts | 3 +- src/components/MessageList/MessageList.tsx | 5 +- .../MessageList/VirtualizedMessageList.tsx | 3 +- .../MessageList/__tests__/utils.test.ts | 13 +++-- .../useFloatingDateSeparator.ts | 5 +- ...seUnreadMessagesNotificationVirtualized.ts | 14 ++--- ...adMessagesNotificationVirtualized.test.tsx | 32 ++++++----- .../MessageList/hooks/useMarkRead.ts | 3 +- src/components/MessageList/renderMessages.tsx | 4 +- src/components/MessageList/utils.ts | 55 +++++++++--------- src/components/Poll/PollVote.tsx | 7 ++- .../Reactions/hooks/useProcessReactions.tsx | 6 +- .../Search/SearchResults/SearchResultItem.tsx | 4 +- src/components/Thread/ThreadHead.tsx | 5 +- .../Threads/ThreadList/ThreadListItemUI.tsx | 3 +- .../Threads/ThreadList/utils.a11y.ts | 7 ++- src/context/ChatContext.tsx | 3 +- src/mock-builders/api/markRead.ts | 3 +- src/mock-builders/event/draftDeleted.ts | 3 +- src/mock-builders/event/draftUpdated.ts | 3 +- src/mock-builders/event/messageDelivered.ts | 11 ++-- src/mock-builders/event/messageRead.ts | 3 +- .../event/notificationMarkRead.ts | 3 +- .../event/notificationMarkUnread.ts | 5 +- .../event/notificationMutesUpdated.ts | 3 +- .../event/userMessagesDeleted.ts | 5 +- src/mock-builders/event/userUpdated.ts | 3 +- src/mock-builders/generator/channel.ts | 15 ++--- src/mock-builders/generator/message.ts | 29 ++++++++-- src/mock-builders/generator/messageDraft.ts | 3 +- src/mock-builders/generator/poll.ts | 57 ++++++++++--------- src/mock-builders/generator/reaction.ts | 7 ++- src/mock-builders/generator/reminder.ts | 6 +- src/mock-builders/generator/sharedLocation.ts | 11 ++-- src/mock-builders/generator/time.ts | 18 ++++++ src/mock-builders/generator/user.ts | 5 +- .../ChannelFilesView.utils.ts | 13 +++-- .../__tests__/ChannelFilesView.test.tsx | 7 ++- .../ChannelMediaView.utils.ts | 3 +- .../ChannelMemberDetail.tsx | 3 +- .../ChannelMembersBrowseView.tsx | 3 +- .../PinnedMessagesView/PinnedMessagesView.tsx | 13 +++-- 57 files changed, 342 insertions(+), 219 deletions(-) create mode 100644 src/mock-builders/generator/time.ts diff --git a/src/components/Attachment/Geolocation.tsx b/src/components/Attachment/Geolocation.tsx index 1c6f1ad0f2..06039fa512 100644 --- a/src/components/Attachment/Geolocation.tsx +++ b/src/components/Attachment/Geolocation.tsx @@ -7,6 +7,7 @@ import { useChannel, useChatContext, useTranslationContext } from '../../context import { ExternalLinkIcon } from './icons'; import { IconLocation } from '../Icons'; import { Button } from '../Button'; +import { convertTimestampToDate, nowNs, nsToMs } from 'stream-chat'; export type GeolocationMapProps = Coords; @@ -26,7 +27,7 @@ export const Geolocation = ({ const { t } = useTranslationContext(); const [stoppedSharing, setStoppedSharing] = useState( - !!location.end_at && new Date(location.end_at).getTime() < new Date().getTime(), + !!location.end_at && location.end_at < nowNs(), ); const timeoutRef = useRef | undefined>(undefined); @@ -38,7 +39,9 @@ export const Geolocation = ({ clearTimeout(timeoutRef.current); timeoutRef.current = setTimeout( () => setStoppedSharing(true), - new Date(location.end_at).getTime() - Date.now(), + // Both operands are wire timestamps, so the difference is in nanoseconds. Passing it raw + // made `setTimeout` fire immediately and end sharing on mount. + Math.max(0, nsToMs(location.end_at - nowNs())), ); }, [location.end_at]); @@ -66,7 +69,12 @@ export const Geolocation = ({ diff --git a/src/components/Thread/ThreadHead.tsx b/src/components/Thread/ThreadHead.tsx index 1fd2931ca7..f978a34167 100644 --- a/src/components/Thread/ThreadHead.tsx +++ b/src/components/Thread/ThreadHead.tsx @@ -1,3 +1,4 @@ +import { convertTimestampToDate } from 'stream-chat'; import React from 'react'; import type { MessageProps } from '../Message'; @@ -11,7 +12,9 @@ export const ThreadHead = (props: MessageProps) => { const { ThreadStart = DefaultThreadStart } = useComponentContext(); return (
- +
diff --git a/src/components/Threads/ThreadList/ThreadListItemUI.tsx b/src/components/Threads/ThreadList/ThreadListItemUI.tsx index 40732e49a4..541f38a923 100644 --- a/src/components/Threads/ThreadList/ThreadListItemUI.tsx +++ b/src/components/Threads/ThreadList/ThreadListItemUI.tsx @@ -1,3 +1,4 @@ +import { convertTimestampToDate } from 'stream-chat'; import type { ComponentPropsWithoutRef } from 'react'; import React, { useCallback, useEffect, useMemo } from 'react'; import clsx from 'clsx'; @@ -220,7 +221,7 @@ export const ThreadListItemUI = ({ diff --git a/src/components/Threads/ThreadList/utils.a11y.ts b/src/components/Threads/ThreadList/utils.a11y.ts index 2dc40eb3f7..9cc95bf21c 100644 --- a/src/components/Threads/ThreadList/utils.a11y.ts +++ b/src/components/Threads/ThreadList/utils.a11y.ts @@ -9,7 +9,8 @@ import { composeAccessibleLabel, unreadCountLabelPart, } from '../../../a11y/accessibleLabel'; -import { getDateString, isDate } from '../../../i18n/utils'; +import { getDateString } from '../../../i18n/utils'; +import { nsToDate } from 'stream-chat'; /** * Everything a label part needs. Gathered by `ThreadListItemUI` from the thread state + contexts and @@ -85,9 +86,9 @@ export const defaultThreadListItemLabelParts = { : undefined, time: ({ latestReply, t, tDateTimeParser }) => { const createdAt = latestReply?.created_at; - if (!createdAt || !isDate(createdAt)) return undefined; + if (createdAt == null) return undefined; const when = getDateString({ - messageCreatedAt: createdAt.toISOString(), + messageCreatedAt: nsToDate(createdAt).toISOString(), t, tDateTimeParser, timestampTranslationKey: 'timestamp.ChannelPreviewTimestamp', diff --git a/src/context/ChatContext.tsx b/src/context/ChatContext.tsx index 5b329db10d..56ebdcde48 100644 --- a/src/context/ChatContext.tsx +++ b/src/context/ChatContext.tsx @@ -35,7 +35,8 @@ export type ChatContextValue = { */ channelManager: ChannelManager; getAppSettings: () => ReturnType | null; - latestMessageDatesByChannels: Record; + /** Newest own-message timestamp per channel, in unix nanoseconds as the API sends it. */ + latestMessageDatesByChannels: Record; mutes: Array; /** Instance of SearchController class that allows to control all the search operations. */ searchController: SearchController; diff --git a/src/mock-builders/api/markRead.ts b/src/mock-builders/api/markRead.ts index 2ab0cb66a8..a5ac8edd94 100644 --- a/src/mock-builders/api/markRead.ts +++ b/src/mock-builders/api/markRead.ts @@ -1,4 +1,5 @@ import type { Channel } from 'stream-chat'; +import { convertDateToTimestamp } from '../generator/time'; /** * Returns the api response for markRead api @@ -11,7 +12,7 @@ export const markReadApi = (channel: Channel) => ({ channel_id: channel.id, channel_type: channel.type, cid: channel.cid, - created_at: new Date().toISOString(), + created_at: convertDateToTimestamp(), last_read_message_id: channel.messagePaginator.headmostItem?.id, type: 'message.read' as const, user: channel.getClient().user, diff --git a/src/mock-builders/event/draftDeleted.ts b/src/mock-builders/event/draftDeleted.ts index 9ce83ebf96..d2fe45eac5 100644 --- a/src/mock-builders/event/draftDeleted.ts +++ b/src/mock-builders/event/draftDeleted.ts @@ -1,4 +1,5 @@ import type { DraftResponse, StreamChat } from 'stream-chat'; +import { convertDateToTimestamp } from '../generator/time'; export const dispatchDraftDeleted = ({ client, @@ -9,7 +10,7 @@ export const dispatchDraftDeleted = ({ }) => { client.dispatchEvent({ cid: draft.channel_cid, - created_at: new Date().toISOString(), + created_at: convertDateToTimestamp(), draft, type: 'draft.deleted', }); diff --git a/src/mock-builders/event/draftUpdated.ts b/src/mock-builders/event/draftUpdated.ts index ea28e0b568..fe047b1c7c 100644 --- a/src/mock-builders/event/draftUpdated.ts +++ b/src/mock-builders/event/draftUpdated.ts @@ -1,4 +1,5 @@ import type { DraftResponse, StreamChat } from 'stream-chat'; +import { convertDateToTimestamp } from '../generator/time'; export const dispatchDraftUpdated = ({ client, @@ -9,7 +10,7 @@ export const dispatchDraftUpdated = ({ }) => { client.dispatchEvent({ cid: draft.channel_cid, - created_at: new Date().toISOString(), + created_at: convertDateToTimestamp(), draft, type: 'draft.updated', }); diff --git a/src/mock-builders/event/messageDelivered.ts b/src/mock-builders/event/messageDelivered.ts index a9ad060202..11ea09eea9 100644 --- a/src/mock-builders/event/messageDelivered.ts +++ b/src/mock-builders/event/messageDelivered.ts @@ -5,6 +5,7 @@ import type { StreamChat, UserResponse, } from 'stream-chat'; +import { convertDateToTimestamp } from '../generator/time'; type MessageDeliveredEvent = { channel_custom: CustomChannelData; @@ -27,20 +28,20 @@ export const makeMessageDeliveredEvent = ( channel_member_count: 2, channel_type: 'messaging', cid: 'messaging:test', - created_at: '2025-09-16T13:25:57.996011272Z', + created_at: convertDateToTimestamp('2025-09-16T13:25:57.996011272Z'), last_delivered_at: '2025-09-16T13:25:57Z', last_delivered_message_id: 'aefbf38a-0e02-4ba6-a480-e595c37ec78a', type: 'message.delivered', user: { banned: false, blocked_user_ids: [], - created_at: '2025-09-16T09:01:40.650479Z', + created_at: convertDateToTimestamp('2025-09-16T09:01:40.650479Z'), id: 'test1', - last_active: '2025-09-16T13:22:52.69594176Z', + last_active: convertDateToTimestamp('2025-09-16T13:22:52.69594176Z'), online: true, role: 'user', teams: [], - updated_at: '2025-09-16T12:40:29.86597Z', + updated_at: convertDateToTimestamp('2025-09-16T12:40:29.86597Z'), }, ...event, }); @@ -64,7 +65,7 @@ export const dispatchMessageDeliveredEvent = ({ channel_member_count: channel.data?.member_count || 0, channel_type: channel.type, cid: channel.cid, - created_at: new Date().toISOString(), + created_at: convertDateToTimestamp(), last_delivered_at: deliveredAt, last_delivered_message_id: lastDeliveredMessageId, user, diff --git a/src/mock-builders/event/messageRead.ts b/src/mock-builders/event/messageRead.ts index d3e1ef3288..3942abb956 100644 --- a/src/mock-builders/event/messageRead.ts +++ b/src/mock-builders/event/messageRead.ts @@ -1,6 +1,7 @@ import { fromPartial } from '@total-typescript/shoehorn'; import type { Event, StreamChat, UserResponse } from 'stream-chat'; import { type ChannelOrResponse, toChannelResponse } from './utils'; +import { convertDateToTimestamp } from '../generator/time'; export default ( client: StreamChat, @@ -12,7 +13,7 @@ export default ( const event = fromPartial({ channel: data, cid: data.cid, - created_at: new Date().toISOString(), + created_at: convertDateToTimestamp(), last_read_message_id: last_read_message_id || 'last_read_message_id', type: 'message.read' as const, user, diff --git a/src/mock-builders/event/notificationMarkRead.ts b/src/mock-builders/event/notificationMarkRead.ts index 1ce3bebd22..8fa242cd7b 100644 --- a/src/mock-builders/event/notificationMarkRead.ts +++ b/src/mock-builders/event/notificationMarkRead.ts @@ -2,6 +2,7 @@ import { fromPartial } from '@total-typescript/shoehorn'; import type { Channel, Event, StreamChat, UserResponse } from 'stream-chat'; import { generateUser } from '../generator'; +import { convertDateToTimestamp } from '../generator/time'; export default ({ channel, @@ -20,7 +21,7 @@ export default ({ channel_id: channel?.id, channel_type: channel?.type, cid: channel?.cid, - created_at: new Date().toISOString(), + created_at: convertDateToTimestamp(), last_read_message_id: 'user_id-rfh6ieeQ8XCqabLN-GCHo', total_unread_count: 3, type: 'notification.mark_read', diff --git a/src/mock-builders/event/notificationMarkUnread.ts b/src/mock-builders/event/notificationMarkUnread.ts index 1e7d62bd4c..0c6e45e7d3 100644 --- a/src/mock-builders/event/notificationMarkUnread.ts +++ b/src/mock-builders/event/notificationMarkUnread.ts @@ -2,6 +2,7 @@ import { fromPartial } from '@total-typescript/shoehorn'; import type { Channel, Event, StreamChat, UserResponse } from 'stream-chat'; import { generateUser } from '../generator'; +import { convertDateToTimestamp } from '../generator/time'; export default ({ channel, @@ -21,10 +22,10 @@ export default ({ channel_type: channel.type, cid: channel.cid, // event creation timestamp - created_at: new Date().toISOString(), + created_at: convertDateToTimestamp(), first_unread_message_id: 'SmithAnne-jZsHxapoz50G3QwDiYmoQ', // creation date of a message with last_read_message_id - last_read_at: '2023-12-15T11:49:21.667730943Z', + last_read_at: convertDateToTimestamp('2023-12-15T11:49:21.667730943Z'), last_read_message_id: 'SmithAnne-jeIYWT39L56bs79f10Hao', total_unread_count: 19, type: 'notification.mark_unread', diff --git a/src/mock-builders/event/notificationMutesUpdated.ts b/src/mock-builders/event/notificationMutesUpdated.ts index 23de346bb2..5e12f83281 100644 --- a/src/mock-builders/event/notificationMutesUpdated.ts +++ b/src/mock-builders/event/notificationMutesUpdated.ts @@ -1,10 +1,11 @@ import { fromPartial } from '@total-typescript/shoehorn'; import type { Event, StreamChat, UserMuteResponse } from 'stream-chat'; +import { convertDateToTimestamp } from '../generator/time'; export default (client: StreamChat, mutes: UserMuteResponse[] = []) => { client.dispatchEvent( fromPartial({ - created_at: '2020-05-26T07:11:57.968294216Z', + created_at: convertDateToTimestamp('2020-05-26T07:11:57.968294216Z'), me: { ...client.user, channel_mutes: [], diff --git a/src/mock-builders/event/userMessagesDeleted.ts b/src/mock-builders/event/userMessagesDeleted.ts index d256275985..c8d20e8cd3 100644 --- a/src/mock-builders/event/userMessagesDeleted.ts +++ b/src/mock-builders/event/userMessagesDeleted.ts @@ -1,5 +1,6 @@ import type { StreamChat, UserResponse } from 'stream-chat'; import { type ChannelOrResponse, toChannelResponse } from './utils'; +import { convertDateToTimestamp } from '../generator/time'; export default ({ channel, @@ -23,14 +24,14 @@ export default ({ channel_member_count: 2, channel_type, cid: data.cid, - created_at: new Date().toISOString(), + created_at: convertDateToTimestamp(), hard_delete: !!hardDelete, type: 'user.messages.deleted', user: user as UserResponse, }); } else { client.dispatchEvent({ - created_at: new Date().toISOString(), + created_at: convertDateToTimestamp(), hard_delete: !!hardDelete, type: 'user.messages.deleted', user: user as UserResponse, diff --git a/src/mock-builders/event/userUpdated.ts b/src/mock-builders/event/userUpdated.ts index 07e7f2ae9c..447c238772 100644 --- a/src/mock-builders/event/userUpdated.ts +++ b/src/mock-builders/event/userUpdated.ts @@ -1,10 +1,11 @@ import { fromPartial } from '@total-typescript/shoehorn'; import type { Event, StreamChat, UserResponse } from 'stream-chat'; +import { convertDateToTimestamp } from '../generator/time'; export default (client: StreamChat, user: Partial) => { client.dispatchEvent( fromPartial({ - created_at: new Date().toISOString(), + created_at: convertDateToTimestamp(), type: 'user.updated', user, }), diff --git a/src/mock-builders/generator/channel.ts b/src/mock-builders/generator/channel.ts index 2b4e6fd58e..c03e5f4333 100644 --- a/src/mock-builders/generator/channel.ts +++ b/src/mock-builders/generator/channel.ts @@ -6,6 +6,7 @@ import type { MessageResponse, } from 'stream-chat'; import type { DeepPartial } from '../../types/types'; +import { convertDateToTimestamp } from './time'; export type GenerateChannelOptions = Omit, 'messages'> & { messages?: (DeepPartial | LocalMessage)[]; @@ -46,7 +47,7 @@ export const generateChannel = (options?: GenerateChannelOptions): ChannelAPIRes }, ], connect_events: true, - created_at: '2020-04-24T11:36:43.859020368Z', + created_at: convertDateToTimestamp('2020-04-24T11:36:43.859020368Z'), max_message_length: 5000, message_retention: 'infinite', mutes: true, @@ -58,28 +59,28 @@ export const generateChannel = (options?: GenerateChannelOptions): ChannelAPIRes search: true, shared_locations: true, typing_events: true, - updated_at: '2020-04-24T11:36:43.859022903Z', + updated_at: convertDateToTimestamp('2020-04-24T11:36:43.859022903Z'), uploads: true, url_enrichment: true, ...config, } as ChannelConfigWithInfo, - created_at: '2020-04-28T11:20:48.578147Z', + created_at: convertDateToTimestamp('2020-04-28T11:20:48.578147Z'), created_by: { banned: false, - created_at: '2020-04-27T13:05:13.847572Z', + created_at: convertDateToTimestamp('2020-04-27T13:05:13.847572Z'), id: 'vishal', - last_active: '2020-04-28T11:21:08.353026Z', + last_active: convertDateToTimestamp('2020-04-28T11:21:08.353026Z'), online: false, role: 'user', - updated_at: '2020-04-28T11:21:08.357468Z', + updated_at: convertDateToTimestamp('2020-04-28T11:21:08.357468Z'), }, disabled: false, frozen: false, id, type, - updated_at: '2020-04-28T11:20:48.578147Z', + updated_at: convertDateToTimestamp('2020-04-28T11:20:48.578147Z'), ...restOptionsChannel, }, messages, diff --git a/src/mock-builders/generator/message.ts b/src/mock-builders/generator/message.ts index b43fec3615..8d6f827731 100644 --- a/src/mock-builders/generator/message.ts +++ b/src/mock-builders/generator/message.ts @@ -1,20 +1,30 @@ import { nanoid } from 'nanoid'; import type { LocalMessage, MessageResponse } from 'stream-chat'; import type { DeepPartial } from '../../types/types'; +import { convertDateToTimestamp } from './time'; type GenerateMessageOptions = Omit< DeepPartial, 'created_at' | 'updated_at' > & { - created_at?: Date | string; - updated_at?: Date | string; + created_at?: Date | number | string; + updated_at?: Date | number | string; }; +/** The message fields the API sends as unix-nanosecond numbers. */ +const TIMESTAMP_FIELDS = [ + 'created_at', + 'updated_at', + 'deleted_at', + 'pinned_at', + 'message_text_updated_at', +] as const; + export const generateMessage = (options?: GenerateMessageOptions): LocalMessage => { const data = { __html: '

regular

', attachments: [], - created_at: new Date(), + created_at: convertDateToTimestamp(), html: '

regular

', id: nanoid(), mentioned_users: [], @@ -22,10 +32,21 @@ export const generateMessage = (options?: GenerateMessageOptions): LocalMessage status: 'received', text: nanoid(), type: 'regular', - updated_at: new Date(), + updated_at: convertDateToTimestamp(), user: null, ...options, } as unknown as LocalMessage; + // Tests read better overriding a timestamp with a date literal, but the wire carries numbers — + // and a fixture handing the SDK a `Date` cannot catch the bugs that unit exists to prevent. + // Normalize every timestamp override here so no individual test has to. + for (const field of TIMESTAMP_FIELDS) { + const value = (data as unknown as Record)[field]; + if (value != null && typeof value !== 'number') { + (data as unknown as Record)[field] = convertDateToTimestamp( + value as Date | number | string, + ); + } + } if (data['reminder']) { (data['reminder'] as any).message_id = data.id; } diff --git a/src/mock-builders/generator/messageDraft.ts b/src/mock-builders/generator/messageDraft.ts index 11275d1969..ca2a38736a 100644 --- a/src/mock-builders/generator/messageDraft.ts +++ b/src/mock-builders/generator/messageDraft.ts @@ -1,5 +1,6 @@ import { generateMessage } from './message'; import type { DraftResponse } from 'stream-chat'; +import { convertDateToTimestamp } from './time'; export const generateMessageDraft = ({ channel_cid, @@ -7,7 +8,7 @@ export const generateMessageDraft = ({ }: Partial) => ({ channel_cid, - created_at: new Date().toISOString(), + created_at: convertDateToTimestamp(), message: generateMessage(), ...customMsgDraft, }) as DraftResponse; diff --git a/src/mock-builders/generator/poll.ts b/src/mock-builders/generator/poll.ts index 823c66f3ef..ac591750fe 100644 --- a/src/mock-builders/generator/poll.ts +++ b/src/mock-builders/generator/poll.ts @@ -1,5 +1,6 @@ import type { PollResponseData, PollVoteResponseData, UserResponse } from 'stream-chat'; import type { DeepPartial } from '../../types/types'; +import { convertDateToTimestamp } from './time'; const pollId = 'WD4SBRJvLoGwB4oAoCQGM'; @@ -13,33 +14,33 @@ const userResponseDefaults = { const user1 = { banned: false, - created_at: new Date('2022-03-08T09:46:56.840739Z'), + created_at: convertDateToTimestamp('2022-03-08T09:46:56.840739Z'), id: 'admin', - last_active: new Date('2024-10-23T08:14:23.299448386Z'), + last_active: convertDateToTimestamp('2024-10-23T08:14:23.299448386Z'), mutes: null, name: 'Test User', online: true, role: 'admin', - updated_at: new Date('2024-09-13T13:53:32.883409Z'), + updated_at: convertDateToTimestamp('2024-09-13T13:53:32.883409Z'), ...userResponseDefaults, } as UserResponse; const user1Votes = [ { - created_at: new Date('2024-10-22T15:58:27.756166Z'), + created_at: convertDateToTimestamp('2024-10-22T15:58:27.756166Z'), id: '332da4fe-e38c-465c-8f74-e8df69680f13', option_id: '85610252-7d50-429c-8183-51a7eba46246', poll_id: pollId, - updated_at: new Date('2024-10-22T15:58:27.756166Z'), + updated_at: convertDateToTimestamp('2024-10-22T15:58:27.756166Z'), user: user1, user_id: user1.id, } as PollVoteResponseData, { - created_at: new Date('2024-10-22T15:58:25.886491Z'), + created_at: convertDateToTimestamp('2024-10-22T15:58:25.886491Z'), id: '5657da00-256e-41fc-a580-b7adabcbfbe1', option_id: 'dc22dcd6-4fc8-4c92-92c2-bfd63245724c', poll_id: pollId, - updated_at: new Date('2024-10-22T15:58:25.886491Z'), + updated_at: convertDateToTimestamp('2024-10-22T15:58:25.886491Z'), user: user1, user_id: user1.id, } as PollVoteResponseData, @@ -47,34 +48,34 @@ const user1Votes = [ const user2 = { banned: false, - created_at: new Date('2022-01-27T08:28:28.412254Z'), + created_at: convertDateToTimestamp('2022-01-27T08:28:28.412254Z'), id: 'SmithAnne', image: 'https://getstream.io/random_png/?name=SmithAnne', - last_active: new Date('2024-10-23T08:01:43.157632831Z'), + last_active: convertDateToTimestamp('2024-10-23T08:01:43.157632831Z'), name: 'SmithAnne', nickname: 'Ann', online: true, role: 'user', - updated_at: new Date('2024-09-26T10:12:23.427141Z'), + updated_at: convertDateToTimestamp('2024-09-26T10:12:23.427141Z'), ...userResponseDefaults, } as UserResponse; const user2Votes = [ { - created_at: new Date('2024-10-22T16:00:50.2493Z'), + created_at: convertDateToTimestamp('2024-10-22T16:00:50.2493Z'), id: 'f428f353-3057-4353-b0b5-b33dcdeb1992', option_id: '7312e983-b042-4596-b5ce-f9e82deb363f', poll_id: pollId, - updated_at: new Date('2024-10-22T16:00:50.2493Z'), + updated_at: convertDateToTimestamp('2024-10-22T16:00:50.2493Z'), user: user2, user_id: user2.id, } as PollVoteResponseData, { - created_at: new Date('2024-10-22T16:00:54.410474Z'), + created_at: convertDateToTimestamp('2024-10-22T16:00:54.410474Z'), id: '75ba8774-bf17-4edd-8ced-39e7dc6aa7dd', option_id: '85610252-7d50-429c-8183-51a7eba46246', poll_id: pollId, - updated_at: new Date('2024-10-22T16:00:54.410474Z'), + updated_at: convertDateToTimestamp('2024-10-22T16:00:54.410474Z'), user: user2, user_id: user2.id, } as PollVoteResponseData, @@ -82,24 +83,24 @@ const user2Votes = [ const user1Answer = { answer_text: 'comment1', - created_at: new Date('2024-10-23T13:12:57.944913Z'), + created_at: convertDateToTimestamp('2024-10-23T13:12:57.944913Z'), id: 'dbb4506c-c5a8-4ca6-86ec-0c57498916fe', is_answer: true, option_id: '', poll_id: pollId, - updated_at: new Date('2024-10-23T13:12:57.944913Z'), + updated_at: convertDateToTimestamp('2024-10-23T13:12:57.944913Z'), user: user1, user_id: user1.id, } as PollVoteResponseData; const user2Answer = { answer_text: 'comment2', - created_at: new Date('2024-10-23T13:12:57.944913Z'), + created_at: convertDateToTimestamp('2024-10-23T13:12:57.944913Z'), id: 'dbb4506c-c5a8-4ca6-86ec-0c57498916xy', is_answer: true, option_id: '', poll_id: pollId, - updated_at: new Date('2024-10-23T13:12:57.944913Z'), + updated_at: convertDateToTimestamp('2024-10-23T13:12:57.944913Z'), user: user2, user_id: user2.id, } as PollVoteResponseData; @@ -151,10 +152,10 @@ const pollEnrichData = { }; const pollMetadata = { - created_at: new Date('2024-10-22T15:28:20.580523Z'), + created_at: convertDateToTimestamp('2024-10-22T15:28:20.580523Z'), created_by: user1, created_by_id: user1.id, - updated_at: new Date('2024-10-22T15:28:20.580523Z'), + updated_at: convertDateToTimestamp('2024-10-22T15:28:20.580523Z'), }; export const generatePoll = (data: DeepPartial = {}) => @@ -172,22 +173,22 @@ export const generatePollVoteCastedEvent = ({ pollVote, }: { cid?: string; - createdAt?: Date; + createdAt?: Date | number | string; poll?: DeepPartial; pollVote?: Partial; } = {}) => { const resultingPoll = generatePoll(poll); return { cid: cid ?? 'messaging:1708683907031', - created_at: createdAt ?? new Date(), + created_at: convertDateToTimestamp(createdAt), custom: {}, poll: resultingPoll, poll_vote: { - created_at: new Date(), + created_at: convertDateToTimestamp(), id: '4c552daf-8f72-409c-a2ee-313b9db9fcd0', option_id: resultingPoll.options[0].id, poll_id: resultingPoll.id, - updated_at: new Date(), + updated_at: convertDateToTimestamp(), user: user1, user_id: user1.id, ...pollVote, @@ -203,22 +204,22 @@ export const generatePollVoteRemovedEvent = ({ pollVote, }: { cid?: string; - createdAt?: Date; + createdAt?: Date | number | string; poll?: DeepPartial; pollVote?: Partial; } = {}) => { const resultingPoll = generatePoll(poll); return { cid: cid ?? 'messaging:1708683907031', - created_at: createdAt ?? new Date(), + created_at: convertDateToTimestamp(createdAt), custom: {}, poll: resultingPoll, poll_vote: { - created_at: new Date(), + created_at: convertDateToTimestamp(), id: '4c552daf-8f72-409c-a2ee-313b9db9fcd0', option_id: resultingPoll.options[0].id, poll_id: resultingPoll.id, - updated_at: new Date(), + updated_at: convertDateToTimestamp(), user: user1, user_id: user1.id, ...pollVote, diff --git a/src/mock-builders/generator/reaction.ts b/src/mock-builders/generator/reaction.ts index 82411439cd..f8ab67c99f 100644 --- a/src/mock-builders/generator/reaction.ts +++ b/src/mock-builders/generator/reaction.ts @@ -1,10 +1,11 @@ import type { ReactionResponse } from 'stream-chat'; import { generateUser } from './user'; +import { convertDateToTimestamp } from './time'; export const generateReaction = (options: Partial = {}) => { const user = options.user || generateUser(); return { - created_at: new Date(), + created_at: convertDateToTimestamp(), type: 'love', user, user_id: user.id, @@ -32,10 +33,10 @@ export const countReactions = (reactions: ReactionResponse[] = []) => { }; export const groupReactions = (reactions: ReactionResponse[] = []) => { - const timestamp = new Date().toISOString(); + const timestamp = convertDateToTimestamp(); const reactionGroups: Record< string, - { count: number; first_reaction_at: string; last_reaction_at: string } + { count: number; first_reaction_at: number; last_reaction_at: number } > = {}; for (const reaction of reactions) { reactionGroups[reaction.type] ??= { diff --git a/src/mock-builders/generator/reminder.ts b/src/mock-builders/generator/reminder.ts index 261663124e..164940069d 100644 --- a/src/mock-builders/generator/reminder.ts +++ b/src/mock-builders/generator/reminder.ts @@ -1,6 +1,8 @@ import { fromPartial } from '@total-typescript/shoehorn'; import type { MessageResponse, ReminderResponseData, UserResponse } from 'stream-chat'; import { generateChannel } from './channel'; +import { convertDateToTimestamp } from './time'; +import { msToNs } from 'stream-chat'; const baseData = { channel_cid: 'messaging:id', @@ -15,7 +17,7 @@ export const generateReminderResponse = ({ data?: Partial; scheduleOffsetMs?: number; } = {}): ReminderResponseData => { - const created_at = new Date(); + const created_at = convertDateToTimestamp(); const basePayload: ReminderResponseData = { ...baseData, channel: generateChannel({ channel: { cid: baseData.channel_cid } }).channel, @@ -25,7 +27,7 @@ export const generateReminderResponse = ({ user: fromPartial({ id: baseData.user_id }), }; if (typeof scheduleOffsetMs === 'number') { - basePayload.remind_at = new Date(created_at.getTime() + scheduleOffsetMs); + basePayload.remind_at = created_at + msToNs(scheduleOffsetMs); } return { ...basePayload, diff --git a/src/mock-builders/generator/sharedLocation.ts b/src/mock-builders/generator/sharedLocation.ts index cf1dd67da5..4cf46c9476 100644 --- a/src/mock-builders/generator/sharedLocation.ts +++ b/src/mock-builders/generator/sharedLocation.ts @@ -1,15 +1,16 @@ import type { SharedLiveLocationResponse, SharedLocationResponseData } from 'stream-chat'; +import { convertDateToTimestamp } from './time'; export const generateStaticLocationResponse = ( data: Partial, ): SharedLocationResponseData => ({ channel_cid: 'channel_cid', - created_at: new Date('1970-01-01T00:00:00.000Z'), + created_at: convertDateToTimestamp('1970-01-01T00:00:00.000Z'), created_by_device_id: 'created_by_device_id', latitude: 1, longitude: 1, message_id: 'message_id', - updated_at: new Date('1970-01-01T00:00:00.000Z'), + updated_at: convertDateToTimestamp('1970-01-01T00:00:00.000Z'), user_id: 'user_id', ...data, }); @@ -18,13 +19,13 @@ export const generateLiveLocationResponse = ( data: Partial, ): SharedLiveLocationResponse => ({ channel_cid: 'channel_cid', - created_at: new Date('1970-01-01T00:00:00.000Z'), + created_at: convertDateToTimestamp('1970-01-01T00:00:00.000Z'), created_by_device_id: 'created_by_device_id', - end_at: new Date('9999-01-01T00:00:00.000Z'), + end_at: convertDateToTimestamp('9999-01-01T00:00:00.000Z'), latitude: 1, longitude: 1, message_id: 'message_id', - updated_at: new Date('1970-01-01T00:00:00.000Z'), + updated_at: convertDateToTimestamp('1970-01-01T00:00:00.000Z'), user_id: 'user_id', ...data, }); diff --git a/src/mock-builders/generator/time.ts b/src/mock-builders/generator/time.ts new file mode 100644 index 0000000000..3a65147631 --- /dev/null +++ b/src/mock-builders/generator/time.ts @@ -0,0 +1,18 @@ +import { dateToNs, msToNs, nowNs } from 'stream-chat'; + +/** + * Normalizes whatever a test hands a generator into the unix-**nanosecond** number the API puts on + * the wire. + * + * Fixtures have to model the wire — a generator that emits `Date` objects or ISO strings cannot + * catch the bugs that unit exists to prevent — but a test reads far better written against a date + * literal. So the generators accept `Date`, an ISO string, or a raw wire number and convert here. + * + * A bare `number` is taken to be nanoseconds already, matching the SDK's unit everywhere else. + */ +export const convertDateToTimestamp = (value?: Date | number | string): number => { + if (value === undefined) return nowNs(); + if (value instanceof Date) return dateToNs(value); + if (typeof value === 'number') return value; + return msToNs(Date.parse(value)); +}; diff --git a/src/mock-builders/generator/user.ts b/src/mock-builders/generator/user.ts index 12b7fbcc78..c898038016 100644 --- a/src/mock-builders/generator/user.ts +++ b/src/mock-builders/generator/user.ts @@ -1,15 +1,16 @@ import { nanoid } from 'nanoid'; import type { UserResponse } from 'stream-chat'; +import { convertDateToTimestamp } from './time'; export const generateUser = (options: Partial = {}) => ({ banned: false, - created_at: '2020-04-27T13:39:49.331742Z', + created_at: convertDateToTimestamp('2020-04-27T13:39:49.331742Z'), id: nanoid(), image: nanoid(), name: nanoid(), online: false, role: 'user', - updated_at: '2020-04-27T13:39:49.332087Z', + updated_at: convertDateToTimestamp('2020-04-27T13:39:49.332087Z'), ...options, }) as UserResponse; diff --git a/src/plugins/ChannelDetail/Views/ChannelFilesView/ChannelFilesView.utils.ts b/src/plugins/ChannelDetail/Views/ChannelFilesView/ChannelFilesView.utils.ts index 1102e5593b..9fbb175549 100644 --- a/src/plugins/ChannelDetail/Views/ChannelFilesView/ChannelFilesView.utils.ts +++ b/src/plugins/ChannelDetail/Views/ChannelFilesView/ChannelFilesView.utils.ts @@ -5,7 +5,7 @@ import { type MessageResponse, } from 'stream-chat'; -import { isDate } from '../../../../i18n/utils'; +import { nsToDate } from 'stream-chat'; /** Attachment types listed by the files view (everything that is not an image/video). */ export const FILE_ATTACHMENT_TYPES = ['file', 'audio'] as const; @@ -42,10 +42,13 @@ export type ChannelFileSections = { sections: ChannelFileSection[]; }; -const normalizeTimestamp = (timestamp?: string | Date) => { - if (!timestamp) return undefined; - return isDate(timestamp) ? timestamp.toISOString() : timestamp; -}; +/** + * A wire timestamp as an ISO string — what `getDateString` accepts, what the month key slices, and + * what `byCreatedAtDesc` compares. `nsToDate` rather than `new Date`: a nanosecond value is out of + * Date's range, so constructing one directly yields an Invalid Date. + */ +const normalizeTimestamp = (timestamp?: number) => + timestamp == null ? undefined : nsToDate(timestamp).toISOString(); const isChannelFileAttachment = (attachment: Attachment) => !isScrapedContent(attachment) && diff --git a/src/plugins/ChannelDetail/Views/ChannelFilesView/__tests__/ChannelFilesView.test.tsx b/src/plugins/ChannelDetail/Views/ChannelFilesView/__tests__/ChannelFilesView.test.tsx index b6ce97daaa..94b9e7f072 100644 --- a/src/plugins/ChannelDetail/Views/ChannelFilesView/__tests__/ChannelFilesView.test.tsx +++ b/src/plugins/ChannelDetail/Views/ChannelFilesView/__tests__/ChannelFilesView.test.tsx @@ -13,6 +13,7 @@ import { useStateStore } from '../../../../../store'; import { ChannelDetailProvider } from '../../../ChannelDetailContext'; import { ChannelFilesView } from '../ChannelFilesView'; import { mockT } from '../../../../../mock-builders/translator'; +import { convertDateToTimestamp } from '../../../../../mock-builders/generator/time'; const mocks = vi.hoisted(() => ({ searchSourceActivate: vi.fn(), @@ -134,7 +135,7 @@ const messages: MessageResponse[] = [ }, ], cid: 'messaging:test-channel', - created_at: '2026-03-10T15:53:00.000Z', + created_at: convertDateToTimestamp('2026-03-10T15:53:00.000Z'), id: 'message-1', type: 'regular', updated_at: '2026-03-10T15:53:00.000Z', @@ -150,7 +151,7 @@ const messages: MessageResponse[] = [ }, ], cid: 'messaging:test-channel', - created_at: '2026-02-05T15:53:00.000Z', + created_at: convertDateToTimestamp('2026-02-05T15:53:00.000Z'), id: 'message-2', type: 'regular', updated_at: '2026-02-05T15:53:00.000Z', @@ -169,7 +170,7 @@ const messages: MessageResponse[] = [ }, ], cid: 'messaging:test-channel', - created_at: '2026-02-01T15:53:00.000Z', + created_at: convertDateToTimestamp('2026-02-01T15:53:00.000Z'), id: 'message-3', type: 'regular', updated_at: '2026-02-01T15:53:00.000Z', diff --git a/src/plugins/ChannelDetail/Views/ChannelMediaView/ChannelMediaView.utils.ts b/src/plugins/ChannelDetail/Views/ChannelMediaView/ChannelMediaView.utils.ts index 3859c3597d..e7744463a3 100644 --- a/src/plugins/ChannelDetail/Views/ChannelMediaView/ChannelMediaView.utils.ts +++ b/src/plugins/ChannelDetail/Views/ChannelMediaView/ChannelMediaView.utils.ts @@ -1,5 +1,6 @@ import { type Attachment, + convertTimestampToDate, isImageAttachment, isVideoAttachment, type LocalMessage, @@ -68,7 +69,7 @@ export const toChannelMediaItems = ( galleryItem: { ...descriptor, // the gallery header reads sender and timestamp off the item - createdAt: message.created_at, + createdAt: convertTimestampToDate(message.created_at), user: message.user ?? undefined, }, id: `${message.id}-${index}`, diff --git a/src/plugins/ChannelDetail/Views/ChannelMemberDetailView/ChannelMemberDetail.tsx b/src/plugins/ChannelDetail/Views/ChannelMemberDetailView/ChannelMemberDetail.tsx index a0a98562c3..44320ff947 100644 --- a/src/plugins/ChannelDetail/Views/ChannelMemberDetailView/ChannelMemberDetail.tsx +++ b/src/plugins/ChannelDetail/Views/ChannelMemberDetailView/ChannelMemberDetail.tsx @@ -1,3 +1,4 @@ +import { convertTimestampToDate } from 'stream-chat'; import React, { useMemo } from 'react'; import type { ChannelMemberResponse } from 'stream-chat'; @@ -41,7 +42,7 @@ const getPresenceStatusText = ( 'Last seen {{ timestamp }}', { timestamp: t('timestamp.ChannelMembersLastActive', { - timestamp: user.last_active, + timestamp: convertTimestampToDate(user.last_active), }), }, ); diff --git a/src/plugins/ChannelDetail/Views/ChannelMembersView/ChannelMembersBrowseView.tsx b/src/plugins/ChannelDetail/Views/ChannelMembersView/ChannelMembersBrowseView.tsx index 429da89c06..b01e4c5544 100644 --- a/src/plugins/ChannelDetail/Views/ChannelMembersView/ChannelMembersBrowseView.tsx +++ b/src/plugins/ChannelDetail/Views/ChannelMembersView/ChannelMembersBrowseView.tsx @@ -1,3 +1,4 @@ +import { convertTimestampToDate } from 'stream-chat'; import type { ChannelMemberResponse } from 'stream-chat'; import React, { useCallback, useMemo } from 'react'; @@ -44,7 +45,7 @@ const getPresenceStatusText = ( 'Last seen {{ timestamp }}', { timestamp: t('timestamp.ChannelMembersLastActive', { - timestamp: user.last_active, + timestamp: convertTimestampToDate(user.last_active), }), }, ); diff --git a/src/plugins/ChannelDetail/Views/PinnedMessagesView/PinnedMessagesView.tsx b/src/plugins/ChannelDetail/Views/PinnedMessagesView/PinnedMessagesView.tsx index 91967df6df..2e3ddecee3 100644 --- a/src/plugins/ChannelDetail/Views/PinnedMessagesView/PinnedMessagesView.tsx +++ b/src/plugins/ChannelDetail/Views/PinnedMessagesView/PinnedMessagesView.tsx @@ -7,7 +7,7 @@ import { useTranslationContext, } from '../../../../context'; import { useChatViewNavigation } from '../../../SlotLayout'; -import { getDateString, isDate } from '../../../../i18n/utils'; +import { getDateString } from '../../../../i18n/utils'; import { Avatar as DefaultAvatar } from '../../../../components/Avatar'; import { extractDisplayInfo as defaultExtractDisplayInfo } from '../../../../components/Avatar/utils'; import { ListItemLayout } from '../../../../components/ListItemLayout'; @@ -24,15 +24,18 @@ import { PinnedMessagesEmptyList } from './PinnedMessagesEmptyList'; import { usePinnedMessagesSearch } from './usePinnedMessagesSearch'; import { useChannelDetailContext } from '../../ChannelDetailContext'; import { ChannelDetailEmptyList } from '../../ChannelDetailEmptyList'; +import { nsToDate } from 'stream-chat'; type PinnedMessage = MessageResponse | LocalMessage; const computeItemKey = (_: number, message: PinnedMessage) => message.id; -const normalizeTimestamp = (timestamp: PinnedMessage['created_at']) => { - if (!timestamp) return undefined; - return isDate(timestamp) ? timestamp.toISOString() : timestamp; -}; +/** + * A wire timestamp as an ISO string, for `getDateString` and the `dateTime` attribute. `nsToDate` + * rather than `new Date`: a nanosecond value is out of Date's range. + */ +const normalizeTimestamp = (timestamp: PinnedMessage['created_at']) => + timestamp == null ? undefined : nsToDate(timestamp).toISOString(); const getPinnedMessagePreview = ( message: PinnedMessage, From 1f4200db03ee6c0503df66c4167238839831dfe8 Mon Sep 17 00:00:00 2001 From: Zita Szupera Date: Tue, 1 Sep 2026 09:26:29 +0200 Subject: [PATCH 2/8] docs: update docs --- ai-docs/ai-migration-v14-v15.md | 59 +++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/ai-docs/ai-migration-v14-v15.md b/ai-docs/ai-migration-v14-v15.md index 1d4c450b80..342552ee04 100644 --- a/ai-docs/ai-migration-v14-v15.md +++ b/ai-docs/ai-migration-v14-v15.md @@ -65,6 +65,65 @@ To ingest an ad-hoc channel (e.g. navigating to a DM or search result) into the `Channel` no longer reflects the channel-list query state. Its loading / error / empty rendering is driven by the channel's own `watch()` bootstrap (`LoadingIndicator` while watching, `LoadingErrorIndicator` on watch failure, `EmptyPlaceholder` when no channel is provided). The channel-list query state is the `ChannelList`'s concern, not `Channel`'s. +## Dates on response types are unix-nanosecond numbers + +`stream-chat` now types every **server-sent** date as the unix-nanosecond `number` the API puts on the +wire — `created_at`, `updated_at`, `last_read`, and every sibling on a response or event. It is not a +`Date` and not an ISO string, and the React types that carry those values through changed with it. + +Two failure modes are silent, because neither is a type error: + +- `new Date(ns)` is **out of range**. `Date` tops out near 8.64e15 ms while a current timestamp is + ~1.79e18, so you get an `Invalid Date` — and `.toISOString()` on one throws + `RangeError: Invalid time value`, usually mid-render. +- Date libraries read a bare number as **milliseconds**, so `dayjs(created_at)` renders a date roughly + 50,000 years out without complaining. + +### The three public React types that changed + +| Type | v14 | v15 | +| ----------------------------------------------------- | ----------------------------- | ------------------------------- | +| `ChatContextValue.latestMessageDatesByChannels` | `Record` | `Record` | +| `ProcessMessagesParams.lastRead` (`processMessages`) | `Date \| null` | `number \| null` | +| `VirtualizedMessageList` render props: `lastReadDate` | `Date \| null` | `number \| null` | + +Comparisons get simpler, not harder — compare and sort the raw numbers and drop the `Date` round-trip: + +```ts +// v14 +if (latestMessageDatesByChannels[cid].getTime() < new Date(message.created_at).getTime()) { … } + +// v15 +if (latestMessageDatesByChannels[cid] < message.created_at) { … } +``` + +### Presentational props still take `Date` + +The conversion boundary is where core data enters the component tree, so components that exist to +_render_ a date are unchanged — `DateSeparator`'s `date: Date` and `formatDate?: (date: Date) => string`, +for instance. Convert at that boundary with the guarded helper `stream-chat` exports: + +```ts +import { convertTimestampToDate } from 'stream-chat'; + +// `undefined` for an absent or non-finite value, so an optional timestamp renders nothing +// instead of throwing RangeError. + +``` + +`nsToDate` / `dateToNs` / `nsToMs` / `msToNs` / `nowNs` are exported alongside it for values known to be +present. Note that **outgoing request** date fields are still `Date` (filter bounds like +`created_at_before`, plus `remind_at` and `message_timestamp`) — `JSON.stringify` emits RFC3339 for a +`Date`, which is what the request spec declares. Use `nsToDate` when handing a server-sent timestamp +back to the API. + +### Test fixtures have to model the wire + +A fixture that hands the SDK a `Date` cannot catch either failure mode above, and will diverge from +runtime behavior. The SDK's own suite normalizes through +`mock-builders/generator/time.ts` (`convertDateToTimestamp`), which accepts a `Date`, an ISO string or a +raw wire number so tests stay readable while the value on the wire stays a number. + ## i18n: English-only bundle, namespaced translation keys Two breaking changes, both of which fail **silently** — no error, no compile break unless the app From c40626f4883ca90d510f3ec609622888da3afa11 Mon Sep 17 00:00:00 2001 From: Zita Szupera Date: Tue, 1 Sep 2026 16:43:09 +0200 Subject: [PATCH 3/8] fix: review fixes --- ai-docs/ai-migration-v14-v15.md | 33 +++++- .../vite/docs-playwright/screenshot-misc.ts | 3 +- .../screenshot-system-message.ts | 2 +- .../websocketEventAutomation.ts | 23 ++-- .../websocketEventTemplates.ts | 58 +++++----- .../tabs/Reactions/reactionsExampleData.ts | 17 +-- .../ChatLayout/ChannelMembersRemoveView.tsx | 3 +- .../vite/src/CustomMessageUi/variants.tsx | 5 +- .../Message/__tests__/utils.test.ts | 94 +++++++++++++++ .../Message/hooks/useReactionHandler.ts | 6 +- src/components/Message/utils.tsx | 9 +- .../MessageList/__tests__/utils.test.ts | 107 +++++++++++++++++- src/components/MessageList/utils.ts | 60 +++++----- src/components/Thread/ThreadHead.tsx | 5 +- src/mock-builders/generator/message.ts | 1 + .../__tests__/ChannelMediaView.test.tsx | 13 ++- .../__tests__/PinnedMessagesView.test.tsx | 25 +++- 17 files changed, 358 insertions(+), 106 deletions(-) diff --git a/ai-docs/ai-migration-v14-v15.md b/ai-docs/ai-migration-v14-v15.md index 342552ee04..328749e6e8 100644 --- a/ai-docs/ai-migration-v14-v15.md +++ b/ai-docs/ai-migration-v14-v15.md @@ -103,12 +103,26 @@ The conversion boundary is where core data enters the component tree, so compone _render_ a date are unchanged — `DateSeparator`'s `date: Date` and `formatDate?: (date: Date) => string`, for instance. Convert at that boundary with the guarded helper `stream-chat` exports: +`convertTimestampToDate` returns `Date | undefined` — `undefined` for an absent or non-finite value. +**Handle that `undefined`; do not cast it away.** A prop typed `date: Date` will accept it through a +cast and then fail somewhere further along: `DateSeparator`'s own `isDate(date)` type guard rejects it, +so the list stops recognising the object as a separator and renders it as an ordinary message — an +empty row where the day divider belonged, with no error and no type error. + ```ts import { convertTimestampToDate } from 'stream-chat'; -// `undefined` for an absent or non-finite value, so an optional timestamp renders nothing -// instead of throwing RangeError. - +const createdAt = convertTimestampToDate(message.created_at); + +// Render nothing when there is no usable timestamp. +{createdAt ? : null} +``` + +```ts +// WRONG — the cast launders `undefined` into a required `Date`. + +// WRONG — invents "now", labelling a months-old message "Today". + ``` `nsToDate` / `dateToNs` / `nsToMs` / `msToNs` / `nowNs` are exported alongside it for values known to be @@ -117,6 +131,19 @@ present. Note that **outgoing request** date fields are still `Date` (filter bou `Date`, which is what the request spec declares. Use `nsToDate` when handing a server-sent timestamp back to the API. +### `MessageList`'s `headerPosition` prop changed unit, not type + +`headerPosition` is compared against `message.created_at`, so it is now **unix nanoseconds** — it was +epoch milliseconds while `created_at` was a `Date`. The type is still `number`, so nothing warns. + +### Peer-dependency gate before release + +The SDK imports `convertTimestampToDate` / `nsToDate` / `nsToMs` from `stream-chat`, which only exist +from the version that ships `utils/time`. Until that is published, `package.json` pins +`stream-chat` exactly and the workspace resolves it through a local `portal:` — so a green local build +says nothing about whether a consumer can resolve these imports. Before publishing, widen the peer +range to the version that exports them and verify from a clean install with no `portal:` override. + ### Test fixtures have to model the wire A fixture that hands the SDK a `Date` cannot catch either failure mode above, and will diverge from diff --git a/examples/vite/docs-playwright/screenshot-misc.ts b/examples/vite/docs-playwright/screenshot-misc.ts index eb7f3124c3..223651439d 100644 --- a/examples/vite/docs-playwright/screenshot-misc.ts +++ b/examples/vite/docs-playwright/screenshot-misc.ts @@ -236,7 +236,8 @@ async function captureCustomNotification(browser: any) { cid: ch.cid, channel_id: ch.id, channel_type: ch.type, - message: { ...msg, text: msg.text, message_text_updated_at: new Date().toISOString() }, + // Unix nanoseconds, as the wire carries it; no imports are available in page.evaluate. + message: { ...msg, text: msg.text, message_text_updated_at: Date.now() * 1e6 }, }); } })()`); diff --git a/examples/vite/docs-playwright/screenshot-system-message.ts b/examples/vite/docs-playwright/screenshot-system-message.ts index 95d051d14d..bf62dae5e0 100644 --- a/examples/vite/docs-playwright/screenshot-system-message.ts +++ b/examples/vite/docs-playwright/screenshot-system-message.ts @@ -126,7 +126,7 @@ async function main() { const injectSystemMessage = `(async () => { var ch = window.channel; var client = window.client; - var now = new Date().toISOString(); + var now = Date.now() * 1e6; // server-sent dates are unix nanoseconds var msg = { id: 'system-msg-' + Date.now(), text: '/mute @${USER_B}', diff --git a/examples/vite/src/AppSettings/ActionsMenu/WebSocketEventPromptDialog/websocketEventAutomation.ts b/examples/vite/src/AppSettings/ActionsMenu/WebSocketEventPromptDialog/websocketEventAutomation.ts index 3356f31d87..95c18482d1 100644 --- a/examples/vite/src/AppSettings/ActionsMenu/WebSocketEventPromptDialog/websocketEventAutomation.ts +++ b/examples/vite/src/AppSettings/ActionsMenu/WebSocketEventPromptDialog/websocketEventAutomation.ts @@ -1,3 +1,4 @@ +import { nowNs } from 'stream-chat'; import type { Channel, ChannelMemberResponse, @@ -97,9 +98,7 @@ const buildReactionState = ({ typeof reaction.score === 'number' && Number.isFinite(reaction.score) ? reaction.score : 1; - const reactionTimestamp = reaction.created_at - ? new Date(reaction.created_at) - : new Date(); + const reactionTimestamp = reaction.created_at ?? nowNs(); return { latest_reactions: [reaction], @@ -160,7 +159,7 @@ const buildFreshContext = ( simulationState: SimulationState, ): WebSocketEventTemplateContext => { const sequence = simulationState.nextSequence; - const createdAt = new Date().toISOString(); + const createdAt = nowNs(); const channelMembers = getChannelMembersForCid( templateContext.cid, simulationState, @@ -389,12 +388,12 @@ export const buildFreshWebSocketEventPayload = ({ created_at: freshContext.createdAt, message: { ...baseMessage, - created_at: new Date(freshContext.createdAt), + created_at: freshContext.createdAt, html: `

${text}

\n`, id: messageId, member, text, - updated_at: new Date(freshContext.createdAt), + updated_at: freshContext.createdAt, user, }, message_id: messageId, @@ -412,14 +411,14 @@ export const buildFreshWebSocketEventPayload = ({ const reactionScore = eventType === 'reaction.updated' ? 2 : 1; const reaction = { ...baseReaction, - // `dispatchEvent` receives an already-parsed `Event`, so timestamps are `Date`s here - // (only the raw wire format uses ISO strings). - created_at: new Date(freshContext.createdAt), + // Server-sent dates are unix-nanosecond numbers everywhere now — on the raw wire frame and + // on the parsed `Event` that `dispatchEvent` receives alike. + created_at: freshContext.createdAt, // v10 requires `custom` on reaction responses. custom: {}, message_id: messageId, type: reactionType, - updated_at: new Date(freshContext.createdAt), + updated_at: freshContext.createdAt, user, user_id: user.id, score: reactionScore, @@ -437,7 +436,7 @@ export const buildFreshWebSocketEventPayload = ({ ...baseMessage, id: messageId, member, - updated_at: new Date(freshContext.createdAt), + updated_at: freshContext.createdAt, user, ...buildReactionState({ reaction }), }, @@ -472,7 +471,7 @@ export const buildFreshWebSocketEventPayload = ({ ...baseMessage, id: messageId, member, - updated_at: new Date(freshContext.createdAt), + updated_at: freshContext.createdAt, user, }, user, diff --git a/examples/vite/src/AppSettings/ActionsMenu/WebSocketEventPromptDialog/websocketEventTemplates.ts b/examples/vite/src/AppSettings/ActionsMenu/WebSocketEventPromptDialog/websocketEventTemplates.ts index b02beae21c..1a86e71246 100644 --- a/examples/vite/src/AppSettings/ActionsMenu/WebSocketEventPromptDialog/websocketEventTemplates.ts +++ b/examples/vite/src/AppSettings/ActionsMenu/WebSocketEventPromptDialog/websocketEventTemplates.ts @@ -1,3 +1,4 @@ +import { msToNs, nowNs } from 'stream-chat'; import type { Channel, ChannelMemberResponse, @@ -101,8 +102,10 @@ export type WebSocketEventTemplateContext = { channelName: string; channelType: string; cid: string; - createdAt: string; - lastReadAt: string; + /** Unix nanoseconds, the unit every server-sent date uses on the wire. */ + createdAt: number; + /** Unix nanoseconds, the unit every server-sent date uses on the wire. */ + lastReadAt: number; memberCount: number; messageId: string; otherMember: ChannelMemberResponse; @@ -120,7 +123,7 @@ type BuildChannelSeedContext = Omit & channel: Partial; }; -const createFallbackUser = (id: string, createdAt: Date): DebugUserResponse => ({ +const createFallbackUser = (id: string, createdAt: number): DebugUserResponse => ({ banned: false, blocked_user_ids: [], created_at: createdAt, @@ -141,9 +144,9 @@ const getUserId = (user: DebugUserResponse) => typeof user.id === 'string' ? user.id : 'debug-user'; const createMember = (user: DebugUserResponse): ChannelMemberResponse => { - // `user.created_at` is typed as `Date` in v10, but this builder also receives raw event/JSON - // payloads where it may still be a string — normalize either form to a `Date`. - const createdAt = user.created_at ? new Date(user.created_at) : new Date(); + // Every date on a response type is already a unix-nanosecond number, so there is nothing to + // normalize — only a fallback for the builders that hand over a user with no timestamps. + const createdAt = user.created_at ?? nowNs(); return { banned: false, @@ -178,9 +181,9 @@ const buildChannel = ( context: BuildChannelSeedContext, overrides: JsonObject = {}, ): DebugChannelResponse => { - // `context.createdAt` stays an ISO string (event payloads carry strings), but the - // `ChannelResponse`/config timestamps below are typed as `Date` in v10. - const createdAt = new Date(context.createdAt); + // Wire timestamps all the way through: the event payload and the `ChannelResponse`/config + // fields below all carry the same unix-nanosecond number. + const createdAt = context.createdAt; return { cid: context.cid, @@ -228,6 +231,8 @@ const buildChannel = ( delivery_events: true, mark_messages_pending: false, max_message_length: 5000, + // Required on `ChannelConfigWithInfo`; the date error above used to mask its absence. + message_retention: 'infinite', mutes: true, name: context.channelType, polls: true, @@ -425,7 +430,7 @@ const buildReactionState = ({ latestReactions: JsonObject[]; reactionType: string; score: number; - timestamp: string; + timestamp: number; }): JsonObject => ({ latest_reactions: latestReactions, reaction_counts: { @@ -564,7 +569,7 @@ const buildPollWithAnswerComment = ( context: WebSocketEventTemplateContext, answerText: string, ) => { - const answerCreatedAt = new Date(Date.now() - 60_000).toISOString(); + const answerCreatedAt = nowNs() - msToNs(60_000); const pollVote = buildPollAnswerVote(context, answerText, { created_at: answerCreatedAt, updated_at: context.createdAt, @@ -768,14 +773,13 @@ export const createWebSocketEventTemplateContext = ({ channel?: Channel; client: StreamChat; }): WebSocketEventTemplateContext => { - // Kept as an ISO string on the context (event payloads carry string timestamps), with the `Date` - // form on hand for the response-shaped builders that v10 types as `Date`. - const createdAtDate = new Date(); - const createdAt = createdAtDate.toISOString(); + // One unit for the whole context: unix nanoseconds, which is what event payloads and the + // response-shaped builders both carry now that the SDK does no date decoding. + const createdAt = nowNs(); const actorUser = client.user && typeof client.user === 'object' ? ({ ...client.user } as DebugUserResponse) - : createFallbackUser('debug-user', createdAtDate); + : createFallbackUser('debug-user', createdAt); const actorId = typeof actorUser.id === 'string' ? actorUser.id : 'debug-user'; const members = channel ? Object.values(channel.state.members) : []; @@ -792,7 +796,7 @@ export const createWebSocketEventTemplateContext = ({ const otherUser = otherMemberFromChannel?.user && typeof otherMemberFromChannel.user === 'object' ? ({ ...otherMemberFromChannel.user } as DebugUserResponse) - : createFallbackUser('debug-other-user', createdAtDate); + : createFallbackUser('debug-other-user', createdAt); const otherMember = otherMemberFromChannel ? ({ ...otherMemberFromChannel } as ChannelMemberResponse) : createMember(otherUser); @@ -1011,7 +1015,7 @@ export const websocketEventTemplateDefinitions = { buildBaseEvent(context, 'message.delivered', { channel_custom: { name: context.channelName }, channel_member_count: context.memberCount, - last_delivered_at: context.createdAt.replace(/\.\d+Z$/, 'Z'), + last_delivered_at: context.createdAt, last_delivered_message_id: context.messageId, user: context.otherUser, }), @@ -1227,7 +1231,7 @@ export const websocketEventTemplateDefinitions = { }, 'poll.vote_changed': { buildDefault: (context) => { - const originalCreatedAt = new Date(Date.now() - 60_000).toISOString(); + const originalCreatedAt = nowNs() - msToNs(60_000); const answerText = 'Some new comment X'; const pollVote = buildPollAnswerVote(context, answerText, { created_at: originalCreatedAt, @@ -1397,10 +1401,7 @@ export const websocketEventTemplateDefinitions = { 'typing.start': { buildDefault: (context) => buildBaseEvent(context, 'typing.start', { - channel_last_message_at: - typeof context.channel.last_message_at === 'string' - ? context.channel.last_message_at - : context.createdAt, + channel_last_message_at: context.channel.last_message_at ?? context.createdAt, user: context.actor, }), description: 'Start typing in the active channel.', @@ -1408,10 +1409,7 @@ export const websocketEventTemplateDefinitions = { 'typing.stop': { buildDefault: (context) => buildBaseEvent(context, 'typing.stop', { - channel_last_message_at: - typeof context.channel.last_message_at === 'string' - ? context.channel.last_message_at - : context.createdAt, + channel_last_message_at: context.channel.last_message_at ?? context.createdAt, user: context.actor, }), description: 'Stop typing in the active channel.', @@ -1422,7 +1420,7 @@ export const websocketEventTemplateDefinitions = { channel_custom: { name: context.channelName }, channel_member_count: context.memberCount, created_by: context.actor, - expiration: new Date(Date.now() + 60 * 60_000).toISOString(), + expiration: nowNs() + msToNs(60 * 60_000), reason: 'because', user: context.otherUser, }), @@ -1714,7 +1712,7 @@ const websocketEventPresetDefinitions = { created_at: context.createdAt, message_id: context.messageId, reminder: buildReminderPayload(context, { - remind_at: new Date(Date.now() + 2 * 60_000).toISOString(), + remind_at: nowNs() + msToNs(2 * 60_000), }), type: 'reminder.created', user_id: context.actorId, @@ -1722,7 +1720,7 @@ const websocketEventPresetDefinitions = { }, 'reminder.deleted.timed': { buildDefault: (context: WebSocketEventTemplateContext) => { - const remindAt = new Date(Date.now() + 2 * 60_000).toISOString(); + const remindAt = nowNs() + msToNs(2 * 60_000); return { cid: context.cid, diff --git a/examples/vite/src/AppSettings/tabs/Reactions/reactionsExampleData.ts b/examples/vite/src/AppSettings/tabs/Reactions/reactionsExampleData.ts index 51164285c6..be1e4cd33e 100644 --- a/examples/vite/src/AppSettings/tabs/Reactions/reactionsExampleData.ts +++ b/examples/vite/src/AppSettings/tabs/Reactions/reactionsExampleData.ts @@ -1,16 +1,19 @@ +import { dateToNs } from 'stream-chat'; import type { LocalMessage } from 'stream-chat'; -// v10 types reaction/message timestamps as `Date` rather than ISO strings. -const fireReactionAt = new Date('2026-02-12T06:39:57.188362Z'); -const firstLikeReactionAt = new Date('2026-02-12T06:39:56.237389Z'); -const secondLikeReactionAt = new Date('2026-02-12T06:39:52.237389Z'); -const heartReactionAt = new Date('2026-02-12T06:35:58.021196Z'); +// Every server-sent date is the unix-**nanosecond** number the API puts on the wire, so a fixture has +// to model that unit — a `Date` here would render as 1970 once the UI converts it. The literals stay +// readable and go through `dateToNs`. +const fireReactionAt = dateToNs(new Date('2026-02-12T06:39:57.188362Z')); +const firstLikeReactionAt = dateToNs(new Date('2026-02-12T06:39:56.237389Z')); +const secondLikeReactionAt = dateToNs(new Date('2026-02-12T06:39:52.237389Z')); +const heartReactionAt = dateToNs(new Date('2026-02-12T06:35:58.021196Z')); // The generated v10 models require far more fields than a static preview fixture needs // (`MessageResponse` alone mandates cid, html, deleted_reply_count, …), so the literal is asserted // once here rather than padded with a dozen placeholder values. export const reactionsPreviewMessage = { - created_at: new Date('2026-02-12T06:34:40.000000Z'), + created_at: dateToNs(new Date('2026-02-12T06:34:40.000000Z')), id: 'settings-preview-message-id', latest_reactions: [ { @@ -133,7 +136,7 @@ export const reactionsPreviewMessage = { status: 'received', text: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed lectus nibh, rutrum in risus eget, dictum commodo dolor. Donec augue nisi, sollicitudin sed magna ut, tincidunt pretium lorem. ', type: 'regular', - updated_at: new Date('2026-02-12T06:40:00.000000Z'), + updated_at: dateToNs(new Date('2026-02-12T06:40:00.000000Z')), // Only the fields the preview renders are supplied; `UserResponse` requires several more. user: { id: 'settings-preview-user', diff --git a/examples/vite/src/ChatLayout/ChannelMembersRemoveView.tsx b/examples/vite/src/ChatLayout/ChannelMembersRemoveView.tsx index d19d785930..0b7cf8fafc 100644 --- a/examples/vite/src/ChatLayout/ChannelMembersRemoveView.tsx +++ b/examples/vite/src/ChatLayout/ChannelMembersRemoveView.tsx @@ -1,3 +1,4 @@ +import { convertTimestampToDate } from 'stream-chat'; import type { ChannelMemberResponse, UserResponse } from 'stream-chat'; import { useMemo, useState } from 'react'; import { @@ -47,7 +48,7 @@ const getPresenceStatusText = ( 'Last seen {{ timestamp }}', { timestamp: t('timestamp.ChannelMembersLastActive', { - timestamp: user.last_active, + timestamp: convertTimestampToDate(user.last_active), }), }, ); diff --git a/examples/vite/src/CustomMessageUi/variants.tsx b/examples/vite/src/CustomMessageUi/variants.tsx index 5c1ef4d68a..daa529ad91 100644 --- a/examples/vite/src/CustomMessageUi/variants.tsx +++ b/examples/vite/src/CustomMessageUi/variants.tsx @@ -1,4 +1,5 @@ import { useMemo } from 'react'; +import { convertTimestampToDate } from 'stream-chat'; import type { LocalMessage, UserResponse } from 'stream-chat'; import { Avatar, @@ -175,7 +176,7 @@ const CustomMessageUiMetadata = ({ return (
- {createdAt?.toLocaleString()} + {convertTimestampToDate(createdAt)?.toLocaleString()}
{statusIconMap[status]} @@ -183,7 +184,7 @@ const CustomMessageUiMetadata = ({ {messageTextUpdatedAt && (
Edited
diff --git a/src/components/Message/__tests__/utils.test.ts b/src/components/Message/__tests__/utils.test.ts index 470bf4bb58..8ef538bf81 100644 --- a/src/components/Message/__tests__/utils.test.ts +++ b/src/components/Message/__tests__/utils.test.ts @@ -183,6 +183,100 @@ describe('Message utils', () => { expect(shouldUpdate).toBe(false); }); + // `formatMessage` runs only on the write path, so an unchanged message keeps its reference and + // the memo always bailed for it. What changed with numbers: `updated_at` is no longer a fresh + // `Date` per ingest, so it no longer forces a repaint on every re-ingest — which is what makes + // the compared-field list load-bearing. These pin the fields that list has to cover. + describe('memoization with nanosecond timestamps', () => { + it('bails out for an identical message, which a fresh Date used to prevent', () => { + const message = generateMessage({ id: 'm' }); + + expect( + areMessagePropsEqual( + fromPartial({ message: { ...message } }), + fromPartial({ message: { ...message } }), + ), + ).toBe(true); + }); + + it('sees a changed updated_at', () => { + const message = generateMessage({ id: 'm' }); + + expect( + areMessagePropsEqual( + fromPartial({ + message: { ...message, updated_at: (message.updated_at as number) + 1e9 }, + }), + fromPartial({ message }), + ), + ).toBe(false); + }); + + it('sees an attachments swap', () => { + // Compared by reference: the SDK builds a new array for any real change, so this needs no + // deep equality. An upload thumbnail resolving used to leave the row stale. + const message = generateMessage({ id: 'm' }); + + expect( + areMessagePropsEqual( + fromPartial({ + message: { + ...message, + attachments: [{ image_url: 'resolved', type: 'image' }], + }, + }), + fromPartial({ message: { ...message, attachments: [] } }), + ), + ).toBe(false); + }); + + it('sees a reaction change that leaves the reaction list length untouched', () => { + // The case a `length` comparison structurally cannot catch: an `enforce_unique` swap takes + // one reaction out and puts one in, so the count holds and `updated_at` never moves. + const message = generateMessage({ id: 'm' }); + const withReaction = (type: string) => ({ + ...message, + latest_reactions: [{ type, user_id: 'u1' }], + reaction_groups: { [type]: { count: 1, sum_scores: 1 } }, + }); + + expect( + areMessagePropsEqual( + fromPartial({ message: withReaction('like') }), + fromPartial({ message: withReaction('love') }), + ), + ).toBe(false); + }); + + it('sees a shared_location moving', () => { + const message = generateMessage({ id: 'm' }); + const at = (latitude: number) => ({ + ...message, + shared_location: { created_by_device_id: 'd', latitude, longitude: 2 }, + }); + + expect( + areMessagePropsEqual( + fromPartial({ message: at(52.3676) }), + fromPartial({ message: at(48.8566) }), + ), + ).toBe(false); + }); + + it('still bails when nothing changed, so a long list does not repaint wholesale', () => { + // The bail-out that matters: an unchanged message keeps its object identity across renders + // because `processMessages` re-uses the references it is given. + const message = generateMessage({ id: 'm' }); + + expect( + areMessagePropsEqual( + fromPartial({ message }), + fromPartial({ message }), + ), + ).toBe(true); + }); + }); + it('should update if rendered with a different message', () => { const message1 = generateMessage({ id: 'message-1' }); const message2 = generateMessage({ id: 'message-2' }); diff --git a/src/components/Message/hooks/useReactionHandler.ts b/src/components/Message/hooks/useReactionHandler.ts index 0186adb762..5794ca5f7b 100644 --- a/src/components/Message/hooks/useReactionHandler.ts +++ b/src/components/Message/hooks/useReactionHandler.ts @@ -39,7 +39,11 @@ export const useReactionHandler = (message?: LocalMessage) => { const createMessagePreview = useCallback( (add: boolean, reaction: ReactionResponse, message: LocalMessage): LocalMessage => { - const newReactionGroups = message?.reaction_groups || {}; + // Copied, not aliased. The assignments and `delete` below used to write straight into the + // message's own `reaction_groups`, so the optimistic message and the one it was derived from + // shared that object — and no comparator, identity or deep, could see a reaction-group + // change. `areMessagesEqual` compares it by reference. + const newReactionGroups = { ...(message?.reaction_groups ?? {}) }; const reactionType = reaction.type; const hasReaction = !!newReactionGroups[reactionType]; diff --git a/src/components/Message/utils.tsx b/src/components/Message/utils.tsx index 314560385e..5d2481d96f 100644 --- a/src/components/Message/utils.tsx +++ b/src/components/Message/utils.tsx @@ -180,10 +180,15 @@ export const ACTIONS_NOT_WORKING_IN_THREAD = [ ]; function areMessagesEqual(prevMessage: LocalMessage, nextMessage: LocalMessage): boolean { + if (prevMessage === nextMessage) return true; + const areBaseMessagesEqual = (prevMessage: LocalMessage, nextMessage: LocalMessage) => prevMessage.deleted_at === nextMessage.deleted_at && - prevMessage.latest_reactions?.length === nextMessage.latest_reactions?.length && - prevMessage.own_reactions?.length === nextMessage.own_reactions?.length && + prevMessage.attachments === nextMessage.attachments && + prevMessage.latest_reactions === nextMessage.latest_reactions && + prevMessage.own_reactions === nextMessage.own_reactions && + prevMessage.reaction_groups === nextMessage.reaction_groups && + prevMessage.shared_location === nextMessage.shared_location && prevMessage.pinned === nextMessage.pinned && prevMessage.reply_count === nextMessage.reply_count && prevMessage.show_in_channel === nextMessage.show_in_channel && diff --git a/src/components/MessageList/__tests__/utils.test.ts b/src/components/MessageList/__tests__/utils.test.ts index 4e4bc37613..9c7272b3f0 100644 --- a/src/components/MessageList/__tests__/utils.test.ts +++ b/src/components/MessageList/__tests__/utils.test.ts @@ -7,7 +7,13 @@ import { generateUser, } from '../../../mock-builders'; -import { getGroupStyles, makeDateMessageId, processMessages } from '../utils'; +import { + getGroupStyles, + insertIntro, + isDateSeparatorMessage, + makeDateMessageId, + processMessages, +} from '../utils'; import { CUSTOM_MESSAGE_TYPE } from '../../../constants/messageTypes'; import { convertTimestampToDate, msToNs } from 'stream-chat'; @@ -155,21 +161,54 @@ describe('processMessages', () => { ); dateSeparatorInsertedAt(expectedWhere, messages, newMessageList); }); + }); - it('first message contains invalid date', () => { + // These fixtures hold an `Invalid Date`, which the wire normalizer turns into `NaN`. No + // separator can be built for such a message; the valid sibling still gets one. + describe('skipped for a message whose timestamp is unusable', () => { + it('omits the separator for an invalid first message, keeping the second', () => { const { messages, newMessageList } = runMessageProcessing( msgCreationDatesFirstInvalid, enableDateSeparatorParams, ); - dateSeparatorInsertedAt(expectedWhere, messages, newMessageList); + + expect(newMessageList).toHaveLength(messages.length + 1); + expect(isDateSeparatorMessage(newMessageList[0])).toBe(false); + expect(newMessageList[0]).toMatchObject(messages[0]); + expect(isDateSeparatorMessage(newMessageList[1])).toBe(true); + expect(newMessageList[1]).toMatchObject(makeDateSeparator(messages[1])); + expect(newMessageList[2]).toMatchObject(messages[1]); }); - it('second message contains invalid date', () => { + it('omits the separator for an invalid second message, keeping the first', () => { const { messages, newMessageList } = runMessageProcessing( msgCreationDatesSecondInvalid, enableDateSeparatorParams, ); - dateSeparatorInsertedAt(expectedWhere, messages, newMessageList); + + expect(newMessageList).toHaveLength(messages.length + 1); + expect(isDateSeparatorMessage(newMessageList[0])).toBe(true); + expect(newMessageList[0]).toMatchObject(makeDateSeparator(messages[0])); + expect(newMessageList[1]).toMatchObject(messages[0]); + expect(isDateSeparatorMessage(newMessageList[2])).toBe(false); + expect(newMessageList[2]).toMatchObject(messages[1]); + }); + + it('never emits a separator object that is not a valid separator', () => { + for (const fixture of [ + msgCreationDatesFirstInvalid, + msgCreationDatesSecondInvalid, + ]) { + const { newMessageList } = runMessageProcessing( + fixture, + enableDateSeparatorParams, + ); + for (const entry of newMessageList) { + if ((entry as { customType?: string }).customType === 'message.date') { + expect(isDateSeparatorMessage(entry)).toBe(true); + } + } + } }); }); @@ -711,3 +750,61 @@ describe('getGroupStyles', () => { ); }); }); + +describe('insertIntro', () => { + // `headerPosition` is a public prop compared against `message.created_at`, so unix nanoseconds. + const NS_PER_MS = 1e6; + const at = (iso: string) => Date.parse(iso) * NS_PER_MS; + const msg = (iso: string, id: string) => + fromPartial({ created_at: at(iso), id, status: 'received' }); + const isIntro = (entry: unknown) => + (entry as { customType?: string })?.customType === CUSTOM_MESSAGE_TYPE.intro; + + it('puts the intro at the top when no position is given', () => { + const result = insertIntro([msg('2026-01-02T00:00:00Z', 'a')]); + + expect(isIntro(result[0])).toBe(true); + }); + + it('puts the intro at the top for an empty list', () => { + expect(isIntro(insertIntro([])[0])).toBe(true); + }); + + it('treats the epoch as a real position rather than "unset"', () => { + // `0` is falsy, so a truthiness guard would unshift the intro instead. + const result = insertIntro([msg('2026-01-02T00:00:00Z', 'a')], 0); + + expect(isIntro(result[0])).toBe(false); + }); + + it('places the intro after messages older than the position, in nanoseconds', () => { + const messages = [ + msg('2026-01-01T00:00:00Z', 'older'), + msg('2026-01-03T00:00:00Z', 'newer'), + ]; + + const result = insertIntro(messages, at('2026-01-02T00:00:00Z')); + + expect(result.map((m) => (isIntro(m) ? 'intro' : m.id))).toEqual([ + 'older', + 'intro', + 'newer', + ]); + }); + + it('is in nanoseconds, not milliseconds — the unit the migration changed', () => { + const messages = [ + msg('2026-01-01T00:00:00Z', 'older'), + msg('2026-01-03T00:00:00Z', 'newer'), + ]; + // The epoch-millisecond value an integrator would have passed before the migration. + const asMilliseconds = Date.parse('2026-01-02T00:00:00Z'); + + const result = insertIntro([...messages], asMilliseconds); + + expect(result.some(isIntro)).toBe(false); + expect(insertIntro([...messages], asMilliseconds * NS_PER_MS).some(isIntro)).toBe( + true, + ); + }); +}); diff --git a/src/components/MessageList/utils.ts b/src/components/MessageList/utils.ts index 2ecbb34498..6ce8bf7f2b 100644 --- a/src/components/MessageList/utils.ts +++ b/src/components/MessageList/utils.ts @@ -10,7 +10,7 @@ import type { MessageLabel, UnreadSnapshotState, } from 'stream-chat'; -import { convertTimestampToDate, nsToDate, nsToMs } from 'stream-chat'; +import { convertTimestampToDate, nsToMs } from 'stream-chat'; type IntroMessage = { customType: typeof CUSTOM_MESSAGE_TYPE.intro; @@ -112,16 +112,16 @@ export const processMessages = (params: ProcessMessagesParams) => { } const changes: RenderedMessage[] = []; - // Nullish, not truthy: `0` is a legitimate wire timestamp (the epoch), and treating it as - // "no date" collapses the day-grouping key to '' and suppresses the separator. - const messageDate = - message.created_at != null ? nsToDate(message.created_at).toDateString() : ''; + // `undefined` when the timestamp is unusable, so no separator is built for that message. + const messageCreatedAt = convertTimestampToDate(message.created_at); + const messageDate = messageCreatedAt ? messageCreatedAt.toDateString() : ''; const previousMessage = messages[i - 1]; - let prevMessageDate = messageDate; - - if (enableDateSeparator && previousMessage?.created_at != null) { - prevMessageDate = nsToDate(previousMessage.created_at).toDateString(); - } + // `''` when the previous message has no usable timestamp, so the current one still gets a + // separator. + const previousCreatedAt = enableDateSeparator + ? convertTimestampToDate(previousMessage?.created_at) + : undefined; + const prevMessageDate = previousCreatedAt ? previousCreatedAt.toDateString() : ''; if (!unread && !hideNewMessageSeparator) { unread = @@ -131,13 +131,19 @@ export const processMessages = (params: ProcessMessagesParams) => { false; // do not show date separator for current user's messages - if (enableDateSeparator && unread && message.user?.id !== userId) { - changes.push({ + if ( + enableDateSeparator && + unread && + messageCreatedAt && + message.user?.id !== userId + ) { + const separator: DateSeparatorMessage = { customType: CUSTOM_MESSAGE_TYPE.date, - date: convertTimestampToDate(message.created_at), - id: makeDateMessageId(convertTimestampToDate(message.created_at)), + date: messageCreatedAt, + id: makeDateMessageId(messageCreatedAt), unread, - } as DateSeparatorMessage); + }; + changes.push(separator); } } @@ -153,14 +159,12 @@ export const processMessages = (params: ProcessMessagesParams) => { ) { lastDateSeparator = messageDate; - changes.push( - { - customType: CUSTOM_MESSAGE_TYPE.date, - date: convertTimestampToDate(message.created_at), - id: makeDateMessageId(convertTimestampToDate(message.created_at)), - } as DateSeparatorMessage, - message, - ); + const separator: DateSeparatorMessage | undefined = messageCreatedAt && { + customType: CUSTOM_MESSAGE_TYPE.date, + date: messageCreatedAt, + id: makeDateMessageId(messageCreatedAt), + }; + changes.push(...(separator ? [separator] : []), message); } else { changes.push(message); } @@ -215,7 +219,7 @@ export const insertIntro = (messages: RenderedMessage[], headerPosition?: number const intro = makeIntroMessage(); // if no headerPosition is set, HeaderComponent will go at the top - if (!headerPosition) { + if (headerPosition == null) { newMessages.unshift(intro); return newMessages; } @@ -228,12 +232,12 @@ export const insertIntro = (messages: RenderedMessage[], headerPosition?: number // else loop over the messages for (let i = 0; i < messages.length; i += 1) { - const messageTime = (messages[i] as LocalMessage).created_at ?? null; + const messageTime = (messages[i] as LocalMessage).created_at; const nextMessageTime = (messages[i + 1] as LocalMessage)?.created_at ?? null; // header position is smaller than message time so comes after; - if (messageTime && messageTime < headerPosition) { + if (messageTime < headerPosition) { // if header position is also smaller than message time continue; if (nextMessageTime && nextMessageTime < headerPosition) { if (messages[i + 1] && isDateSeparatorMessage(messages[i + 1])) continue; @@ -379,7 +383,9 @@ export const getIsFirstUnreadMessage = ({ const lastReadTimestamp = lastReadAt; const messageIsUnread = - !!createdAtTimestamp && !!lastReadTimestamp && createdAtTimestamp > lastReadTimestamp; + createdAtTimestamp != null && + lastReadTimestamp != null && + createdAtTimestamp > lastReadTimestamp; const previousMessageIsLastRead = !!lastReadMessageId && lastReadMessageId === previousMessage?.id; diff --git a/src/components/Thread/ThreadHead.tsx b/src/components/Thread/ThreadHead.tsx index f978a34167..8e2d60a984 100644 --- a/src/components/Thread/ThreadHead.tsx +++ b/src/components/Thread/ThreadHead.tsx @@ -10,11 +10,10 @@ import { DateSeparator } from '../DateSeparator'; export const ThreadHead = (props: MessageProps) => { const { ThreadStart = DefaultThreadStart } = useComponentContext(); + const parentCreatedAt = convertTimestampToDate(props.message.created_at); return (
- + {parentCreatedAt ? : null}
diff --git a/src/mock-builders/generator/message.ts b/src/mock-builders/generator/message.ts index 8d6f827731..ef12ee81f4 100644 --- a/src/mock-builders/generator/message.ts +++ b/src/mock-builders/generator/message.ts @@ -17,6 +17,7 @@ const TIMESTAMP_FIELDS = [ 'updated_at', 'deleted_at', 'pinned_at', + 'pin_expires', 'message_text_updated_at', ] as const; diff --git a/src/plugins/ChannelDetail/Views/ChannelMediaView/__tests__/ChannelMediaView.test.tsx b/src/plugins/ChannelDetail/Views/ChannelMediaView/__tests__/ChannelMediaView.test.tsx index 15cf994e97..482d6733d5 100644 --- a/src/plugins/ChannelDetail/Views/ChannelMediaView/__tests__/ChannelMediaView.test.tsx +++ b/src/plugins/ChannelDetail/Views/ChannelMediaView/__tests__/ChannelMediaView.test.tsx @@ -1,6 +1,7 @@ import { fireEvent, render, screen } from '@testing-library/react'; import React from 'react'; import type { Channel, MessageResponse } from 'stream-chat'; +import { msToNs } from 'stream-chat'; import { fromPartial } from '@total-typescript/shoehorn'; import { @@ -85,10 +86,10 @@ const messages: MessageResponse[] = [ }, ], cid: 'messaging:test-channel', - created_at: '2026-01-01T15:53:00.000Z', + created_at: msToNs(Date.parse('2026-01-01T15:53:00.000Z')), id: 'message-1', type: 'regular', - updated_at: '2026-01-01T15:53:00.000Z', + updated_at: msToNs(Date.parse('2026-01-01T15:53:00.000Z')), user: { id: 'user-1', image: 'https://cdn.test/avatar-1.png', name: 'Alice' }, }, { @@ -103,10 +104,10 @@ const messages: MessageResponse[] = [ }, ], cid: 'messaging:test-channel', - created_at: '2026-01-02T15:53:00.000Z', + created_at: msToNs(Date.parse('2026-01-02T15:53:00.000Z')), id: 'message-2', type: 'regular', - updated_at: '2026-01-02T15:53:00.000Z', + updated_at: msToNs(Date.parse('2026-01-02T15:53:00.000Z')), user: { id: 'user-2', name: 'Bob' }, }, ]; @@ -220,10 +221,10 @@ describe('ChannelMediaView', () => { }, ], cid: 'messaging:test-channel', - created_at: '2026-01-01T15:53:00.000Z', + created_at: msToNs(Date.parse('2026-01-01T15:53:00.000Z')), id: `message-${index}`, type: 'regular', - updated_at: '2026-01-01T15:53:00.000Z', + updated_at: msToNs(Date.parse('2026-01-01T15:53:00.000Z')), user: { id: 'user-1', name: 'Alice' }, })); diff --git a/src/plugins/ChannelDetail/Views/PinnedMessagesView/__tests__/PinnedMessagesView.test.tsx b/src/plugins/ChannelDetail/Views/PinnedMessagesView/__tests__/PinnedMessagesView.test.tsx index 4b4e8ef579..8ce4efe612 100644 --- a/src/plugins/ChannelDetail/Views/PinnedMessagesView/__tests__/PinnedMessagesView.test.tsx +++ b/src/plugins/ChannelDetail/Views/PinnedMessagesView/__tests__/PinnedMessagesView.test.tsx @@ -1,6 +1,6 @@ import { act, fireEvent, render, screen } from '@testing-library/react'; import React from 'react'; -import { StateStore } from 'stream-chat'; +import { msToNs, StateStore } from 'stream-chat'; import type { Channel, LocalMessage, @@ -131,22 +131,22 @@ vi.mock('../../../../../components/Dialog', () => ({ const pinnedMessages: LocalMessage[] = [ fromPartial({ cid: 'messaging:test-channel', - created_at: new Date('2026-01-01T15:53:00.000Z'), + created_at: msToNs(Date.parse('2026-01-01T15:53:00.000Z')), id: 'message-1', pinned: true, text: 'Release timeline: Code freeze March 18', type: 'regular', - updated_at: new Date('2026-01-01T15:53:00.000Z'), + updated_at: msToNs(Date.parse('2026-01-01T15:53:00.000Z')), user: { id: 'user-1', name: 'Alice' }, }), fromPartial({ attachments: [{ title: 'Roadmap.pdf', type: 'file' }], cid: 'messaging:test-channel', - created_at: new Date('2026-01-02T15:53:00.000Z'), + created_at: msToNs(Date.parse('2026-01-02T15:53:00.000Z')), id: 'message-2', pinned: true, type: 'regular', - updated_at: new Date('2026-01-02T15:53:00.000Z'), + updated_at: msToNs(Date.parse('2026-01-02T15:53:00.000Z')), user: { id: 'user-2', name: 'Bob' }, }), ]; @@ -291,6 +291,21 @@ describe('PinnedMessagesView', () => { expect(screen.getByText('Roadmap.pdf')).toBeInTheDocument(); }); + it('renders each message at its real instant, not the epoch', () => { + // Fixtures must model the wire: a `Date` through `fromPartial` type-checks but renders as 1970. + mockSearchSourceState({ messages: pinnedMessages }); + + renderWithChannel(); + + const stamps = screen + .getAllByRole('time') + .map((el) => el.getAttribute('dateTime') ?? el.getAttribute('datetime')); + + expect(stamps).toContain('2026-01-01T15:53:00.000Z'); + expect(stamps).toContain('2026-01-02T15:53:00.000Z'); + expect(stamps.some((s) => s?.startsWith('1970'))).toBe(false); + }); + it('searches pinned messages with the trimmed query', () => { renderWithChannel(); From 83c6b68cd754098bf8b4717a2808c5551cfba271 Mon Sep 17 00:00:00 2001 From: Zita Szupera Date: Wed, 2 Sep 2026 10:04:49 +0200 Subject: [PATCH 4/8] fix: date values in unit tests --- .../useIncomingMessageAnnouncements.test.tsx | 3 +- .../Channel/__tests__/Channel.test.tsx | 9 +- .../__tests__/ChannelListItem.test.tsx | 35 +++++--- ...nelListItemActionButtons.defaults.test.tsx | 5 +- .../ChannelListItem/__tests__/utils.test.ts | 11 ++- .../__tests__/useIsChannelMuted.test.tsx | 6 +- .../useMessageDeliveryStatus.test.tsx | 29 +++++-- .../__tests__/EventComponent.test.tsx | 7 +- .../Message/__tests__/MessageStatus.test.tsx | 5 +- .../Message/__tests__/MessageText.test.tsx | 9 +- .../__tests__/MessageTimestamp.test.tsx | 8 +- .../Message/__tests__/MessageUI.test.tsx | 9 +- .../Message/__tests__/QuotedMessage.test.tsx | 12 ++- .../Message/__tests__/utils.test.ts | 13 ++- .../__tests__/useMentionsHandler.test.tsx | 5 +- .../__tests__/useReactionsFetcher.test.tsx | 17 +++- .../__tests__/MessageActions.test.tsx | 5 +- .../__tests__/MessageInput.test.tsx | 5 +- .../__tests__/MessageList.test.tsx | 37 +++++--- .../VirtualizedMessageListComponents.test.tsx | 3 +- .../MessageList/__tests__/utils.test.ts | 85 +++++++++++++------ .../hooks/__tests__/useMarkRead.test.tsx | 13 +-- .../Poll/__tests__/PollOptionList.test.tsx | 5 +- .../__tests__/MessageReactions.test.tsx | 11 ++- .../useLatestMessagePreview.test.tsx | 6 +- .../ThreadList/__tests__/utils.a11y.test.ts | 5 +- src/mock-builders/generator/index.ts | 1 + src/mock-builders/generator/message.ts | 48 ++++------- .../__tests__/ChannelFilesView.test.tsx | 6 +- .../__tests__/ChannelMemberDetail.test.tsx | 5 +- .../ChannelMembersBrowseView.test.tsx | 9 +- 31 files changed, 279 insertions(+), 148 deletions(-) diff --git a/src/components/Accessibility/hooks/__tests__/useIncomingMessageAnnouncements.test.tsx b/src/components/Accessibility/hooks/__tests__/useIncomingMessageAnnouncements.test.tsx index 93f7023718..7335e1bbea 100644 --- a/src/components/Accessibility/hooks/__tests__/useIncomingMessageAnnouncements.test.tsx +++ b/src/components/Accessibility/hooks/__tests__/useIncomingMessageAnnouncements.test.tsx @@ -4,6 +4,7 @@ import { fromPartial } from '@total-typescript/shoehorn'; import { useIncomingMessageAnnouncements } from '../useIncomingMessageAnnouncements'; import type { Channel, Event, LocalMessage } from 'stream-chat'; +import { convertDateToTimestamp } from '../../../../mock-builders'; const { announceMock, tMock } = vi.hoisted(() => ({ announceMock: vi.fn(), @@ -48,7 +49,7 @@ const createMessage = ({ userName?: string; }) => fromPartial({ - created_at: new Date('2026-04-22T10:00:00.000Z'), + created_at: convertDateToTimestamp(new Date('2026-04-22T10:00:00.000Z')), id, parent_id: parentId, status: 'received', diff --git a/src/components/Channel/__tests__/Channel.test.tsx b/src/components/Channel/__tests__/Channel.test.tsx index 9e0bbd2495..7b11178cf0 100644 --- a/src/components/Channel/__tests__/Channel.test.tsx +++ b/src/components/Channel/__tests__/Channel.test.tsx @@ -45,6 +45,7 @@ import { WithComponents } from '../../../context'; import type { ChatContextValue, ComponentContextValue } from '../../../context'; import { generateMessageDraft } from '../../../mock-builders/generator/messageDraft'; import type { ChannelProps } from '../Channel'; +import { convertDateToTimestamp } from '../../../mock-builders'; vi.mock('../../Loading', () => ({ LoadingChannel: vi.fn(() =>
Loading channel
), @@ -168,7 +169,7 @@ describe('Channel', () => { Array.from({ length: 25 }, (_, i) => generateMessage({ cid: `${channelType}:${channelId}`, - created_at: new Date((i + 1) * 1000000), + created_at: convertDateToTimestamp(new Date((i + 1) * 1000000)), user, }), ); @@ -883,7 +884,7 @@ describe('Channel', () => { user: { ...user, ...updatedAttribute, - updated_at: new Date().toISOString(), + updated_at: convertDateToTimestamp(new Date().toISOString()), }, }, chatClient, @@ -927,7 +928,7 @@ describe('Channel', () => { messages: [generateMessage()], read: [ { - last_read: new Date().toISOString(), + last_read: convertDateToTimestamp(new Date().toISOString()), last_read_message_id: 'last_read_message_id-1', unread_messages, user, @@ -938,7 +939,7 @@ describe('Channel', () => { messages: [generateMessage()], read: [ { - last_read: new Date().toISOString(), + last_read: convertDateToTimestamp(new Date().toISOString()), last_read_message_id: 'last_read_message_id-2', unread_messages, user, diff --git a/src/components/ChannelListItem/__tests__/ChannelListItem.test.tsx b/src/components/ChannelListItem/__tests__/ChannelListItem.test.tsx index ea4f93428b..fc0799c4af 100644 --- a/src/components/ChannelListItem/__tests__/ChannelListItem.test.tsx +++ b/src/components/ChannelListItem/__tests__/ChannelListItem.test.tsx @@ -40,6 +40,7 @@ import { mockComponentContext, mockTranslationContextValue, } from '../../../mock-builders'; +import { convertDateToTimestamp } from '../../../mock-builders'; const EMPTY_CHANNEL_PREVIEW_TEXT = 'Empty channel'; const AVATAR_IMG_TEST_ID = 'avatar-img'; @@ -113,7 +114,9 @@ describe('ChannelPreview', () => { const genMessages = () => Array.from({ length: 5 }, (_, i) => generateMessage({ - created_at: new Date(Date.UTC(2020, 0, 1, 0, 0, i)).toISOString(), + created_at: convertDateToTimestamp( + new Date(Date.UTC(2020, 0, 1, 0, 0, i)).toISOString(), + ), }), ); useMockedApis(client, [ @@ -419,19 +422,25 @@ describe('ChannelPreview', () => { generateChannel({ messages: [ generateMessage({ - created_at: '1970-01-01T00:00:00.000Z', + created_at: convertDateToTimestamp('1970-01-01T00:00:00.000Z'), user: { id: 'other-user' }, }), - generateMessage({ created_at: '1970-01-02T00:00:00.000Z', user }), + generateMessage({ + created_at: convertDateToTimestamp('1970-01-02T00:00:00.000Z'), + user, + }), ] as LocalMessage[], }), generateChannel({ messages: [ generateMessage({ - created_at: '1971-01-01T00:00:00.000Z', + created_at: convertDateToTimestamp('1971-01-01T00:00:00.000Z'), user: { id: 'other-user' }, }), - generateMessage({ created_at: '1971-01-02T00:00:00.000Z', user }), + generateMessage({ + created_at: convertDateToTimestamp('1971-01-02T00:00:00.000Z'), + user, + }), ] as LocalMessage[], }), ], @@ -474,19 +483,25 @@ describe('ChannelPreview', () => { generateChannel({ messages: [ generateMessage({ - created_at: '1970-01-01T00:00:00.000Z', + created_at: convertDateToTimestamp('1970-01-01T00:00:00.000Z'), user: { id: 'other-user' }, }), - generateMessage({ created_at: '1970-01-02T00:00:00.000Z', user }), + generateMessage({ + created_at: convertDateToTimestamp('1970-01-02T00:00:00.000Z'), + user, + }), ] as LocalMessage[], }), generateChannel({ messages: [ generateMessage({ - created_at: '1971-01-01T00:00:00.000Z', + created_at: convertDateToTimestamp('1971-01-01T00:00:00.000Z'), user: { id: 'other-user' }, }), - generateMessage({ created_at: '1971-01-02T00:00:00.000Z', user }), + generateMessage({ + created_at: convertDateToTimestamp('1971-01-02T00:00:00.000Z'), + user, + }), ] as LocalMessage[], }), ], @@ -697,7 +712,7 @@ describe('ChannelPreview', () => { it('should pass pinned=true when membership has pinned_at', async () => { c0.state.membership = fromPartial({ ...c0.state.membership, - pinned_at: '2024-01-01T00:00:00Z', + pinned_at: convertDateToTimestamp('2024-01-01T00:00:00Z'), }); renderComponent( diff --git a/src/components/ChannelListItem/__tests__/ChannelListItemActionButtons.defaults.test.tsx b/src/components/ChannelListItem/__tests__/ChannelListItemActionButtons.defaults.test.tsx index 055691f70e..45426320b7 100644 --- a/src/components/ChannelListItem/__tests__/ChannelListItemActionButtons.defaults.test.tsx +++ b/src/components/ChannelListItem/__tests__/ChannelListItemActionButtons.defaults.test.tsx @@ -11,6 +11,7 @@ import { initClientWithChannels, } from '../../../mock-builders'; import { ResizeObserverMock } from '../../../mock-builders/browser'; +import { convertDateToTimestamp } from '../../../mock-builders'; const ResizeObserverConstructor = ResizeObserverMock as unknown as typeof window.ResizeObserver; @@ -568,7 +569,7 @@ describe('ChannelListItemActionButtons defaults', () => { // Simulate archived state channel.state.membership = fromPartial({ ...channel.state.membership, - archived_at: '2024-01-01T00:00:00Z', + archived_at: convertDateToTimestamp('2024-01-01T00:00:00Z'), }); vi.spyOn(channel, 'unarchive').mockResolvedValue(fromPartial({})); const addSpy = vi.spyOn(client.notifications, 'add'); @@ -673,7 +674,7 @@ describe('ChannelListItemActionButtons defaults', () => { // Simulate pinned state channel.state.membership = fromPartial({ ...channel.state.membership, - pinned_at: '2024-01-01T00:00:00Z', + pinned_at: convertDateToTimestamp('2024-01-01T00:00:00Z'), }); vi.spyOn(channel, 'unpin').mockResolvedValue(fromPartial({})); const addSpy = vi.spyOn(client.notifications, 'add'); diff --git a/src/components/ChannelListItem/__tests__/utils.test.ts b/src/components/ChannelListItem/__tests__/utils.test.ts index c801ad5b42..5c8e52f4b4 100644 --- a/src/components/ChannelListItem/__tests__/utils.test.ts +++ b/src/components/ChannelListItem/__tests__/utils.test.ts @@ -27,6 +27,7 @@ import { MessageDeliveryStatus } from '../hooks/useMessageDeliveryStatus'; import { generateStaticLocationResponse } from '../../../mock-builders'; import { render } from '@testing-library/react'; import { mockT } from '../../../mock-builders/translator'; +import { convertDateToTimestamp } from '../../../mock-builders'; describe('ChannelPreview utils', () => { const clientUser = generateUser(); @@ -49,7 +50,9 @@ describe('ChannelPreview utils', () => { describe('getLatestMessagePreview', () => { const channelWithEmptyMessage = generateChannel(); const channelWithDeletedMessage = generateChannel({ - messages: [generateMessage({ deleted_at: new Date().toISOString() })], + messages: [ + generateMessage({ deleted_at: convertDateToTimestamp(new Date().toISOString()) }), + ], }); const channelWithDeletedTypeMessage = generateChannel({ messages: [generateMessage({ type: 'deleted' })], @@ -127,7 +130,11 @@ describe('ChannelPreview utils', () => { const t = mockT as TranslationContextValue['t']; const channel = await getQueriedChannelInstance( generateChannel({ - messages: [generateMessage({ deleted_at: new Date().toISOString() })], + messages: [ + generateMessage({ + deleted_at: convertDateToTimestamp(new Date().toISOString()), + }), + ], }), ); expect(getLatestMessagePreviewText(channel, t)).toBe('Message deleted'); diff --git a/src/components/ChannelListItem/hooks/__tests__/useIsChannelMuted.test.tsx b/src/components/ChannelListItem/hooks/__tests__/useIsChannelMuted.test.tsx index 44dfb783ce..4f27b9f91a 100644 --- a/src/components/ChannelListItem/hooks/__tests__/useIsChannelMuted.test.tsx +++ b/src/components/ChannelListItem/hooks/__tests__/useIsChannelMuted.test.tsx @@ -13,6 +13,7 @@ import { useMockedApis, } from '../../../../mock-builders'; import { useIsChannelMuted } from '../useIsChannelMuted'; +import { convertDateToTimestamp } from '../../../../mock-builders'; const clientUser = generateUser({ id: 'current-user' }); @@ -99,7 +100,10 @@ describe('useIsChannelMuted', () => { me: { ...client.user, channel_mutes: [ - { channel: { cid: channel.cid }, created_at: '2020-05-26T07:11:57.968Z' }, + { + channel: { cid: channel.cid }, + created_at: convertDateToTimestamp('2020-05-26T07:11:57.968Z'), + }, ], }, type: 'notification.channel_mutes_updated', diff --git a/src/components/ChannelListItem/hooks/__tests__/useMessageDeliveryStatus.test.tsx b/src/components/ChannelListItem/hooks/__tests__/useMessageDeliveryStatus.test.tsx index 33b5b37d1a..8842baefe6 100644 --- a/src/components/ChannelListItem/hooks/__tests__/useMessageDeliveryStatus.test.tsx +++ b/src/components/ChannelListItem/hooks/__tests__/useMessageDeliveryStatus.test.tsx @@ -22,6 +22,7 @@ import { } from '../../../../mock-builders'; import { act } from '@testing-library/react'; import { dispatchMessageDeliveredEvent } from '../../../../mock-builders/event/messageDelivered'; +import { convertDateToTimestamp } from '../../../../mock-builders'; const ownUser = generateUser({ id: 'own-user' }); const otherUser = generateUser(); @@ -50,8 +51,14 @@ const getClientAndChannel = async (channelData = {}, user = ownUser) => { const ownLastMessage = () => { const messages = [ - generateMessage({ created_at: new Date(1000), user: otherUser }), - generateMessage({ created_at: new Date(2000), user: ownUser }), + generateMessage({ + created_at: convertDateToTimestamp(new Date(1000)), + user: otherUser, + }), + generateMessage({ + created_at: convertDateToTimestamp(new Date(2000)), + user: ownUser, + }), ]; const lastMessage = messages.slice(-1)[0]; return { lastMessage, messages }; @@ -59,8 +66,14 @@ const ownLastMessage = () => { const othersLastMessage = () => { const messages = [ - generateMessage({ created_at: new Date(1000), user: ownUser }), - generateMessage({ created_at: new Date(2000), user: otherUser }), + generateMessage({ + created_at: convertDateToTimestamp(new Date(1000)), + user: ownUser, + }), + generateMessage({ + created_at: convertDateToTimestamp(new Date(2000)), + user: otherUser, + }), ]; const lastMessage = messages.slice(-1)[0]; return { lastMessage, messages }; @@ -268,7 +281,7 @@ describe('Message delivery status', () => { const { result } = renderComponent({ channel, client }); const newMessage = generateMessage({ - created_at: new Date('1970-01-01T00:00:02.00Z'), + created_at: convertDateToTimestamp(new Date('1970-01-01T00:00:02.00Z')), user: otherUser, }); await act(() => { @@ -287,7 +300,7 @@ describe('Message delivery status', () => { const { channel, client } = await getClientAndChannel({ messages, read }); const newMessage = generateMessage({ - created_at: new Date(3000), + created_at: convertDateToTimestamp(new Date(3000)), user: ownUser, }); const { rerender, result } = renderComponent({ @@ -442,7 +455,7 @@ describe('Message delivery status', () => { const updatedMessage = { ...lastMessage, - updated_at: new Date('1970-01-01T00:00:02.00Z'), + updated_at: convertDateToTimestamp(new Date('1970-01-01T00:00:02.00Z')), }; await act(() => { @@ -460,7 +473,7 @@ describe('Message delivery status', () => { const updatedMessage = { ...lastMessage, - updated_at: new Date(4000), + updated_at: convertDateToTimestamp(new Date(4000)), }; await act(() => { diff --git a/src/components/EventComponent/__tests__/EventComponent.test.tsx b/src/components/EventComponent/__tests__/EventComponent.test.tsx index f1b04b4fd1..8f5d738c75 100644 --- a/src/components/EventComponent/__tests__/EventComponent.test.tsx +++ b/src/components/EventComponent/__tests__/EventComponent.test.tsx @@ -9,6 +9,7 @@ import type { EventComponentProps } from '../EventComponent'; import { Chat } from '../../Chat'; import type { ChatProps } from '../../Chat'; import { getTestClient } from '../../../mock-builders'; +import { convertDateToTimestamp } from '../../../mock-builders'; const SYSTEM_MSG_TEST_ID = 'message-system'; @@ -16,7 +17,7 @@ describe('EventComponent', () => { afterEach(cleanup); const message = fromPartial({ - created_at: new Date('2020-03-13T10:18:38.148025Z'), + created_at: convertDateToTimestamp(new Date('2020-03-13T10:18:38.148025Z')), type: 'system', }); @@ -107,7 +108,7 @@ describe('EventComponent', () => { describe('Channel events', () => { it('should render null for member add event (channel events no longer rendered)', () => { const msg = fromPartial({ - created_at: '2020-01-13T18:18:38.148025Z', + created_at: convertDateToTimestamp('2020-01-13T18:18:38.148025Z'), event: { type: 'member.added', user: { id: 'user_id', image: 'image_url', username: 'username' }, @@ -121,7 +122,7 @@ describe('EventComponent', () => { it('should render null for member remove event (channel events no longer rendered)', () => { const msg = fromPartial({ - created_at: '2020-01-13T18:18:38.148025Z', + created_at: convertDateToTimestamp('2020-01-13T18:18:38.148025Z'), event: { type: 'member.removed', user: { id: 'user_id', image: 'image_url', username: 'username' }, diff --git a/src/components/Message/__tests__/MessageStatus.test.tsx b/src/components/Message/__tests__/MessageStatus.test.tsx index 920b30909f..cc7069ac03 100644 --- a/src/components/Message/__tests__/MessageStatus.test.tsx +++ b/src/components/Message/__tests__/MessageStatus.test.tsx @@ -18,6 +18,7 @@ import { mockTranslationContextValue, } from '../../../mock-builders'; import { mockT } from '../../../mock-builders/translator'; +import { convertDateToTimestamp } from '../../../mock-builders'; const MESSAGE_STATUS_SENDING_TEST_ID = 'message-status-sending'; const MESSAGE_STATUS_DELIVERED_TEST_ID = 'message-status-delivered'; @@ -29,7 +30,7 @@ const user = { id: 'me' }; const foreignMsg = { __html: '

regular

', attachments: [], - created_at: '2024-05-28T15:13:20.899Z', + created_at: convertDateToTimestamp('2024-05-28T15:13:20.899Z'), html: '

regular

', id: '5kIE4fIArv11V4YHYdXKO', mentioned_users: [], @@ -37,7 +38,7 @@ const foreignMsg = { status: 'received', text: 'udSNfyk7Z-0MRn17WUQwY', type: 'regular', - updated_at: '2024-05-28T15:13:20.900Z', + updated_at: convertDateToTimestamp('2024-05-28T15:13:20.900Z'), user: otherUser, }; diff --git a/src/components/Message/__tests__/MessageText.test.tsx b/src/components/Message/__tests__/MessageText.test.tsx index 9399636c4d..53da397c96 100644 --- a/src/components/Message/__tests__/MessageText.test.tsx +++ b/src/components/Message/__tests__/MessageText.test.tsx @@ -32,6 +32,7 @@ import type { MessageProps } from '../types'; import type { MessageTextProps } from '../MessageText'; import type { TranslationContextValue } from '../../../context'; import { mockT } from '../../../mock-builders/translator'; +import { convertDateToTimestamp } from '../../../mock-builders'; vi.mock('../../ChatView', async (importOriginal) => { const actual = await importOriginal(); @@ -266,10 +267,10 @@ describe('', () => { { mentioned_groups: [ fromPartial({ - created_at: '2026-05-28T00:00:00.000Z', + created_at: convertDateToTimestamp('2026-05-28T00:00:00.000Z'), id: 'backend-team', name: 'Backend Team', - updated_at: '2026-05-28T00:00:00.000Z', + updated_at: convertDateToTimestamp('2026-05-28T00:00:00.000Z'), }), ], text: 'Hello @Backend Team', @@ -389,10 +390,10 @@ describe('', () => { mentioned_channel: true, mentioned_groups: [ fromPartial({ - created_at: '2026-05-28T00:00:00.000Z', + created_at: convertDateToTimestamp('2026-05-28T00:00:00.000Z'), id: 'backend-team', name: 'Backend Team', - updated_at: '2026-05-28T00:00:00.000Z', + updated_at: convertDateToTimestamp('2026-05-28T00:00:00.000Z'), }), ], mentioned_here: true, diff --git a/src/components/Message/__tests__/MessageTimestamp.test.tsx b/src/components/Message/__tests__/MessageTimestamp.test.tsx index 37b78e5192..a65f089d9b 100644 --- a/src/components/Message/__tests__/MessageTimestamp.test.tsx +++ b/src/components/Message/__tests__/MessageTimestamp.test.tsx @@ -3,7 +3,7 @@ import { act, cleanup, render, type RenderResult } from '@testing-library/react' import { fromPartial } from '@total-typescript/shoehorn'; import type { LocalMessage } from 'stream-chat'; import { msToNs, nsToDate } from 'stream-chat'; -import { generateMessage } from 'mock-builders'; +import { convertDateToTimestamp, generateMessage } from 'mock-builders'; import { MessageTimestamp } from '../MessageTimestamp'; import { ComponentProvider, MessageProvider, TranslationContext } from '../../../context'; import type { TranslationContextValue } from '../../../context'; @@ -35,7 +35,7 @@ const formatDate = () => dateMock; const createdAt = new Date('2019-04-03T14:42:47.087869Z'); const messageMock = generateMessage({ - created_at: createdAt, + created_at: convertDateToTimestamp(createdAt), }); const renderComponent = async ({ @@ -119,7 +119,9 @@ describe('', () => { }); it('should not render if message created_at is not a valid date', () => { - const message = generateMessage({ created_at: 'I am not a date' }); + // An unusable wire timestamp is `NaN`, not a string — that is what `convertTimestampToDate` + // guards against and what a malformed payload actually produces. + const message = generateMessage({ created_at: NaN }); const { container } = render( diff --git a/src/components/Message/__tests__/MessageUI.test.tsx b/src/components/Message/__tests__/MessageUI.test.tsx index 22f8872d75..3766d48357 100644 --- a/src/components/Message/__tests__/MessageUI.test.tsx +++ b/src/components/Message/__tests__/MessageUI.test.tsx @@ -40,6 +40,7 @@ import { ThreadProvider } from '../../Threads'; import { generateReminderResponse } from '../../../mock-builders/generator/reminder'; import type { Channel, StreamChat, Thread } from 'stream-chat'; import type { ComponentContextValue, MessageContextValue } from '../../../context'; +import { convertDateToTimestamp } from '../../../mock-builders'; // MERGE-RECONCILE (test migration): thread opening moved from the deleted ChannelActionContext // `openThread` handler to the core workspace-navigation adapter `openThread`. We mock the @@ -234,7 +235,7 @@ describe('', () => { it('should render deleted message with default MessageDelete component when message was deleted', async () => { const deletedMessage = generateAliceMessage({ - deleted_at: new Date('2019-12-17T03:24:00').toISOString(), + deleted_at: convertDateToTimestamp(new Date('2019-12-17T03:24:00').toISOString()), }); const { container, getByTestId } = await renderMessageSimple({ message: deletedMessage, @@ -270,7 +271,7 @@ describe('', () => { it('should render deleted message with custom component when message was deleted and a custom delete message component was passed', async () => { const deletedMessage = generateAliceMessage({ - deleted_at: new Date('2019-12-25T03:24:00').toISOString(), + deleted_at: convertDateToTimestamp(new Date('2019-12-25T03:24:00').toISOString()), }); const CustomMessageDeletedComponent = () => (

Gone!

@@ -834,7 +835,7 @@ describe('', () => { it("should display message's timestamp", async () => { const messageDate = new Date('2019-12-12T03:33:00'); const message = generateAliceMessage({ - created_at: messageDate, + created_at: convertDateToTimestamp(messageDate), }); const { container } = await renderMessageSimple({ message }); const timeEl = container.querySelector('time.str-chat__message-metadata__timestamp'); @@ -1018,7 +1019,7 @@ describe('', () => { describe('edited label', () => { const editedMessageOptions = { - message_text_updated_at: '2024-03-05T09:56:22.487729Z', + message_text_updated_at: convertDateToTimestamp('2024-03-05T09:56:22.487729Z'), }; it('should render error badge for bounced messages', async () => { diff --git a/src/components/Message/__tests__/QuotedMessage.test.tsx b/src/components/Message/__tests__/QuotedMessage.test.tsx index ccde81aa23..c1a6977603 100644 --- a/src/components/Message/__tests__/QuotedMessage.test.tsx +++ b/src/components/Message/__tests__/QuotedMessage.test.tsx @@ -24,6 +24,7 @@ import { MessageUI } from '../MessageUI'; import { QuotedMessage } from '../QuotedMessage'; import { renderText } from '../renderText'; import { mockT } from '../../../mock-builders/translator'; +import { convertDateToTimestamp } from '../../../mock-builders'; vi.mock('../../ChatView', async (importOriginal) => { const actual = await importOriginal(); @@ -144,10 +145,10 @@ describe('QuotedMessage', () => { mentioned_channel: true, mentioned_groups: [ fromPartial({ - created_at: '2026-05-28T00:00:00.000Z', + created_at: convertDateToTimestamp('2026-05-28T00:00:00.000Z'), id: 'backend-team', name: 'Backend Team', - updated_at: '2026-05-28T00:00:00.000Z', + updated_at: convertDateToTimestamp('2026-05-28T00:00:00.000Z'), }), ], mentioned_here: true, @@ -333,7 +334,10 @@ describe('QuotedMessage', () => { it('should still render the quoted message preview for deleted_at timestamp', async () => { const message = { - quoted_message: { deleted_at: new Date().toISOString(), text: quotedText }, + quoted_message: { + deleted_at: convertDateToTimestamp(new Date().toISOString()), + text: quotedText, + }, }; const { container, queryByTestId } = await renderQuotedMessage({ customProps: { message }, @@ -347,7 +351,7 @@ describe('QuotedMessage', () => { const message = { quoted_message: { attachments: [generateFileAttachment()], - deleted_at: new Date().toISOString(), + deleted_at: convertDateToTimestamp(new Date().toISOString()), }, }; const { container, queryByTestId } = await renderQuotedMessage({ diff --git a/src/components/Message/__tests__/utils.test.ts b/src/components/Message/__tests__/utils.test.ts index 8ef538bf81..b994c8c0a9 100644 --- a/src/components/Message/__tests__/utils.test.ts +++ b/src/components/Message/__tests__/utils.test.ts @@ -27,6 +27,7 @@ import { } from '../utils'; import type { MessageProps } from '../types'; import type { GroupStyle } from '../../MessageList/utils'; +import { convertDateToTimestamp } from '../../../mock-builders'; const alice = generateUser({ name: 'alice' }); const bob = generateUser({ name: 'bob' }); @@ -58,7 +59,9 @@ describe('Message utils', () => { it('should return false if message is not defined', () => { const mutes = [ fromPartial({ - created_at: new Date('2019-03-30T13:24:10').toISOString(), + created_at: convertDateToTimestamp( + new Date('2019-03-30T13:24:10').toISOString(), + ), target: bob, user: alice, }), @@ -76,7 +79,9 @@ describe('Message utils', () => { it('should return true if user was muted', () => { const mutes = [ fromPartial({ - created_at: new Date('2019-03-30T13:24:10').toISOString(), + created_at: convertDateToTimestamp( + new Date('2019-03-30T13:24:10').toISOString(), + ), target: bob, user: alice, }), @@ -299,8 +304,8 @@ describe('Message utils', () => { ['updated_at', new Date(1).toISOString(), new Date(2).toISOString()], [ 'user', - { updated_at: new Date(1).toISOString() }, - { updated_at: new Date(2).toISOString() }, + { updated_at: convertDateToTimestamp(new Date(1).toISOString()) }, + { updated_at: convertDateToTimestamp(new Date(2).toISOString()) }, ], ]; const message = generateMessage(); diff --git a/src/components/Message/hooks/__tests__/useMentionsHandler.test.tsx b/src/components/Message/hooks/__tests__/useMentionsHandler.test.tsx index 7a2d9fcdc3..13b0a205d7 100644 --- a/src/components/Message/hooks/__tests__/useMentionsHandler.test.tsx +++ b/src/components/Message/hooks/__tests__/useMentionsHandler.test.tsx @@ -4,6 +4,7 @@ import { fromPartial } from '@total-typescript/shoehorn'; import { useMentionsHandler } from '../useMentionsHandler'; import { generateMessage, generateUser } from '../../../../mock-builders'; +import { convertDateToTimestamp } from '../../../../mock-builders'; // MERGE-RECONCILE (test migration): the master merge removed ChannelActionContext. // `useMentionsHandler` no longer reads `onMentionsClick`/`onMentionsHover` from context — @@ -83,10 +84,10 @@ describe('useMentionsHandler custom hooks', () => { { mentioned_groups: [ fromPartial({ - created_at: '2026-05-28T00:00:00.000Z', + created_at: convertDateToTimestamp('2026-05-28T00:00:00.000Z'), id: 'backend-team', name: 'Backend Team', - updated_at: '2026-05-28T00:00:00.000Z', + updated_at: convertDateToTimestamp('2026-05-28T00:00:00.000Z'), }), ], }, diff --git a/src/components/Message/hooks/__tests__/useReactionsFetcher.test.tsx b/src/components/Message/hooks/__tests__/useReactionsFetcher.test.tsx index 1ba1619534..4e82cee80b 100644 --- a/src/components/Message/hooks/__tests__/useReactionsFetcher.test.tsx +++ b/src/components/Message/hooks/__tests__/useReactionsFetcher.test.tsx @@ -9,6 +9,7 @@ import { mockChatContext, } from '../../../../mock-builders'; import type { LocalMessage, MessageResponse } from 'stream-chat'; +import { convertDateToTimestamp } from '../../../../mock-builders'; describe('useReactionsFetcher', () => { it('paginates until next is empty', async () => { @@ -18,11 +19,23 @@ describe('useReactionsFetcher', () => { .mockResolvedValueOnce({ duration: '0', next: 'page-2', - reactions: [{ created_at: new Date(), type: 'like', updated_at: new Date() }], + reactions: [ + { + created_at: convertDateToTimestamp(new Date()), + type: 'like', + updated_at: convertDateToTimestamp(new Date()), + }, + ], } as never) .mockResolvedValueOnce({ duration: '0', - reactions: [{ created_at: new Date(), type: 'love', updated_at: new Date() }], + reactions: [ + { + created_at: convertDateToTimestamp(new Date()), + type: 'love', + updated_at: convertDateToTimestamp(new Date()), + }, + ], } as never); const message = generateMessage() as MessageResponse & LocalMessage; diff --git a/src/components/MessageActions/__tests__/MessageActions.test.tsx b/src/components/MessageActions/__tests__/MessageActions.test.tsx index e69b6b9beb..fc7f70a76d 100644 --- a/src/components/MessageActions/__tests__/MessageActions.test.tsx +++ b/src/components/MessageActions/__tests__/MessageActions.test.tsx @@ -27,6 +27,7 @@ import { ResizeObserverMock } from '../../../mock-builders/browser'; import { Message } from '../../Message'; import { Channel } from '../../Channel'; import { Chat } from '../../Chat'; +import { convertDateToTimestamp } from '../../../mock-builders'; (window as any).ResizeObserver = ResizeObserverMock; @@ -262,7 +263,7 @@ describe('', () => { it('should not show Delete when the message is already deleted', async () => { const message = generateMessage({ - deleted_at: new Date().toISOString(), + deleted_at: convertDateToTimestamp(new Date().toISOString()), user: alice, }); await renderMessageActions({ @@ -724,7 +725,7 @@ describe('', () => { const lastReceivedId = message.id; const read = [ { - last_read: new Date().toISOString(), + last_read: convertDateToTimestamp(new Date().toISOString()), last_read_message_id: message.id, unread_messages: 0, user: me, diff --git a/src/components/MessageComposer/__tests__/MessageInput.test.tsx b/src/components/MessageComposer/__tests__/MessageInput.test.tsx index 4ad19266f1..41c1e8945c 100644 --- a/src/components/MessageComposer/__tests__/MessageInput.test.tsx +++ b/src/components/MessageComposer/__tests__/MessageInput.test.tsx @@ -62,6 +62,7 @@ import { import { QuotedMessagePreview } from '../QuotedMessagePreview'; import type { ChannelProps } from '../../Channel'; import type { GenerateChannelOptions } from '../../../mock-builders/generator/channel'; +import { convertDateToTimestamp } from '../../../mock-builders'; const IMAGE_PREVIEW_TEST_ID = 'attachment-preview-media'; const FILE_PREVIEW_TEST_ID = 'attachment-preview-file'; @@ -1115,8 +1116,8 @@ describe(`MessageInputFlat`, () => { messageContextOverrides: { message: fromPartial({ cid: customChannel.cid, - created_at: new Date(), - updated_at: new Date(), + created_at: convertDateToTimestamp(new Date()), + updated_at: convertDateToTimestamp(new Date()), }), }, }); diff --git a/src/components/MessageList/__tests__/MessageList.test.tsx b/src/components/MessageList/__tests__/MessageList.test.tsx index 13dc2e28d9..9fb380c8d9 100644 --- a/src/components/MessageList/__tests__/MessageList.test.tsx +++ b/src/components/MessageList/__tests__/MessageList.test.tsx @@ -36,6 +36,7 @@ import type { ComponentContextValue } from '../../../context'; import type { MockInstance } from 'vitest'; import type { ChannelProps } from '../../Channel'; import type { MessageListProps } from '../MessageList'; +import { convertDateToTimestamp } from '../../../mock-builders'; // MERGE-RECONCILE (test migration): PR #2909 moved the rendered message collection off the // `messages` prop / removed ChannelStateContext onto `channel.messagePaginator`. MessageList now @@ -499,7 +500,11 @@ describe('MessageList', () => { describe('unread messages', () => { const timestamp = new Date().getTime(); const messages = Array.from({ length: 5 }, (_, index) => - generateMessage({ created_at: new Date(timestamp + index * 1000).toISOString() }), + generateMessage({ + created_at: convertDateToTimestamp( + new Date(timestamp + index * 1000).toISOString(), + ), + }), ); const unread_messages = 2; @@ -636,7 +641,9 @@ describe('MessageList', () => { it('should display unread messages separator in main msg list', async () => { const user = generateUser(); const messages = Array.from({ length: 5 }).map((_, i) => - generateMessage({ created_at: new Date(i + 1000).toISOString() }), + generateMessage({ + created_at: convertDateToTimestamp(new Date(i + 1000).toISOString()), + }), ); const { channels: [channel], @@ -647,7 +654,9 @@ describe('MessageList', () => { messages, read: [ { - last_read: new Date(messages[2].created_at).toISOString(), + last_read: convertDateToTimestamp( + new Date(messages[2].created_at).toISOString(), + ), last_read_message_id: messages[2].id, unread_messages: 2, user, @@ -685,7 +694,9 @@ describe('MessageList', () => { it('should not display unread messages separator in read main msg list', async () => { const user = generateUser(); const messages = Array.from({ length: 5 }).map((_, i) => - generateMessage({ created_at: new Date(i + 1000).toISOString() }), + generateMessage({ + created_at: convertDateToTimestamp(new Date(i + 1000).toISOString()), + }), ); const lastMessage = messages.slice(-1)[0]; @@ -698,7 +709,9 @@ describe('MessageList', () => { messages, read: [ { - last_read: new Date(lastMessage.created_at).toISOString(), + last_read: convertDateToTimestamp( + new Date(lastMessage.created_at).toISOString(), + ), last_read_message_id: lastMessage.id, unread_messages: 0, user, @@ -729,15 +742,17 @@ describe('MessageList', () => { it('should not display unread messages separator in threads', async () => { const user = generateUser(); const messages = Array.from({ length: 5 }).map((_, i) => - generateMessage({ created_at: new Date(i + 1000).toISOString() }), + generateMessage({ + created_at: convertDateToTimestamp(new Date(i + 1000).toISOString()), + }), ); const parentMsg = messages[4]; const lastReadMessage = messages[3]; const replies = Array.from({ length: 3 }).map(() => generateMessage({ - created_at: new Date( - new Date(parentMsg.created_at).getTime() + 1000 + 1, - ).toISOString(), + created_at: convertDateToTimestamp( + new Date(new Date(parentMsg.created_at).getTime() + 1000 + 1).toISOString(), + ), parent_id: parentMsg.id, }), ); @@ -750,7 +765,9 @@ describe('MessageList', () => { messages, read: [ { - last_read: new Date(lastReadMessage.created_at).toISOString(), + last_read: convertDateToTimestamp( + new Date(lastReadMessage.created_at).toISOString(), + ), last_read_message_id: lastReadMessage.id, unread_messages: 1, user, diff --git a/src/components/MessageList/__tests__/VirtualizedMessageListComponents.test.tsx b/src/components/MessageList/__tests__/VirtualizedMessageListComponents.test.tsx index 37c3cd9d75..3dd560818a 100644 --- a/src/components/MessageList/__tests__/VirtualizedMessageListComponents.test.tsx +++ b/src/components/MessageList/__tests__/VirtualizedMessageListComponents.test.tsx @@ -29,6 +29,7 @@ import { MessageUI } from '../../Message'; import { UnreadMessagesSeparator } from '../UnreadMessagesSeparator'; import type { GroupStyle, RenderedMessage } from '../utils'; import type { Channel, StreamChat, Thread } from 'stream-chat'; +import { convertDateToTimestamp } from '../../../mock-builders'; // EmptyPlaceholder derives thread-ness from useThreadContext() rather than context.threadList, // so a truthy thread must be provided to exercise the thread branch. @@ -461,7 +462,7 @@ describe('VirtualizedMessageComponents', () => { describe('UnreadMessagesSeparator', () => { const messages = Array.from({ length: 2 }, (_, i) => generateMessage({ - created_at: new Date(i + 2).toISOString(), + created_at: convertDateToTimestamp(new Date(i + 2).toISOString()), id: String(i + 1), }), ); diff --git a/src/components/MessageList/__tests__/utils.test.ts b/src/components/MessageList/__tests__/utils.test.ts index 9c7272b3f0..5daa8aa6c9 100644 --- a/src/components/MessageList/__tests__/utils.test.ts +++ b/src/components/MessageList/__tests__/utils.test.ts @@ -16,6 +16,7 @@ import { } from '../utils'; import { CUSTOM_MESSAGE_TYPE } from '../../../constants/messageTypes'; import { convertTimestampToDate, msToNs } from 'stream-chat'; +import { convertDateToTimestamp } from '../../../mock-builders'; const mockedNanoId = 'V1StGXR8_Z5jdHi6B-myT'; vi.mock('nanoid', () => ({ @@ -27,20 +28,44 @@ const otherUserId = 'otherUserId'; const enableDateSeparatorParams = { enableDateSeparator: true }; const msgCreationDatesSameDay = [ - { created_at: new Date('1970-01-01'), updated_at: new Date('1970-01-01') }, - { created_at: new Date('1970-01-01'), updated_at: new Date('1970-01-01') }, + { + created_at: convertDateToTimestamp(new Date('1970-01-01')), + updated_at: convertDateToTimestamp(new Date('1970-01-01')), + }, + { + created_at: convertDateToTimestamp(new Date('1970-01-01')), + updated_at: convertDateToTimestamp(new Date('1970-01-01')), + }, ]; const msgCreationDatesDifferentDay = [ - { created_at: new Date('1970-01-01'), updated_at: new Date('1970-01-01') }, - { created_at: new Date('1970-01-02'), updated_at: new Date('1970-01-02') }, + { + created_at: convertDateToTimestamp(new Date('1970-01-01')), + updated_at: convertDateToTimestamp(new Date('1970-01-01')), + }, + { + created_at: convertDateToTimestamp(new Date('1970-01-02')), + updated_at: convertDateToTimestamp(new Date('1970-01-02')), + }, ]; const msgCreationDatesFirstInvalid = [ - { created_at: new Date('1970-01-00'), updated_at: new Date('1970-01-00') }, - { created_at: new Date('1970-01-01'), updated_at: new Date('1970-01-01') }, + { + created_at: convertDateToTimestamp(new Date('1970-01-00')), + updated_at: convertDateToTimestamp(new Date('1970-01-00')), + }, + { + created_at: convertDateToTimestamp(new Date('1970-01-01')), + updated_at: convertDateToTimestamp(new Date('1970-01-01')), + }, ]; const msgCreationDatesSecondInvalid = [ - { created_at: new Date('1970-01-31'), updated_at: new Date('1970-01-31') }, - { created_at: new Date('1970-02-00'), updated_at: new Date('1970-02-00') }, + { + created_at: convertDateToTimestamp(new Date('1970-01-31')), + updated_at: convertDateToTimestamp(new Date('1970-01-31')), + }, + { + created_at: convertDateToTimestamp(new Date('1970-02-00')), + updated_at: convertDateToTimestamp(new Date('1970-02-00')), + }, ]; const runMessageProcessing = (msgData, processMsgParams = {}) => { @@ -213,9 +238,9 @@ describe('processMessages', () => { }); describe('replaces deleted messages', () => { - const date1 = new Date('1970-01-01'); - const date2 = new Date('1970-01-02'); - const date3 = new Date('1970-01-03'); + const date1 = convertDateToTimestamp('1970-01-01'); + const date2 = convertDateToTimestamp('1970-01-02'); + const date3 = convertDateToTimestamp('1970-01-03'); const deletedMessagesReplacedCorrectly = (messages, newMessageList) => { expect(newMessageList[0]).toMatchObject(makeDateSeparator(messages[0])); @@ -362,12 +387,12 @@ describe('processMessages', () => { const shouldExpectUnreadSeparator = true; const lastRead = new Date(); const oldMsg = { - created_at: new Date('1970-01-01'), - updated_at: new Date('1970-01-01'), + created_at: convertDateToTimestamp(new Date('1970-01-01')), + updated_at: convertDateToTimestamp(new Date('1970-01-01')), }; const unreadMsg = { - created_at: new Date('9999-12-31'), - updated_at: new Date('9999-12-31'), + created_at: convertDateToTimestamp(new Date('9999-12-31')), + updated_at: convertDateToTimestamp(new Date('9999-12-31')), }; const myNewMessages = [ { user: { id: myUserId }, ...unreadMsg }, @@ -495,9 +520,15 @@ describe('getGroupStyles', () => { let nextMessage: LocalMessage; let noGroupByUser: boolean; beforeEach(() => { - message = generateMessage({ created_at: new Date(2), user }); - previousMessage = generateMessage({ created_at: new Date(1), user }); - nextMessage = generateMessage({ created_at: new Date(100), user }); + message = generateMessage({ created_at: convertDateToTimestamp(new Date(2)), user }); + previousMessage = generateMessage({ + created_at: convertDateToTimestamp(new Date(1)), + user, + }); + nextMessage = generateMessage({ + created_at: convertDateToTimestamp(new Date(100)), + user, + }); noGroupByUser = false; }); @@ -608,10 +639,13 @@ describe('getGroupStyles', () => { // deleted_at no longer affects grouping in v14 it('is deleted', () => { if (position === 'bottom') { - nextMessage = { ...nextMessage, deleted_at: new Date() }; + nextMessage = { ...nextMessage, deleted_at: convertDateToTimestamp(new Date()) }; } if (position === 'top') { - previousMessage = { ...previousMessage, deleted_at: new Date() }; + previousMessage = { + ...previousMessage, + deleted_at: convertDateToTimestamp(new Date()), + }; } // deleted_at on adjacent messages does not break groups anymore expect(getGroupStyles(message, previousMessage, nextMessage, noGroupByUser)).toBe( @@ -621,7 +655,10 @@ describe('getGroupStyles', () => { }); it('marks a message as bottom when the message is edited', () => { - message = { ...message, message_text_updated_at: new Date().toISOString() }; + message = { + ...message, + message_text_updated_at: convertDateToTimestamp(new Date().toISOString()), + }; expect(getGroupStyles(message, previousMessage, nextMessage, noGroupByUser)).toBe( 'bottom', ); @@ -630,7 +667,7 @@ describe('getGroupStyles', () => { it('marks a message as top when the previous message is edited', () => { previousMessage = { ...previousMessage, - message_text_updated_at: new Date().toISOString(), + message_text_updated_at: convertDateToTimestamp(new Date().toISOString()), }; expect(getGroupStyles(message, previousMessage, nextMessage, noGroupByUser)).toBe( 'top', @@ -720,7 +757,7 @@ describe('getGroupStyles', () => { // deleted_at on the message itself no longer forces 'single' in v14 it('marks message as middle even when deleted (deleted_at no longer affects grouping)', () => { - message = { ...message, deleted_at: new Date() }; + message = { ...message, deleted_at: convertDateToTimestamp(new Date()) }; expect(getGroupStyles(message, previousMessage, nextMessage, noGroupByUser)).toBe( 'middle', ); @@ -735,7 +772,7 @@ describe('getGroupStyles', () => { // deleted_at no longer forces 'single'; at the bottom position it's just 'bottom' it('marks message at the bottom as bottom even when deleted', () => { - message = { ...message, deleted_at: new Date() }; + message = { ...message, deleted_at: convertDateToTimestamp(new Date()) }; nextMessage = undefined; expect(getGroupStyles(message, previousMessage, nextMessage, noGroupByUser)).toBe( 'bottom', diff --git a/src/components/MessageList/hooks/__tests__/useMarkRead.test.tsx b/src/components/MessageList/hooks/__tests__/useMarkRead.test.tsx index e2efb57138..6c79db6946 100644 --- a/src/components/MessageList/hooks/__tests__/useMarkRead.test.tsx +++ b/src/components/MessageList/hooks/__tests__/useMarkRead.test.tsx @@ -12,6 +12,7 @@ import { initClientWithChannels, } from '../../../../mock-builders'; import { act } from 'react'; +import { convertDateToTimestamp } from '../../../../mock-builders'; // MERGE-RECONCILE (test migration): useMarkRead was rewritten (PR #2909). It no longer receives // `markRead`/`setChannelUnreadUiState` from the removed ChannelActionContext, and the manual @@ -58,14 +59,14 @@ const render = async ({ const unreadLastMessageChannelData = () => { const user = generateUser(); const messages = [ - generateMessage({ created_at: new Date(1) }), - generateMessage({ created_at: new Date(2) }), + generateMessage({ created_at: convertDateToTimestamp(new Date(1)) }), + generateMessage({ created_at: convertDateToTimestamp(new Date(2)) }), ]; return { messages, read: [ { - last_read: new Date(1).toISOString(), + last_read: convertDateToTimestamp(new Date(1).toISOString()), last_read_message_id: messages[0].id, unread_messages: 1, user, @@ -77,15 +78,15 @@ const unreadLastMessageChannelData = () => { const readLastMessageChannelData = () => { const user = generateUser(); const messages = [ - generateMessage({ created_at: new Date(1) }), - generateMessage({ created_at: new Date(2) }), + generateMessage({ created_at: convertDateToTimestamp(new Date(1)) }), + generateMessage({ created_at: convertDateToTimestamp(new Date(2)) }), ]; return { channel: { config: { read_events: true } }, messages, read: [ { - last_read: new Date(2).toISOString(), + last_read: convertDateToTimestamp(new Date(2).toISOString()), last_read_message_id: messages[1].id, unread_messages: 0, user, diff --git a/src/components/Poll/__tests__/PollOptionList.test.tsx b/src/components/Poll/__tests__/PollOptionList.test.tsx index aa2caae09b..7e141d0ea3 100644 --- a/src/components/Poll/__tests__/PollOptionList.test.tsx +++ b/src/components/Poll/__tests__/PollOptionList.test.tsx @@ -26,6 +26,7 @@ import { mockTranslationContextValue, } from '../../../mock-builders'; import { mockT } from '../../../mock-builders/translator'; +import { convertDateToTimestamp } from '../../../mock-builders'; // MERGE-RECONCILE (test migration): the deleted ChannelStateContext no longer provides // `channelCapabilities`. Poll components now read capabilities via useChannelCapabilities({ cid }), @@ -382,11 +383,11 @@ describe('PollOptionList', () => { }, }, pollVote: { - created_at: new Date(), + created_at: convertDateToTimestamp(new Date()), id: '4c552daf-8f72-409c-a2ee-313b9db9fcd0', option_id: pollWithNoVotes.options[0].id, poll_id: pollWithNoVotes.id, - updated_at: new Date(), + updated_at: convertDateToTimestamp(new Date()), user, user_id: user.id, }, diff --git a/src/components/Reactions/__tests__/MessageReactions.test.tsx b/src/components/Reactions/__tests__/MessageReactions.test.tsx index f91699e487..be6f38dfc8 100644 --- a/src/components/Reactions/__tests__/MessageReactions.test.tsx +++ b/src/components/Reactions/__tests__/MessageReactions.test.tsx @@ -13,6 +13,7 @@ import { defaultReactionOptions, type ReactionOptions } from '../reactionOptions import type { ReactionGroupResponse } from 'stream-chat'; import type { ReactionsComparator } from '../types'; +import { convertDateToTimestamp } from '../../../mock-builders'; const USER_ID = 'mark'; @@ -116,15 +117,19 @@ describe('MessageReactions', () => { reaction_groups: { haha: fromPartial({ count: 2, - first_reaction_at: new Date().toISOString(), + first_reaction_at: convertDateToTimestamp(new Date().toISOString()), }), like: fromPartial({ count: 8, - first_reaction_at: new Date(Date.now() + 60_000).toISOString(), + first_reaction_at: convertDateToTimestamp( + new Date(Date.now() + 60_000).toISOString(), + ), }), love: fromPartial({ count: 5, - first_reaction_at: new Date(Date.now() + 120_000).toISOString(), + first_reaction_at: convertDateToTimestamp( + new Date(Date.now() + 120_000).toISOString(), + ), }), }, }); diff --git a/src/components/SummarizedMessagePreview/__tests__/useLatestMessagePreview.test.tsx b/src/components/SummarizedMessagePreview/__tests__/useLatestMessagePreview.test.tsx index 3b1b47d500..6bac566c1d 100644 --- a/src/components/SummarizedMessagePreview/__tests__/useLatestMessagePreview.test.tsx +++ b/src/components/SummarizedMessagePreview/__tests__/useLatestMessagePreview.test.tsx @@ -22,6 +22,7 @@ import { } from '../../../mock-builders'; import { generateStaticLocationResponse } from '../../../mock-builders/generator/sharedLocation'; import { generatePoll } from '../../../mock-builders/generator/poll'; +import { convertDateToTimestamp } from '../../../mock-builders'; const ownUser = generateUser({ id: 'own-user' }); const otherUser = generateUser({ id: 'other-user', name: 'Other User' }); @@ -156,7 +157,10 @@ describe('useLatestMessagePreview', () => { describe('deleted message', () => { it.each([ - ['deleted_at timestamp', { deleted_at: new Date().toISOString() }], + [ + 'deleted_at timestamp', + { deleted_at: convertDateToTimestamp(new Date().toISOString()) }, + ], ['deleted type', { type: 'deleted' as const }], ['deleted for current user', { deleted_for_me: true }], ])( diff --git a/src/components/Threads/ThreadList/__tests__/utils.a11y.test.ts b/src/components/Threads/ThreadList/__tests__/utils.a11y.test.ts index a319664afa..97493db3d2 100644 --- a/src/components/Threads/ThreadList/__tests__/utils.a11y.test.ts +++ b/src/components/Threads/ThreadList/__tests__/utils.a11y.test.ts @@ -8,6 +8,7 @@ import { composeThreadListItemAccessibleLabel, DEFAULT_THREAD_LIST_ITEM_LABEL_ORDER, } from '../utils.a11y'; +import { convertDateToTimestamp } from '../../../../mock-builders'; const t = mockT as TranslationContextValue['t']; @@ -19,7 +20,9 @@ const client = fromPartial({ userID: 'me' }); const baseData = { client, displayTitle: 'General', - latestReply: fromPartial({ created_at: new Date() }), + latestReply: fromPartial({ + created_at: convertDateToTimestamp(new Date()), + }), parentMessagePreview: 'hello world', replyCount: 3, t, diff --git a/src/mock-builders/generator/index.ts b/src/mock-builders/generator/index.ts index 9d3b27eaad..8c5edd6268 100644 --- a/src/mock-builders/generator/index.ts +++ b/src/mock-builders/generator/index.ts @@ -8,4 +8,5 @@ export * from './poll'; export * from './reaction'; export * from './reminder'; export * from './sharedLocation'; +export * from './time'; export * from './user'; diff --git a/src/mock-builders/generator/message.ts b/src/mock-builders/generator/message.ts index ef12ee81f4..341b27a5c5 100644 --- a/src/mock-builders/generator/message.ts +++ b/src/mock-builders/generator/message.ts @@ -1,31 +1,28 @@ import { nanoid } from 'nanoid'; -import type { LocalMessage, MessageResponse } from 'stream-chat'; +import type { LocalMessage } from 'stream-chat'; import type { DeepPartial } from '../../types/types'; import { convertDateToTimestamp } from './time'; -type GenerateMessageOptions = Omit< - DeepPartial, - 'created_at' | 'updated_at' -> & { - created_at?: Date | number | string; - updated_at?: Date | number | string; +/** + * Timestamp overrides are the unix-nanosecond numbers the API puts on the wire — a fixture holding + * a `Date` or an ISO string cannot catch the bugs that unit exists to prevent, and the compiler + * now says so. Where a test reads better against a date literal, convert at the call site with + * `convertDateToTimestamp`. `timestamp` is the shorthand for the common case of seeding + * both `created_at` and `updated_at` from one wall-clock value. It is deliberately not called + * `date`: `DateSeparatorMessage.date` is a real field on the rendered view-model, and a generator + * param of that name would swallow it. + */ +type GenerateMessageOptions = DeepPartial & { + timestamp?: Date | number | string; }; -/** The message fields the API sends as unix-nanosecond numbers. */ -const TIMESTAMP_FIELDS = [ - 'created_at', - 'updated_at', - 'deleted_at', - 'pinned_at', - 'pin_expires', - 'message_text_updated_at', -] as const; - export const generateMessage = (options?: GenerateMessageOptions): LocalMessage => { + const { timestamp: seed, ...overrides } = options ?? {}; + const timestamp = convertDateToTimestamp(seed); const data = { __html: '

regular

', attachments: [], - created_at: convertDateToTimestamp(), + created_at: timestamp, html: '

regular

', id: nanoid(), mentioned_users: [], @@ -33,21 +30,10 @@ export const generateMessage = (options?: GenerateMessageOptions): LocalMessage status: 'received', text: nanoid(), type: 'regular', - updated_at: convertDateToTimestamp(), + updated_at: timestamp, user: null, - ...options, + ...overrides, } as unknown as LocalMessage; - // Tests read better overriding a timestamp with a date literal, but the wire carries numbers — - // and a fixture handing the SDK a `Date` cannot catch the bugs that unit exists to prevent. - // Normalize every timestamp override here so no individual test has to. - for (const field of TIMESTAMP_FIELDS) { - const value = (data as unknown as Record)[field]; - if (value != null && typeof value !== 'number') { - (data as unknown as Record)[field] = convertDateToTimestamp( - value as Date | number | string, - ); - } - } if (data['reminder']) { (data['reminder'] as any).message_id = data.id; } diff --git a/src/plugins/ChannelDetail/Views/ChannelFilesView/__tests__/ChannelFilesView.test.tsx b/src/plugins/ChannelDetail/Views/ChannelFilesView/__tests__/ChannelFilesView.test.tsx index 94b9e7f072..305b5ac43a 100644 --- a/src/plugins/ChannelDetail/Views/ChannelFilesView/__tests__/ChannelFilesView.test.tsx +++ b/src/plugins/ChannelDetail/Views/ChannelFilesView/__tests__/ChannelFilesView.test.tsx @@ -138,7 +138,7 @@ const messages: MessageResponse[] = [ created_at: convertDateToTimestamp('2026-03-10T15:53:00.000Z'), id: 'message-1', type: 'regular', - updated_at: '2026-03-10T15:53:00.000Z', + updated_at: convertDateToTimestamp('2026-03-10T15:53:00.000Z'), user: { id: 'user-1', name: 'Alice' }, }, { @@ -154,7 +154,7 @@ const messages: MessageResponse[] = [ created_at: convertDateToTimestamp('2026-02-05T15:53:00.000Z'), id: 'message-2', type: 'regular', - updated_at: '2026-02-05T15:53:00.000Z', + updated_at: convertDateToTimestamp('2026-02-05T15:53:00.000Z'), user: { id: 'user-2', name: 'Bob' }, }, { @@ -173,7 +173,7 @@ const messages: MessageResponse[] = [ created_at: convertDateToTimestamp('2026-02-01T15:53:00.000Z'), id: 'message-3', type: 'regular', - updated_at: '2026-02-01T15:53:00.000Z', + updated_at: convertDateToTimestamp('2026-02-01T15:53:00.000Z'), user: { id: 'user-1', name: 'Alice' }, }, ]; diff --git a/src/plugins/ChannelDetail/Views/ChannelMemberDetailView/__tests__/ChannelMemberDetail.test.tsx b/src/plugins/ChannelDetail/Views/ChannelMemberDetailView/__tests__/ChannelMemberDetail.test.tsx index 1a05062c52..4ddae81f78 100644 --- a/src/plugins/ChannelDetail/Views/ChannelMemberDetailView/__tests__/ChannelMemberDetail.test.tsx +++ b/src/plugins/ChannelDetail/Views/ChannelMemberDetailView/__tests__/ChannelMemberDetail.test.tsx @@ -12,6 +12,7 @@ import { import { ChannelDetailProvider } from '../../../ChannelDetailContext'; import { ChannelMemberDetail } from '../ChannelMemberDetail'; import { mockT } from '../../../../../mock-builders/translator'; +import { convertDateToTimestamp } from '../../../../../mock-builders'; vi.mock('../../../../../context'); @@ -42,7 +43,7 @@ const createChannel = ({ 'user-2': { user: { id: 'user-2', - last_active: '2026-01-01T00:00:00.000000000Z', + last_active: convertDateToTimestamp('2026-01-01T00:00:00.000000000Z'), name: 'Bob', }, user_id: 'user-2', @@ -62,7 +63,7 @@ const createAction = (type: string, label: string) => ({ const otherMember = fromPartial({ user: { id: 'user-2', - last_active: '2026-01-01T00:00:00.000000000Z', + last_active: convertDateToTimestamp('2026-01-01T00:00:00.000000000Z'), name: 'Bob', }, user_id: 'user-2', diff --git a/src/plugins/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersBrowseView.test.tsx b/src/plugins/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersBrowseView.test.tsx index b20b98d36a..600aa080f0 100644 --- a/src/plugins/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersBrowseView.test.tsx +++ b/src/plugins/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersBrowseView.test.tsx @@ -11,6 +11,7 @@ import { useStateStore } from '../../../../../store'; import { ChannelMembersBrowseView } from '../ChannelMembersBrowseView'; import { createChannel, emitChannelEvent, renderWithChannel } from './testUtils'; import { mockT } from '../../../../../mock-builders/translator'; +import { convertDateToTimestamp } from '../../../../../mock-builders'; const mocks = vi.hoisted(() => ({ searchSourceActivate: vi.fn(), @@ -95,15 +96,15 @@ vi.mock('../../../../../components/Dialog', () => ({ const members: ChannelMemberResponse[] = [ { - created_at: '2026-01-01T00:00:00.000000000Z', - updated_at: '2026-01-01T00:00:00.000000000Z', + created_at: convertDateToTimestamp('2026-01-01T00:00:00.000000000Z'), + updated_at: convertDateToTimestamp('2026-01-01T00:00:00.000000000Z'), user: { id: 'user-1', name: 'Alice' }, user_id: 'user-1', }, { channel_role: 'admin', - created_at: '2026-01-01T00:00:00.000000000Z', - updated_at: '2026-01-01T00:00:00.000000000Z', + created_at: convertDateToTimestamp('2026-01-01T00:00:00.000000000Z'), + updated_at: convertDateToTimestamp('2026-01-01T00:00:00.000000000Z'), user: { id: 'user-2', name: 'Bob' }, user_id: 'user-2', }, From 1c8d7d7c1468e39bb939ef365873c8f79716c792 Mon Sep 17 00:00:00 2001 From: Zita Szupera Date: Wed, 2 Sep 2026 13:04:23 +0200 Subject: [PATCH 5/8] fix: more review fixes --- .../__tests__/ChannelListItem.test.tsx | 6 +- ...nelListItemActionButtons.defaults.test.tsx | 6 +- .../Message/ReminderNotification.tsx | 12 ++- .../__tests__/ReminderNotification.test.tsx | 5 +- .../__tests__/MessageList.test.tsx | 2 +- .../VirtualizedMessageListComponents.test.tsx | 4 +- .../MessageList/__tests__/utils.test.ts | 84 +++++++++++++++++++ .../useMessageListScrollManager.test.tsx | 4 +- ...adMessagesNotificationVirtualized.test.tsx | 6 +- src/components/MessageList/utils.ts | 25 +++--- src/mock-builders/event/messageDelivered.ts | 9 +- 11 files changed, 135 insertions(+), 28 deletions(-) diff --git a/src/components/ChannelListItem/__tests__/ChannelListItem.test.tsx b/src/components/ChannelListItem/__tests__/ChannelListItem.test.tsx index fc0799c4af..f3d83e0eaf 100644 --- a/src/components/ChannelListItem/__tests__/ChannelListItem.test.tsx +++ b/src/components/ChannelListItem/__tests__/ChannelListItem.test.tsx @@ -331,7 +331,11 @@ describe('ChannelPreview', () => { // it in sync with its own `mutedChannels`), not the imperative `channel.muteStatus()`. act(() => c0.state.partialNext({ - muteStatus: { createdAt: new Date(), expiresAt: new Date(), muted: true }, + muteStatus: { + createdAt: convertDateToTimestamp(new Date()), + expiresAt: convertDateToTimestamp(new Date()), + muted: true, + }, }), ); diff --git a/src/components/ChannelListItem/__tests__/ChannelListItemActionButtons.defaults.test.tsx b/src/components/ChannelListItem/__tests__/ChannelListItemActionButtons.defaults.test.tsx index 45426320b7..300039ad48 100644 --- a/src/components/ChannelListItem/__tests__/ChannelListItemActionButtons.defaults.test.tsx +++ b/src/components/ChannelListItem/__tests__/ChannelListItemActionButtons.defaults.test.tsx @@ -30,7 +30,11 @@ describe('ChannelListItemActionButtons defaults', () => { const setMuted = (channel: Channel) => channel.state.partialNext({ - muteStatus: { createdAt: new Date(), expiresAt: null, muted: true }, + muteStatus: { + createdAt: convertDateToTimestamp(new Date()), + expiresAt: null, + muted: true, + }, }); beforeEach(() => { diff --git a/src/components/Message/ReminderNotification.tsx b/src/components/Message/ReminderNotification.tsx index 1066e09813..7106586470 100644 --- a/src/components/Message/ReminderNotification.tsx +++ b/src/components/Message/ReminderNotification.tsx @@ -30,15 +30,17 @@ function RemindMeContent({ reminder }: { reminder: Reminder }) { const { timeLeftMs } = useStateStore(reminder?.state, reminderStateSelector) ?? {}; const stopRefreshBoundaryMs = reminder?.timer.stopRefreshBoundaryMs; + // Nullish, not truthy: `remindAt` is unix nanoseconds and `0` is a legitimate value (the epoch), + // so a truthiness guard leaves an overdue epoch reminder with no refresh boundary at all. const stopRefreshTimeStamp = - reminder?.remindAt && stopRefreshBoundaryMs + reminder?.remindAt != null && stopRefreshBoundaryMs != null ? nsToMs(reminder.remindAt) + stopRefreshBoundaryMs : undefined; const isBehindRefreshBoundary = - !!stopRefreshTimeStamp && new Date().getTime() > stopRefreshTimeStamp; + stopRefreshTimeStamp != null && new Date().getTime() > stopRefreshTimeStamp; - if (timeLeftMs === null || !reminder.remindAt) return null; + if (timeLeftMs === null || reminder.remindAt == null) return null; const nowMs = Date.now(); const remindAtMs = nsToMs(reminder.remindAt); @@ -105,7 +107,9 @@ function RemindMeContent({ reminder }: { reminder: Reminder }) { export const ReminderNotification = ({ reminder }: ReminderNotificationProps) => { if (!reminder) return null; - if (!reminder.remindAt) { + // Nullish, not truthy: `remindAt` is `null` only when the message is saved for later without a + // deadline. `0` is a real deadline (the epoch) and must render as an overdue reminder. + if (reminder.remindAt == null) { return ; } diff --git a/src/components/Message/__tests__/ReminderNotification.test.tsx b/src/components/Message/__tests__/ReminderNotification.test.tsx index 01e91ba814..d87ba551d9 100644 --- a/src/components/Message/__tests__/ReminderNotification.test.tsx +++ b/src/components/Message/__tests__/ReminderNotification.test.tsx @@ -36,9 +36,12 @@ describe('ReminderNotification', () => { expect(container).toMatchSnapshot(); }); it('displays text for reminder deadline if trespassed the refresh boundary', async () => { + // `remind_at` is unix nanoseconds, so the epoch is `0` — a `Date` here would type-check through + // the raw `data` override and exercise a path the wire never produces. `0` is falsy, so a + // truthiness guard renders "Saved for later" for what is really a long-overdue reminder. const reminder = new Reminder({ data: generateReminderResponse({ - data: { remind_at: new Date(0) }, + data: { remind_at: 0 }, }), }); const { container } = await renderComponent({ reminder }); diff --git a/src/components/MessageList/__tests__/MessageList.test.tsx b/src/components/MessageList/__tests__/MessageList.test.tsx index 9fb380c8d9..5da17ffd26 100644 --- a/src/components/MessageList/__tests__/MessageList.test.tsx +++ b/src/components/MessageList/__tests__/MessageList.test.tsx @@ -674,7 +674,7 @@ describe('MessageList', () => { // read state on a first-page query in production). Seed it here to mirror an unread channel. channel.messagePaginator.setUnreadSnapshot({ firstUnreadMessageId: messages[3].id, - lastReadAt: new Date(messages[2].created_at), + lastReadAt: messages[2].created_at, lastReadMessageId: messages[2].id, unreadCount: 2, }); diff --git a/src/components/MessageList/__tests__/VirtualizedMessageListComponents.test.tsx b/src/components/MessageList/__tests__/VirtualizedMessageListComponents.test.tsx index 3dd560818a..c9d9aca4cc 100644 --- a/src/components/MessageList/__tests__/VirtualizedMessageListComponents.test.tsx +++ b/src/components/MessageList/__tests__/VirtualizedMessageListComponents.test.tsx @@ -505,7 +505,7 @@ describe('VirtualizedMessageComponents', () => { it('should be rendered above the first unread message if unread count is non-zero', async () => { const { container } = await renderMarkUnread({ virtuosoContext: { - lastReadDate: new Date(messages[0].created_at), + lastReadDate: messages[0].created_at, lastReadMessageId: messages[0].id, lastReceivedMessageId: messages[1].id, messageGroupStyles: {}, @@ -530,7 +530,7 @@ describe('VirtualizedMessageComponents', () => { it('should not be rendered below the last read message if the message is the newest in the channel', async () => { const { container } = await renderMarkUnread({ virtuosoContext: { - lastReadDate: new Date(messages[1].created_at), + lastReadDate: messages[1].created_at, lastReadMessageId: messages[1].id, lastReceivedMessageId: messages[1].id, messageGroupStyles: {}, diff --git a/src/components/MessageList/__tests__/utils.test.ts b/src/components/MessageList/__tests__/utils.test.ts index 5daa8aa6c9..7e31a6622a 100644 --- a/src/components/MessageList/__tests__/utils.test.ts +++ b/src/components/MessageList/__tests__/utils.test.ts @@ -511,6 +511,50 @@ describe('processMessages', () => { expect(reviewProcessedMessage.mock.calls[i][0].changes[0].id).toBe(msg.id); }); }); + + // The other separator assertions in this file use `toMatchObject`, which is a subset match and so + // pins nothing about the shape. These two do, because the shape is public: `customMessageRenderer` + // receives the enriched list, and integrators discriminate it on `customType`. In particular there + // is deliberately no `type` field — a separator is a view-model, not a message. + describe('the date separator shape', () => { + const expectedSeparator = (message: LocalMessage) => ({ + customType: CUSTOM_MESSAGE_TYPE.date, + date: convertTimestampToDate(message.created_at), + id: makeDateMessageId(convertTimestampToDate(message.created_at)), + }); + + it('carries only customType, date and id for a plain day divider', () => { + const message = generateMessage({ + created_at: convertDateToTimestamp('2026-01-01'), + user: { id: myUserId }, + }); + + const [separator] = processMessages({ + ...enableDateSeparatorParams, + messages: [message], + userId: myUserId, + }); + + expect(separator).toStrictEqual(expectedSeparator(message)); + }); + + it('adds only `unread` for the unread separator', () => { + const message = generateMessage({ + created_at: convertDateToTimestamp('2026-01-01'), + user: { id: otherUserId }, + }); + + const [separator] = processMessages({ + ...enableDateSeparatorParams, + // The epoch as "nothing read yet", so the message counts as unread. + lastRead: 0, + messages: [message], + userId: myUserId, + }); + + expect(separator).toStrictEqual({ ...expectedSeparator(message), unread: true }); + }); + }); }); describe('getGroupStyles', () => { @@ -786,6 +830,46 @@ describe('getGroupStyles', () => { 'single', ); }); + + // `created_at` is unix nanoseconds, so the epoch is `0` — a legitimate wire value that is falsy. + // A truthiness guard in front of the time-gap calculation skips the cutoff entirely, leaving + // messages grouped however far apart they are. + describe('with a message created at the epoch', () => { + it('applies the cutoff when the previous message is at the epoch', () => { + const maxTimeBetweenGroupedMessages = 10; + previousMessage = { ...previousMessage, created_at: 0 }; + message = { ...message, created_at: msToNs(12) }; + + // 12ms apart, so the previous message must not be grouped with this one. A truthiness guard + // skips the comparison and reports 'bottom' instead. + expect( + getGroupStyles( + message, + previousMessage, + nextMessage, + noGroupByUser, + maxTimeBetweenGroupedMessages, + ), + ).toBe('single'); + }); + + it('applies the cutoff when the message itself is at the epoch', () => { + const maxTimeBetweenGroupedMessages = 10; + message = { ...message, created_at: 0 }; + nextMessage = { ...nextMessage, created_at: msToNs(12) }; + + // The symmetric branch: a truthiness guard reports 'middle' and glues the next message on. + expect( + getGroupStyles( + message, + previousMessage, + nextMessage, + noGroupByUser, + maxTimeBetweenGroupedMessages, + ), + ).toBe('bottom'); + }); + }); }); describe('insertIntro', () => { diff --git a/src/components/MessageList/hooks/__tests__/useMessageListScrollManager.test.tsx b/src/components/MessageList/hooks/__tests__/useMessageListScrollManager.test.tsx index 2a74f3bece..9f0869dd19 100644 --- a/src/components/MessageList/hooks/__tests__/useMessageListScrollManager.test.tsx +++ b/src/components/MessageList/hooks/__tests__/useMessageListScrollManager.test.tsx @@ -220,7 +220,7 @@ describe('useMessageListScrollManager', () => { ([{ id: 100, user: { id: client.userID } }]), + fromPartial([{ id: '100', user: { id: client.userID } }]), )} offsetHeight={100} scrollHeight={600} @@ -262,7 +262,7 @@ describe('useMessageListScrollManager', () => { rerender( { @@ -122,7 +122,7 @@ describe('useUnreadMessagesNotificationVirtualized', () => { ])( '%s show notification if the last rendered message was created earlier than last read when showUnreadNotificationAlways is %s', async (_, showUnreadNotificationAlways) => { - const now = new Date(); + const now = nowNs(); const firstRenderedMsgCreated = now - msToNs(1002); const lastRenderedMsgCreated = now - msToNs(1001); const lastRead = now - msToNs(1000); @@ -147,7 +147,7 @@ describe('useUnreadMessagesNotificationVirtualized', () => { it.each([[true], [false]])( 'should not show notification if the first rendered message was created earlier than last read when showUnreadNotificationAlways is %s', async (showUnreadNotificationAlways) => { - const now = new Date(); + const now = nowNs(); const firstRenderedMsgCreated = now - msToNs(1002); const lastRead = now - msToNs(1001); const messages = [ diff --git a/src/components/MessageList/utils.ts b/src/components/MessageList/utils.ts index 6ce8bf7f2b..f395490e49 100644 --- a/src/components/MessageList/utils.ts +++ b/src/components/MessageList/utils.ts @@ -4,12 +4,7 @@ import { CUSTOM_MESSAGE_TYPE } from '../../constants/messageTypes'; import { isMessageEdited } from '../Message/utils'; import { isDate } from '../../i18n'; -import type { - Channel, - LocalMessage, - MessageLabel, - UnreadSnapshotState, -} from 'stream-chat'; +import type { Channel, LocalMessage, UnreadSnapshotState } from 'stream-chat'; import { convertTimestampToDate, nsToMs } from 'stream-chat'; type IntroMessage = { @@ -26,8 +21,12 @@ type DateSeparatorMessage = { */ date: Date; id: string; - type: MessageLabel; - unread: boolean; + /** + * Only the unread separator sets this; the plain day divider leaves it absent. There is + * deliberately no `type`: a separator is a view-model, not a message, and `IntroMessage` declares + * none either — every consumer narrows the union with `isDateSeparatorMessage` first. + */ + unread?: boolean; }; export type RenderedMessage = LocalMessage | DateSeparatorMessage | IntroMessage; @@ -288,8 +287,10 @@ export const getGroupStyles = ( (message.reaction_groups && isNonEmptyRecord(message.reaction_groups)) || isMessageEdited(previousMessage) || (maxTimeBetweenGroupedMessages !== undefined && - previousMessage.created_at && - message.created_at && + // Nullish, not truthy: `0` is a legitimate wire timestamp (the epoch), and skipping the + // cutoff for it would keep messages grouped past `maxTimeBetweenGroupedMessages`. + previousMessage.created_at != null && + message.created_at != null && nsToMs(message.created_at - previousMessage.created_at) > maxTimeBetweenGroupedMessages); @@ -304,8 +305,8 @@ export const getGroupStyles = ( (nextMessage.reaction_groups && isNonEmptyRecord(nextMessage.reaction_groups)) || isMessageEdited(message) || (maxTimeBetweenGroupedMessages !== undefined && - nextMessage.created_at && - message.created_at && + nextMessage.created_at != null && + message.created_at != null && nsToMs(nextMessage.created_at - message.created_at) > maxTimeBetweenGroupedMessages); diff --git a/src/mock-builders/event/messageDelivered.ts b/src/mock-builders/event/messageDelivered.ts index 11ea09eea9..dea542d1e0 100644 --- a/src/mock-builders/event/messageDelivered.ts +++ b/src/mock-builders/event/messageDelivered.ts @@ -1,6 +1,7 @@ import type { Channel, CustomChannelData, + CustomEventData, Event, StreamChat, UserResponse, @@ -13,7 +14,10 @@ type MessageDeliveredEvent = { channel_member_count: number; channel_type: string; cid: string; - created_at: string; + // `created_at` is unix nanoseconds like every other wire timestamp, but `last_delivered_at` is + // the one field the spec still declares as a bare string, so it really does arrive as RFC3339. + created_at: number; + custom: CustomEventData; last_delivered_at: string; last_delivered_message_id: string; user: UserResponse; @@ -29,6 +33,7 @@ export const makeMessageDeliveredEvent = ( channel_type: 'messaging', cid: 'messaging:test', created_at: convertDateToTimestamp('2025-09-16T13:25:57.996011272Z'), + custom: {}, last_delivered_at: '2025-09-16T13:25:57Z', last_delivered_message_id: 'aefbf38a-0e02-4ba6-a480-e595c37ec78a', type: 'message.delivered', @@ -36,7 +41,9 @@ export const makeMessageDeliveredEvent = ( banned: false, blocked_user_ids: [], created_at: convertDateToTimestamp('2025-09-16T09:01:40.650479Z'), + custom: {}, id: 'test1', + language: '', last_active: convertDateToTimestamp('2025-09-16T13:22:52.69594176Z'), online: true, role: 'user', From 28070fe964cee399648fe7bb5bc9df75e27c43db Mon Sep 17 00:00:00 2001 From: Zita Szupera Date: Wed, 2 Sep 2026 14:21:03 +0200 Subject: [PATCH 6/8] fix: more review fixes --- ai-docs/ai-migration-v14-v15.md | 30 +++++++++++++++-------- ai-docs/i18n-v15-migration.md | 7 ++++++ src/components/Attachment/Geolocation.tsx | 6 ++--- src/components/MessageList/utils.ts | 6 +---- 4 files changed, 31 insertions(+), 18 deletions(-) diff --git a/ai-docs/ai-migration-v14-v15.md b/ai-docs/ai-migration-v14-v15.md index 328749e6e8..f819a567e3 100644 --- a/ai-docs/ai-migration-v14-v15.md +++ b/ai-docs/ai-migration-v14-v15.md @@ -71,15 +71,18 @@ To ingest an ad-hoc channel (e.g. navigating to a DM or search result) into the wire — `created_at`, `updated_at`, `last_read`, and every sibling on a response or event. It is not a `Date` and not an ISO string, and the React types that carry those values through changed with it. -Two failure modes are silent, because neither is a type error: +Two failure modes, neither of which is a type error: -- `new Date(ns)` is **out of range**. `Date` tops out near 8.64e15 ms while a current timestamp is - ~1.79e18, so you get an `Invalid Date` — and `.toISOString()` on one throws - `RangeError: Invalid time value`, usually mid-render. -- Date libraries read a bare number as **milliseconds**, so `dayjs(created_at)` renders a date roughly - 50,000 years out without complaining. +- **Every `Date`-based path is out of range.** `Date` tops out near 8.64e15 ms while a current + timestamp is ~1.79e18, and a date library reads a bare number as **milliseconds** — so both land on + an invalid instance rather than on a plausible wrong date. `.toISOString()` throws + `RangeError: Invalid time value`, usually mid-render; `dayjs(created_at).format()` instead returns + the literal string `Invalid Date` and renders it on screen. +- **A unit mix-up between two `number`s is the silent one.** Comparing a wire timestamp against + `Date.now()`, or adding a millisecond duration to one, produces a plausible-looking number and no + complaint at all — see `headerPosition` below for a case with no type change to warn you. -### The three public React types that changed +### The public React types that changed | Type | v14 | v15 | | ----------------------------------------------------- | ----------------------------- | ------------------------------- | @@ -87,6 +90,13 @@ Two failure modes are silent, because neither is a type error: | `ProcessMessagesParams.lastRead` (`processMessages`) | `Date \| null` | `number \| null` | | `VirtualizedMessageList` render props: `lastReadDate` | `Date \| null` | `number \| null` | +`DateSeparatorMessage` (a member of the exported `RenderedMessage` union) changed shape rather than +type: it **lost its `type: MessageLabel` field**, and `unread` is now optional. The `type` field was +never actually populated — every construction site cast the object into place without it — so reading +it was already `undefined` at runtime; it now fails to compile. `unread` is set only by the unread +separator; the plain day divider omits it. Narrow with `isDateSeparatorMessage` rather than checking +either field. + Comparisons get simpler, not harder — compare and sort the raw numbers and drop the `Date` round-trip: ```ts @@ -105,9 +115,9 @@ for instance. Convert at that boundary with the guarded helper `stream-chat` exp `convertTimestampToDate` returns `Date | undefined` — `undefined` for an absent or non-finite value. **Handle that `undefined`; do not cast it away.** A prop typed `date: Date` will accept it through a -cast and then fail somewhere further along: `DateSeparator`'s own `isDate(date)` type guard rejects it, -so the list stops recognising the object as a separator and renders it as an ordinary message — an -empty row where the day divider belonged, with no error and no type error. +cast and then fail somewhere further along: `isDateSeparatorMessage` (`src/components/MessageList/utils.ts`) +gates on `isDate(message.date)`, so the list stops recognising the object as a separator and renders it +as an ordinary message — an empty row where the day divider belonged, with no error and no type error. ```ts import { convertTimestampToDate } from 'stream-chat'; diff --git a/ai-docs/i18n-v15-migration.md b/ai-docs/i18n-v15-migration.md index e5c321380b..58c09f5180 100644 --- a/ai-docs/i18n-v15-migration.md +++ b/ai-docs/i18n-v15-migration.md @@ -378,6 +378,13 @@ find . -maxdepth 4 -name dayjs -type d -path '*node_modules*' # expect exactly ## Date and time +> **Before anything on this page:** every timestamp you hand a formatter is now a unix-**nanosecond** +> number, and the `t('timestamp.X', { timestamp })` path is **not type-checked** — i18next's +> interpolation bag is untyped, so a raw wire number compiles and renders the literal text +> `Invalid Date`. `getDateString`'s `messageCreatedAt` _is_ typed (`string | Date`). If a timestamp is +> rendering wrong or blank, check the conversion first; see +> [Dates on response types are unix-nanosecond numbers](./ai-migration-v14-v15.md#dates-on-response-types-are-unix-nanosecond-numbers). + Only the `en` dayjs locale is bundled, and the per-language `calendar` formats the SDK used to ship are gone. For any other language, import the locale and supply the calendar config: diff --git a/src/components/Attachment/Geolocation.tsx b/src/components/Attachment/Geolocation.tsx index 06039fa512..ced3c4d8fc 100644 --- a/src/components/Attachment/Geolocation.tsx +++ b/src/components/Attachment/Geolocation.tsx @@ -27,15 +27,15 @@ export const Geolocation = ({ const { t } = useTranslationContext(); const [stoppedSharing, setStoppedSharing] = useState( - !!location.end_at && location.end_at < nowNs(), + location.end_at != null && location.end_at < nowNs(), ); const timeoutRef = useRef | undefined>(undefined); const isMyLocation = location.user_id === client.userID; - const isLiveLocation = !!location.end_at; + const isLiveLocation = location.end_at != null; useEffect(() => { - if (!location.end_at) return; + if (location.end_at == null) return; clearTimeout(timeoutRef.current); timeoutRef.current = setTimeout( () => setStoppedSharing(true), diff --git a/src/components/MessageList/utils.ts b/src/components/MessageList/utils.ts index f395490e49..43299fe140 100644 --- a/src/components/MessageList/utils.ts +++ b/src/components/MessageList/utils.ts @@ -238,12 +238,8 @@ export const insertIntro = (messages: RenderedMessage[], headerPosition?: number // header position is smaller than message time so comes after; if (messageTime < headerPosition) { // if header position is also smaller than message time continue; - if (nextMessageTime && nextMessageTime < headerPosition) { + if (nextMessageTime != null && nextMessageTime < headerPosition) { if (messages[i + 1] && isDateSeparatorMessage(messages[i + 1])) continue; - if (!nextMessageTime) { - newMessages.push(intro); - return newMessages; - } } else { newMessages.splice(i + 1, 0, intro); return newMessages; From 538fe046450388770219c4306756294ee4733343 Mon Sep 17 00:00:00 2001 From: Zita Szupera Date: Wed, 2 Sep 2026 15:24:10 +0200 Subject: [PATCH 7/8] fix: null checks in sample apps --- examples/vite/src/ChatLayout/ChannelMembersRemoveView.tsx | 2 +- examples/vite/src/CustomMessageUi/variants.tsx | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/examples/vite/src/ChatLayout/ChannelMembersRemoveView.tsx b/examples/vite/src/ChatLayout/ChannelMembersRemoveView.tsx index 0b7cf8fafc..5282615572 100644 --- a/examples/vite/src/ChatLayout/ChannelMembersRemoveView.tsx +++ b/examples/vite/src/ChatLayout/ChannelMembersRemoveView.tsx @@ -42,7 +42,7 @@ const getPresenceStatusText = ( ) => { if (user?.online) return t('common.online.label', 'Online'); - if (user?.last_active) { + if (user?.last_active != null) { return t( 'channelDetail.channelMemberDetail.lastSeen.label', 'Last seen {{ timestamp }}', diff --git a/examples/vite/src/CustomMessageUi/variants.tsx b/examples/vite/src/CustomMessageUi/variants.tsx index daa529ad91..64b9bbf898 100644 --- a/examples/vite/src/CustomMessageUi/variants.tsx +++ b/examples/vite/src/CustomMessageUi/variants.tsx @@ -309,10 +309,10 @@ export const CustomMessageUi_V7 = () => { return (
- {message.deleted_at && ( + {message.deleted_at != null && (
This message has been deleted...
)} - {!message.deleted_at && ( + {message.deleted_at == null && ( <>
{ return (
- {message.deleted_at && ( + {message.deleted_at != null && (
This message has been deleted...
)} - {!message.deleted_at && ( + {message.deleted_at == null && ( <>
Date: Wed, 2 Sep 2026 18:07:35 +0200 Subject: [PATCH 8/8] fix: more review fixes --- .../ChannelListItemActionButtons.defaults.tsx | 20 ++++++++++--------- .../useMessageDeliveryStatus.test.tsx | 17 +++++----------- .../Message/MessageEditedIndicator.tsx | 2 +- src/components/Message/utils.tsx | 6 ++++-- .../MessageActions.defaults.tsx | 4 ++-- .../__tests__/MessageList.test.tsx | 18 +++++------------ ...seUnreadMessagesNotificationVirtualized.ts | 4 ++-- .../ChannelManagementView.tsx | 2 +- .../ChannelMemberDetail.tsx | 2 +- .../ChannelMembersBrowseView.tsx | 2 +- 10 files changed, 33 insertions(+), 44 deletions(-) diff --git a/src/components/ChannelListItem/ChannelListItemActionButtons.defaults.tsx b/src/components/ChannelListItem/ChannelListItemActionButtons.defaults.tsx index 75d3c806b9..0f9c0c7876 100644 --- a/src/components/ChannelListItem/ChannelListItemActionButtons.defaults.tsx +++ b/src/components/ChannelListItem/ChannelListItemActionButtons.defaults.tsx @@ -96,7 +96,7 @@ const useArchiveAction = (): ChannelActionBehavior => { const toggle = async () => { try { - if (membership.archived_at) { + if (membership.archived_at != null) { await channel.unarchive(); addNotification({ context: { channel }, @@ -132,9 +132,10 @@ const useArchiveAction = (): ChannelActionBehavior => { return { 'aria-pressed': membership.archived_at != null, - title: membership.archived_at - ? t('channelListItem.unarchive.title', 'Unarchive') - : t('channelListItem.archive.title', 'Archive'), + title: + membership.archived_at != null + ? t('channelListItem.unarchive.title', 'Unarchive') + : t('channelListItem.archive.title', 'Archive'), toggle, }; }; @@ -302,7 +303,7 @@ const usePinAction = (): ChannelActionBehavior => { const toggle = async () => { try { - if (membership.pinned_at) { + if (membership.pinned_at != null) { await channel.unpin(); addNotification({ context: { channel }, @@ -337,10 +338,11 @@ const usePinAction = (): ChannelActionBehavior => { }; return { - 'aria-pressed': !!membership.pinned_at, - title: membership.pinned_at - ? t('common.unpin.title', 'Unpin') - : t('common.pin.title', 'Pin'), + 'aria-pressed': membership.pinned_at != null, + title: + membership.pinned_at != null + ? t('common.unpin.title', 'Unpin') + : t('common.pin.title', 'Pin'), toggle, }; }; diff --git a/src/components/ChannelListItem/hooks/__tests__/useMessageDeliveryStatus.test.tsx b/src/components/ChannelListItem/hooks/__tests__/useMessageDeliveryStatus.test.tsx index 8842baefe6..866f0218dc 100644 --- a/src/components/ChannelListItem/hooks/__tests__/useMessageDeliveryStatus.test.tsx +++ b/src/components/ChannelListItem/hooks/__tests__/useMessageDeliveryStatus.test.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { renderHook } from '@testing-library/react'; import type { Channel, LocalMessage, MessageResponse, StreamChat } from 'stream-chat'; +import { nsToMs } from 'stream-chat'; import { MessageDeliveryStatus, useMessageDeliveryStatus, @@ -328,9 +329,7 @@ describe('Message delivery status', () => { dispatchMessageDeliveredEvent({ channel, client, - deliveredAt: new Date( - new Date(lastMessage.created_at).getTime() + 1000, - ).toISOString(), + deliveredAt: new Date(nsToMs(lastMessage.created_at) + 1000).toISOString(), lastDeliveredMessageId: lastMessage.id, user: otherUser, }); @@ -348,9 +347,7 @@ describe('Message delivery status', () => { dispatchMessageDeliveredEvent({ channel, client, - deliveredAt: new Date( - new Date(lastMessage.created_at).getTime() + 1000, - ).toISOString(), + deliveredAt: new Date(nsToMs(lastMessage.created_at) + 1000).toISOString(), lastDeliveredMessageId: lastMessage.id, user: ownUser, }); @@ -368,9 +365,7 @@ describe('Message delivery status', () => { dispatchMessageDeliveredEvent({ channel, client, - deliveredAt: new Date( - new Date(lastMessage.created_at).getTime() + 1000, - ).toISOString(), + deliveredAt: new Date(nsToMs(lastMessage.created_at) + 1000).toISOString(), lastDeliveredMessageId: lastMessage.id, user: otherUser, }); @@ -388,9 +383,7 @@ describe('Message delivery status', () => { dispatchMessageDeliveredEvent({ channel, client, - deliveredAt: new Date( - new Date(lastMessage.created_at).getTime() + 1000, - ).toISOString(), + deliveredAt: new Date(nsToMs(lastMessage.created_at) + 1000).toISOString(), lastDeliveredMessageId: 'another-message-id', user: otherUser, }); diff --git a/src/components/Message/MessageEditedIndicator.tsx b/src/components/Message/MessageEditedIndicator.tsx index 9869041b10..9d2dad2d90 100644 --- a/src/components/Message/MessageEditedIndicator.tsx +++ b/src/components/Message/MessageEditedIndicator.tsx @@ -29,7 +29,7 @@ const UnMemoizedMessageEditedIndicator = (props: MessageEditedIndicatorProps) => const { handleEnter, handleLeave, tooltipVisible } = useEnterLeaveHandlers(); - if (!message?.message_text_updated_at) { + if (message?.message_text_updated_at == null) { return null; } diff --git a/src/components/Message/utils.tsx b/src/components/Message/utils.tsx index 5d2481d96f..4226a2795c 100644 --- a/src/components/Message/utils.tsx +++ b/src/components/Message/utils.tsx @@ -418,7 +418,9 @@ export const isMessageBlocked = ( (message.type === 'error' && message.moderation?.action === 'remove'); export const isMessageDeleted = (message: LocalMessage): boolean => - Boolean(message.deleted_at || message.type === 'deleted' || message.deleted_for_me); + Boolean( + message.deleted_at != null || message.type === 'deleted' || message.deleted_for_me, + ); export const isMessageEdited = (message: Pick) => - !!message.message_text_updated_at; + message.message_text_updated_at != null; diff --git a/src/components/MessageActions/MessageActions.defaults.tsx b/src/components/MessageActions/MessageActions.defaults.tsx index ec1bec935b..d5916e151c 100644 --- a/src/components/MessageActions/MessageActions.defaults.tsx +++ b/src/components/MessageActions/MessageActions.defaults.tsx @@ -398,7 +398,7 @@ const DefaultMessageActionComponents = { const { t } = useTranslationContext(); const { message } = useMessageContext(); const reminder = useMessageReminder(message.id); - const messageAlreadyBookmarked = reminder && !reminder?.remindAt; + const messageAlreadyBookmarked = reminder != null && reminder.remindAt == null; if (messageAlreadyBookmarked) return null; @@ -461,7 +461,7 @@ const DefaultMessageActionComponents = { const { message } = useMessageContext(); const { t } = useTranslationContext(); const reminder = useMessageReminder(message.id); - const messageAlreadyHasReminderScheduled = Boolean(reminder && reminder?.remindAt); + const messageAlreadyHasReminderScheduled = reminder?.remindAt != null; if (messageAlreadyHasReminderScheduled) return null; diff --git a/src/components/MessageList/__tests__/MessageList.test.tsx b/src/components/MessageList/__tests__/MessageList.test.tsx index 5da17ffd26..8d4cec0745 100644 --- a/src/components/MessageList/__tests__/MessageList.test.tsx +++ b/src/components/MessageList/__tests__/MessageList.test.tsx @@ -24,7 +24,7 @@ import { useChannel, useMessageContext, WithComponents } from '../../../context' import { EmptyStateIndicator as EmptyStateIndicatorMock } from '../../EmptyStateIndicator'; import { mockedApiResponse } from '../../../mock-builders/api/utils'; import { nanoid } from 'nanoid'; -import { StateStore } from 'stream-chat'; +import { msToNs, StateStore } from 'stream-chat'; import type { Channel as ChannelType, Event, @@ -654,9 +654,7 @@ describe('MessageList', () => { messages, read: [ { - last_read: convertDateToTimestamp( - new Date(messages[2].created_at).toISOString(), - ), + last_read: messages[2].created_at, last_read_message_id: messages[2].id, unread_messages: 2, user, @@ -709,9 +707,7 @@ describe('MessageList', () => { messages, read: [ { - last_read: convertDateToTimestamp( - new Date(lastMessage.created_at).toISOString(), - ), + last_read: lastMessage.created_at, last_read_message_id: lastMessage.id, unread_messages: 0, user, @@ -750,9 +746,7 @@ describe('MessageList', () => { const lastReadMessage = messages[3]; const replies = Array.from({ length: 3 }).map(() => generateMessage({ - created_at: convertDateToTimestamp( - new Date(new Date(parentMsg.created_at).getTime() + 1000 + 1).toISOString(), - ), + created_at: parentMsg.created_at + msToNs(1001), parent_id: parentMsg.id, }), ); @@ -765,9 +759,7 @@ describe('MessageList', () => { messages, read: [ { - last_read: convertDateToTimestamp( - new Date(lastReadMessage.created_at).toISOString(), - ), + last_read: lastReadMessage.created_at, last_read_message_id: lastReadMessage.id, unread_messages: 1, user, diff --git a/src/components/MessageList/hooks/VirtualizedMessageList/useUnreadMessagesNotificationVirtualized.ts b/src/components/MessageList/hooks/VirtualizedMessageList/useUnreadMessagesNotificationVirtualized.ts index f942c60b38..017b3291c6 100644 --- a/src/components/MessageList/hooks/VirtualizedMessageList/useUnreadMessagesNotificationVirtualized.ts +++ b/src/components/MessageList/hooks/VirtualizedMessageList/useUnreadMessagesNotificationVirtualized.ts @@ -51,9 +51,9 @@ export const useUnreadMessagesNotificationVirtualized = ({ const lastReadTime = lastReadAt ?? 0; const scrolledBelowSeparator = - !!lastReadTime && firstRenderedMessageTime > lastReadTime; + lastReadAt != null && firstRenderedMessageTime > lastReadTime; const scrolledAboveSeparator = - !!lastReadTime && lastRenderedMessageTime < lastReadTime; + lastReadAt != null && lastRenderedMessageTime < lastReadTime; setShow( showAlways diff --git a/src/plugins/ChannelDetail/Views/ChannelManagementView/ChannelManagementView.tsx b/src/plugins/ChannelDetail/Views/ChannelManagementView/ChannelManagementView.tsx index 7310c3aaa2..0b7add8cca 100644 --- a/src/plugins/ChannelDetail/Views/ChannelManagementView/ChannelManagementView.tsx +++ b/src/plugins/ChannelDetail/Views/ChannelManagementView/ChannelManagementView.tsx @@ -80,7 +80,7 @@ export const ChannelManagementInfoBody = ({ // from context (useChannel) instead of an argument. Verify this view renders within a // Channel/ChannelInstance subtree so the context channel matches `channel`. const onlineStatusText = useChannelHeaderOnlineStatus(); - const pinned = !!membership.pinned_at; + const pinned = membership.pinned_at != null; return ( diff --git a/src/plugins/ChannelDetail/Views/ChannelMemberDetailView/ChannelMemberDetail.tsx b/src/plugins/ChannelDetail/Views/ChannelMemberDetailView/ChannelMemberDetail.tsx index 44320ff947..f2e02889d1 100644 --- a/src/plugins/ChannelDetail/Views/ChannelMemberDetailView/ChannelMemberDetail.tsx +++ b/src/plugins/ChannelDetail/Views/ChannelMemberDetailView/ChannelMemberDetail.tsx @@ -36,7 +36,7 @@ const getPresenceStatusText = ( ) => { if (user?.online) return t('common.online.label', 'Online'); - if (user?.last_active) { + if (user?.last_active != null) { return t( 'channelDetail.channelMemberDetail.lastSeen.label', 'Last seen {{ timestamp }}', diff --git a/src/plugins/ChannelDetail/Views/ChannelMembersView/ChannelMembersBrowseView.tsx b/src/plugins/ChannelDetail/Views/ChannelMembersView/ChannelMembersBrowseView.tsx index b01e4c5544..66b0131b08 100644 --- a/src/plugins/ChannelDetail/Views/ChannelMembersView/ChannelMembersBrowseView.tsx +++ b/src/plugins/ChannelDetail/Views/ChannelMembersView/ChannelMembersBrowseView.tsx @@ -39,7 +39,7 @@ const getPresenceStatusText = ( ) => { if (user?.online) return t('common.online.label', 'Online'); - if (user?.last_active) { + if (user?.last_active != null) { return t( 'channelDetail.channelMemberDetail.lastSeen.label', 'Last seen {{ timestamp }}',