diff --git a/ai-docs/ai-migration-v14-v15.md b/ai-docs/ai-migration-v14-v15.md index 1d4c450b8..f819a567e 100644 --- a/ai-docs/ai-migration-v14-v15.md +++ b/ai-docs/ai-migration-v14-v15.md @@ -65,6 +65,102 @@ 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, neither of which is a type error: + +- **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 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` | + +`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 +// 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: + +`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: `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'; + +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 +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. + +### `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 +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 diff --git a/ai-docs/i18n-v15-migration.md b/ai-docs/i18n-v15-migration.md index e5c321380..58c09f518 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/examples/tutorial/package.json b/examples/tutorial/package.json index 8e085c42e..de3726271 100644 --- a/examples/tutorial/package.json +++ b/examples/tutorial/package.json @@ -16,7 +16,7 @@ "emoji-mart": "^5.6.0", "react": "^19.2.6", "react-dom": "^19.2.6", - "stream-chat": "10.0.0-rc.7", + "stream-chat": "10.0.0-rc.9", "stream-chat-react": "workspace:^" }, "devDependencies": { diff --git a/examples/vite/docs-playwright/screenshot-misc.ts b/examples/vite/docs-playwright/screenshot-misc.ts index eb7f3124c..223651439 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 95d051d14..bf62dae5e 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/package.json b/examples/vite/package.json index 96beb3be4..a755391db 100644 --- a/examples/vite/package.json +++ b/examples/vite/package.json @@ -17,7 +17,7 @@ "modern-normalize": "^3.0.1", "react": "^19.2.6", "react-dom": "^19.2.6", - "stream-chat": "10.0.0-rc.7", + "stream-chat": "10.0.0-rc.9", "stream-chat-react": "workspace:^" }, "devDependencies": { diff --git a/examples/vite/src/AppSettings/ActionsMenu/WebSocketEventPromptDialog/websocketEventAutomation.ts b/examples/vite/src/AppSettings/ActionsMenu/WebSocketEventPromptDialog/websocketEventAutomation.ts index 3356f31d8..95c18482d 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 b02beae21..1a86e7124 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 51164285c..be1e4cd33 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 d19d78593..528261557 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 { @@ -41,13 +42,13 @@ 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 }}', { 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 5c1ef4d68..64b9bbf89 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
@@ -308,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 && ( <>
({ 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/Attachment/Geolocation.tsx b/src/components/Attachment/Geolocation.tsx index 1c6f1ad0f..c58974217 100644 --- a/src/components/Attachment/Geolocation.tsx +++ b/src/components/Attachment/Geolocation.tsx @@ -7,9 +7,17 @@ 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; +/** `setTimeout` silently clamps a longer delay to 1 ms, so longer waits are armed in steps. */ +const MAX_TIMEOUT_MS = 2 ** 31 - 1; + +/** Whether a live location's expiry is a usable instant to schedule against. */ +const isSchedulableExpiry = (endAt?: number): endAt is number => + endAt != null && Number.isFinite(endAt); + export type GeolocationProps = { location: SharedLocationResponseData; GeolocationAttachmentMapPlaceholder?: ComponentType; @@ -26,21 +34,32 @@ export const Geolocation = ({ const { t } = useTranslationContext(); const [stoppedSharing, setStoppedSharing] = useState( - !!location.end_at && new Date(location.end_at).getTime() < new Date().getTime(), + isSchedulableExpiry(location.end_at) && 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; + const endAt = location.end_at; useEffect(() => { - if (!location.end_at) return; - clearTimeout(timeoutRef.current); - timeoutRef.current = setTimeout( - () => setStoppedSharing(true), - new Date(location.end_at).getTime() - Date.now(), - ); - }, [location.end_at]); + if (!isSchedulableExpiry(endAt)) return; + + const arm = () => { + // Both operands are wire timestamps, so the difference is in nanoseconds. Passing it raw + // made `setTimeout` fire immediately and end sharing on mount. + const remaining = Math.max(0, nsToMs(endAt - nowNs())); + timeoutRef.current = + remaining > MAX_TIMEOUT_MS + ? // Wait the maximum, then re-arm. One call would be clamped to 1 ms, showing + // "sharing ended" on mount for any share longer than ~24.9 days. + setTimeout(arm, MAX_TIMEOUT_MS) + : setTimeout(() => setStoppedSharing(true), remaining); + }; + + arm(); + return () => clearTimeout(timeoutRef.current); + }, [endAt]); return (
channel?.stopLiveLocationSharing(location)} + onClick={() => + // The request shape, not the whole response: `stopLiveLocationSharing` stamps + // `end_at` itself, and the response's timestamps are wire numbers a request + // field cannot take. + channel?.stopLiveLocationSharing({ message_id: location.message_id }) + } size='sm' variant='secondary' > @@ -78,7 +102,7 @@ export const Geolocation = ({ 'Live until {{ timestamp }}', { timestamp: t('timestamp.LiveLocation', { - timestamp: location.end_at, + timestamp: convertTimestampToDate(location.end_at), }), }, )} @@ -95,7 +119,7 @@ export const Geolocation = ({ 'Live until {{ timestamp }}', { timestamp: t('timestamp.LiveLocation', { - timestamp: location.end_at, + timestamp: convertTimestampToDate(location.end_at), }), }, )} diff --git a/src/components/Attachment/ModalGallery.tsx b/src/components/Attachment/ModalGallery.tsx index 6af5a2c45..1e906f3c5 100644 --- a/src/components/Attachment/ModalGallery.tsx +++ b/src/components/Attachment/ModalGallery.tsx @@ -1,3 +1,4 @@ +import { convertTimestampToDate } from 'stream-chat'; import React, { useCallback, useContext, useMemo, useState } from 'react'; import clsx from 'clsx'; @@ -69,7 +70,7 @@ export const ModalGallery = ({ () => items.map((item) => ({ ...item, - createdAt: item.createdAt ?? message?.created_at, + createdAt: item.createdAt ?? convertTimestampToDate(message?.created_at), user: item.user ?? message?.user ?? undefined, })), [items, message?.created_at, message?.user], diff --git a/src/components/Attachment/__tests__/Geolocation.test.tsx b/src/components/Attachment/__tests__/Geolocation.test.tsx index 5ec248b13..ab69d7561 100644 --- a/src/components/Attachment/__tests__/Geolocation.test.tsx +++ b/src/components/Attachment/__tests__/Geolocation.test.tsx @@ -10,6 +10,8 @@ import { initClientWithChannels, } from '../../../mock-builders'; import type { Channel as ChannelType, StreamChat } from 'stream-chat'; +import { msToNs, nowNs } from 'stream-chat'; +import { convertDateToTimestamp } from '../../../mock-builders/generator/time'; const GeolocationMapComponent = (props) => (
@@ -114,7 +116,7 @@ describe.each([ it('renders own live location', async () => { const location = generateLiveLocationResponse({ - end_at: new Date(Date.now() + 10000).toISOString(), + end_at: nowNs() + msToNs(10000), user_id: ownUser.id, }); await renderComponent({ @@ -140,7 +142,7 @@ describe.each([ }); it("other user's live location", async () => { const location = generateLiveLocationResponse({ - end_at: new Date(Date.now() + 10000).toISOString(), + end_at: nowNs() + msToNs(10000), user_id: otherUser.id, }); await renderComponent({ @@ -166,7 +168,7 @@ describe.each([ }); it("own user's stopped live location", async () => { const location = generateLiveLocationResponse({ - end_at: '1980-01-01T00:00:00.000Z', + end_at: convertDateToTimestamp('1980-01-01T00:00:00.000Z'), user_id: ownUser.id, }); await renderComponent({ @@ -192,7 +194,7 @@ describe.each([ }); it("other user's stopped live location", async () => { const location = generateLiveLocationResponse({ - end_at: '1980-01-01T00:00:00.000Z', + end_at: convertDateToTimestamp('1980-01-01T00:00:00.000Z'), user_id: otherUser.id, }); await renderComponent({ diff --git a/src/components/Channel/Channel.tsx b/src/components/Channel/Channel.tsx index 31c200515..6a4b7b944 100644 --- a/src/components/Channel/Channel.tsx +++ b/src/components/Channel/Channel.tsx @@ -194,14 +194,14 @@ const ChannelInner = ( event?.message?.created_at && event?.message?.cid ) { - const messageDate = new Date(event.message.created_at); + const messageCreatedAt = event.message.created_at; const cid = event.message.cid; if ( !latestMessageDatesByChannels[cid] || - latestMessageDatesByChannels[cid].getTime() < messageDate.getTime() + latestMessageDatesByChannels[cid] < messageCreatedAt ) { - latestMessageDatesByChannels[cid] = messageDate; + latestMessageDatesByChannels[cid] = messageCreatedAt; } } } diff --git a/src/components/Channel/__tests__/Channel.test.tsx b/src/components/Channel/__tests__/Channel.test.tsx index 0ab48ff6c..7b11178cf 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, }), ); @@ -509,7 +510,7 @@ describe('Channel', () => { it('should add a preview for messages that are sent to the channel state, so that they are rendered even without API response', async () => { const { channel, chatClient } = await setup(); const messageText = nanoid(); - const m = generateMessage({ text: messageText }); + const m = generateMessage({ cid: channel.cid, text: messageText }); useMockedApis(chatClient, [sendMessageApi(m)]); await renderComponent({ channel, chatClient }); @@ -548,6 +549,7 @@ describe('Channel', () => { await renderComponent({ channel, chatClient, children: }); const m = generateMessage({ + cid: channel.cid, id: messageId, status: 'sending', text: messageText, @@ -702,6 +704,7 @@ describe('Channel', () => { it('should enable retrying message sending', async () => { const { channel, chatClient } = await setup(); const messageObject = generateMessage({ + cid: channel.cid, text: nanoid(), }); @@ -881,7 +884,7 @@ describe('Channel', () => { user: { ...user, ...updatedAttribute, - updated_at: new Date().toISOString(), + updated_at: convertDateToTimestamp(new Date().toISOString()), }, }, chatClient, @@ -925,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, @@ -936,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/ChannelListItem.tsx b/src/components/ChannelListItem/ChannelListItem.tsx index a04830282..4bf904b68 100644 --- a/src/components/ChannelListItem/ChannelListItem.tsx +++ b/src/components/ChannelListItem/ChannelListItem.tsx @@ -203,7 +203,7 @@ export const ChannelListItem = (props: ChannelListItemProps) => { groupChannelDisplayInfo={groupChannelDisplayInfo} messageDeliveryStatus={messageDeliveryStatus} muted={muted} - pinned={!!membership.pinned_at} + pinned={membership.pinned_at != null} previewedMessage={previewedMessage} unread={unread} /> diff --git a/src/components/ChannelListItem/ChannelListItemActionButtons.defaults.tsx b/src/components/ChannelListItem/ChannelListItemActionButtons.defaults.tsx index e40cca3f5..0f9c0c787 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 }, @@ -131,10 +131,11 @@ const useArchiveAction = (): ChannelActionBehavior => { }; return { - 'aria-pressed': typeof membership.archived_at === 'string', - title: membership.archived_at - ? t('channelListItem.unarchive.title', 'Unarchive') - : t('channelListItem.archive.title', 'Archive'), + 'aria-pressed': membership.archived_at != null, + 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/ChannelListItemTimestamp.tsx b/src/components/ChannelListItem/ChannelListItemTimestamp.tsx index 9518c94f5..9a50d16a6 100644 --- a/src/components/ChannelListItem/ChannelListItemTimestamp.tsx +++ b/src/components/ChannelListItem/ChannelListItemTimestamp.tsx @@ -2,7 +2,8 @@ import React, { useMemo } from 'react'; import type { LocalMessage } from 'stream-chat'; import { useTranslationContext } from '../../context/TranslationContext'; -import { getDateString, isDate } from '../../i18n/utils'; +import { getDateString } from '../../i18n/utils'; +import { convertTimestampToDate } from 'stream-chat'; export type ChannelListItemTimestampProps = { /** The message previewed by the item, used to extract the timestamp */ @@ -15,8 +16,10 @@ export function ChannelListItemTimestamp({ const { t, tDateTimeParser } = useTranslationContext(); const timestamp = previewedMessage?.created_at; + // `isDate` correctly reports that a wire number is not a `Date`, so the old idiom here returned + // `undefined` for every message and the timestamp vanished from the list. Convert instead. const normalizedTimestamp = - timestamp && isDate(timestamp) ? timestamp.toISOString() : undefined; + timestamp != null ? convertTimestampToDate(timestamp)?.toISOString() : undefined; const when = useMemo( () => diff --git a/src/components/ChannelListItem/__tests__/ChannelListItem.test.tsx b/src/components/ChannelListItem/__tests__/ChannelListItem.test.tsx index ea4f93428..f3d83e0ea 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, [ @@ -328,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, + }, }), ); @@ -419,19 +426,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 +487,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 +716,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 055691f70..300039ad4 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; @@ -29,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(() => { @@ -568,7 +573,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 +678,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 c801ad5b4..5c8e52f4b 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 44dfb783c..4f27b9f91 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 e51a9806a..866f0218d 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, @@ -22,6 +23,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 +52,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 +67,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 }; @@ -68,17 +82,17 @@ const othersLastMessage = () => { const lastMessageCreated = (messages) => [ { - last_delivered_at: messages[0].created_at.toISOString(), + last_delivered_at: messages[0].created_at, last_delivered_message_id: messages[0].id, - last_read: messages[0].created_at.toISOString(), + last_read: messages[0].created_at, last_read_message_id: messages[0], unread_messages: 0, user: ownUser, }, { - last_delivered_at: messages[0].created_at.toISOString(), + last_delivered_at: messages[0].created_at, last_delivered_message_id: messages[0].id, - last_read: messages[0].created_at.toISOString(), + last_read: messages[0].created_at, unread_messages: 1, user: otherUser, }, @@ -86,17 +100,17 @@ const lastMessageCreated = (messages) => [ const lastDeliveredOnlyToMe = (messages) => [ { - last_delivered_at: messages[1].created_at.toISOString(), + last_delivered_at: messages[1].created_at, last_delivered_message_id: messages[1].id, - last_read: messages[0].created_at.toISOString(), + last_read: messages[0].created_at, last_read_message_id: messages[0], unread_messages: 0, user: ownUser, }, { - last_delivered_at: messages[0].created_at.toISOString(), + last_delivered_at: messages[0].created_at, last_delivered_message_id: messages[0].id, - last_read: messages[0].created_at.toISOString(), + last_read: messages[0].created_at, unread_messages: 1, user: otherUser, }, @@ -104,17 +118,17 @@ const lastDeliveredOnlyToMe = (messages) => [ const lastReadOnlyByMe = (messages) => [ { - last_delivered_at: messages[1].created_at.toISOString(), + last_delivered_at: messages[1].created_at, last_delivered_message_id: messages[1].id, - last_read: messages[1].created_at.toISOString(), + last_read: messages[1].created_at, last_read_message_id: messages[1], unread_messages: 0, user: ownUser, }, { - last_delivered_at: messages[0].created_at.toISOString(), + last_delivered_at: messages[0].created_at, last_delivered_message_id: messages[0].id, - last_read: messages[0].created_at.toISOString(), + last_read: messages[0].created_at, unread_messages: 1, user: otherUser, }, @@ -122,17 +136,17 @@ const lastReadOnlyByMe = (messages) => [ const lastMessageDelivered = (messages) => [ { - last_delivered_at: messages[0].created_at.toISOString(), + last_delivered_at: messages[0].created_at, last_delivered_message_id: messages[0].id, - last_read: messages[0].created_at.toISOString(), + last_read: messages[0].created_at, last_read_message_id: messages[0], unread_messages: 0, user: ownUser, }, { - last_delivered_at: messages[1].created_at.toISOString(), + last_delivered_at: messages[1].created_at, last_delivered_message_id: messages[1].id, - last_read: messages[0].created_at.toISOString(), + last_read: messages[0].created_at, unread_messages: 1, user: otherUser, }, @@ -140,17 +154,17 @@ const lastMessageDelivered = (messages) => [ const lastMessageRead = (messages) => [ { - last_delivered_at: messages[0].created_at.toISOString(), + last_delivered_at: messages[0].created_at, last_delivered_message_id: messages[0].id, - last_read: messages[0].created_at.toISOString(), + last_read: messages[0].created_at, last_read_message_id: messages[0], unread_messages: 0, user: ownUser, }, { - last_delivered_at: messages[1].created_at.toISOString(), + last_delivered_at: messages[1].created_at, last_delivered_message_id: messages[1].id, - last_read: messages[1].created_at.toISOString(), + last_read: messages[1].created_at, unread_messages: 0, user: otherUser, }, @@ -203,7 +217,7 @@ describe('Message delivery status', () => { user: ownUser, }, { - last_read: '1970-01-01T00:00:00.00Z', + last_read: 0, unread_messages: 1, user: otherUser, }, @@ -268,7 +282,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 +301,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({ @@ -315,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, }); @@ -335,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, }); @@ -355,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, }); @@ -375,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, }); @@ -442,7 +448,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 +466,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/ChannelListItem/utils.a11y.ts b/src/components/ChannelListItem/utils.a11y.ts index a2d8d716a..a7fc2fb8d 100644 --- a/src/components/ChannelListItem/utils.a11y.ts +++ b/src/components/ChannelListItem/utils.a11y.ts @@ -9,9 +9,10 @@ import { composeAccessibleLabel, unreadCountLabelPart, } from '../../a11y/accessibleLabel'; -import { getDateString, isDate } from '../../i18n/utils'; +import { getDateString } from '../../i18n/utils'; import { MessageDeliveryStatus } from './hooks/useMessageDeliveryStatus'; import { getLatestMessagePreviewText } from './utils'; +import { convertTimestampToDate } from 'stream-chat'; /** * Everything a label part needs. Gathered by `ChannelListItemUI` from its props + contexts and @@ -139,9 +140,9 @@ export const defaultChannelListItemLabelParts = { name: ({ displayTitle }) => displayTitle || undefined, time: ({ latestMessage, t, tDateTimeParser }) => { const createdAt = latestMessage?.created_at; - if (!createdAt || !isDate(createdAt)) return undefined; + if (createdAt == null) return undefined; const when = getDateString({ - messageCreatedAt: createdAt.toISOString(), + messageCreatedAt: convertTimestampToDate(createdAt)?.toISOString(), t, tDateTimeParser, timestampTranslationKey: 'timestamp.ChannelPreviewTimestamp', diff --git a/src/components/ChannelListItem/utils.tsx b/src/components/ChannelListItem/utils.tsx index 8c733b3e4..82b060ab3 100644 --- a/src/components/ChannelListItem/utils.tsx +++ b/src/components/ChannelListItem/utils.tsx @@ -51,8 +51,7 @@ const getLatestPollVote = ( let latestVote: PollVoteResponseData | undefined; for (const optionVotes of Object.values(latestVotesByOption)) { optionVotes.forEach((vote) => { - if (latestVote && new Date(latestVote.updated_at) >= new Date(vote.created_at)) - return; + if (latestVote && latestVote.updated_at >= vote.created_at) return; latestVote = vote; }); } diff --git a/src/components/EventComponent/__tests__/EventComponent.test.tsx b/src/components/EventComponent/__tests__/EventComponent.test.tsx index f1b04b4fd..8f5d738c7 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/MessageEditedIndicator.tsx b/src/components/Message/MessageEditedIndicator.tsx index 37488ee0d..4cde08a61 100644 --- a/src/components/Message/MessageEditedIndicator.tsx +++ b/src/components/Message/MessageEditedIndicator.tsx @@ -1,3 +1,4 @@ +import { convertTimestampToDate } from 'stream-chat'; import React, { useState } from 'react'; import type { LocalMessage } from 'stream-chat'; import type { TimestampFormatterOptions } from '../../i18n/types'; @@ -28,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; } @@ -47,7 +48,10 @@ const UnMemoizedMessageEditedIndicator = (props: MessageEditedIndicatorProps) => referenceElement={referenceElement} visible={tooltipVisible} > - + ); diff --git a/src/components/Message/MessageTimestamp.tsx b/src/components/Message/MessageTimestamp.tsx index 8ba1d2599..a91c3d741 100644 --- a/src/components/Message/MessageTimestamp.tsx +++ b/src/components/Message/MessageTimestamp.tsx @@ -1,3 +1,4 @@ +import { convertTimestampToDate } from 'stream-chat'; import React from 'react'; import { useMessageContext } from '../../context/MessageContext'; import { Timestamp as DefaultTimestamp } from './Timestamp'; @@ -18,7 +19,12 @@ const UnMemoizedMessageTimestamp = (props: MessageTimestampProps) => { const { message: contextMessage } = useMessageContext(); const { Timestamp = DefaultTimestamp } = useComponentContext(); const message = propMessage || contextMessage; - return ; + return ( + + ); }; export const MessageTimestamp = React.memo( diff --git a/src/components/Message/ReminderNotification.tsx b/src/components/Message/ReminderNotification.tsx index ee1ff7216..710658647 100644 --- a/src/components/Message/ReminderNotification.tsx +++ b/src/components/Message/ReminderNotification.tsx @@ -3,6 +3,7 @@ import { useTranslationContext } from '../../context'; import { useStateStore } from '../../store'; import type { Reminder, ReminderState } from 'stream-chat'; import { IconBell, IconBookmark } from '../Icons'; +import { nsToDate, nsToMs } from 'stream-chat'; export type ReminderNotificationProps = { reminder?: Reminder; @@ -29,18 +30,20 @@ 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.getTime() + 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 = reminder.remindAt.getTime(); + const remindAtMs = nsToMs(reminder.remindAt); const diffMs = remindAtMs - nowMs; const diffMinutes = Math.abs(diffMs) / (60 * 1000); const useAbsoluteFormat = diffMinutes > THRESHOLD_RELATIVE_MINUTES; @@ -56,7 +59,8 @@ function RemindMeContent({ reminder }: { reminder: Reminder }) { 'Due since {{ dueSince }}', { dueSince: t('timestamp.ReminderNotification', { - timestamp: reminder.remindAt, + timestamp: + reminder.remindAt != null ? nsToDate(reminder.remindAt) : undefined, }), }, ); @@ -78,7 +82,7 @@ function RemindMeContent({ reminder }: { reminder: Reminder }) { // > 59 min from now: calendar + time (no "Due" prefix) // e.g. "Today at 15:00", "Tomorrow at 09:30" return t('timestamp.ReminderNotification', { - timestamp: reminder.remindAt, + timestamp: reminder.remindAt != null ? nsToDate(reminder.remindAt) : undefined, }); } // Within 59 min from now: relative @@ -103,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__/MessageStatus.test.tsx b/src/components/Message/__tests__/MessageStatus.test.tsx index 920b30909..cc7069ac0 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 9399636c4..53da397c9 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 ecbaeb253..a65f089d9 100644 --- a/src/components/Message/__tests__/MessageTimestamp.test.tsx +++ b/src/components/Message/__tests__/MessageTimestamp.test.tsx @@ -2,7 +2,8 @@ import React from 'react'; import { act, cleanup, render, type RenderResult } from '@testing-library/react'; import { fromPartial } from '@total-typescript/shoehorn'; import type { LocalMessage } from 'stream-chat'; -import { generateMessage } from 'mock-builders'; +import { msToNs, nsToDate } from 'stream-chat'; +import { convertDateToTimestamp, generateMessage } from 'mock-builders'; import { MessageTimestamp } from '../MessageTimestamp'; import { ComponentProvider, MessageProvider, TranslationContext } from '../../../context'; import type { TranslationContextValue } from '../../../context'; @@ -34,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 ({ @@ -77,15 +78,17 @@ describe('', () => { `); }); - it('should render non-Date timestamp value in datetime attribute without processing', async () => { + it('interprets a numeric timestamp as the wire value rather than echoing it', async () => { + // 28 nanoseconds is sub-millisecond, so it resolves to the epoch — the point being that a bare + // number is now read as a wire timestamp instead of passed through as opaque text. const { container } = await renderComponent({ messageCtx: { message: { ...messageMock, created_at: 28 } }, }); expect(container).toMatchInlineSnapshot(`
@@ -99,9 +102,7 @@ describe('', () => { props: { message: { ...messageMock, - created_at: new Date( - (messageMock.created_at as unknown as Date).getTime() + oneYearMs, - ), + created_at: (messageMock.created_at as unknown as number) + msToNs(oneYearMs), }, }, }); @@ -118,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( @@ -166,7 +169,9 @@ describe('', () => { props: { format: 'YYYY' }, }); expect(container).toHaveTextContent( - (messageMock.created_at as unknown as Date).getFullYear().toString(), + nsToDate(messageMock.created_at as unknown as number) + .getFullYear() + .toString(), ); }); @@ -183,7 +188,9 @@ describe('', () => { props: { format: 'YYYY' }, }); expect(container).toHaveTextContent( - (messageMock.created_at as unknown as Date).getFullYear().toString(), + nsToDate(messageMock.created_at as unknown as number) + .getFullYear() + .toString(), ); }); diff --git a/src/components/Message/__tests__/MessageUI.test.tsx b/src/components/Message/__tests__/MessageUI.test.tsx index 22f8872d7..3766d4835 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 ccde81aa2..c1a697760 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__/ReminderNotification.test.tsx b/src/components/Message/__tests__/ReminderNotification.test.tsx index 01e91ba81..d87ba551d 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/Message/__tests__/utils.test.ts b/src/components/Message/__tests__/utils.test.ts index 470bf4bb5..b994c8c0a 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, }), @@ -183,6 +188,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' }); @@ -205,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 7a2d9fcdc..13b0a205d 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 1ba161953..4e82cee80 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/Message/hooks/usePinHandler.ts b/src/components/Message/hooks/usePinHandler.ts index 6acc7da86..ff8bce29e 100644 --- a/src/components/Message/hooks/usePinHandler.ts +++ b/src/components/Message/hooks/usePinHandler.ts @@ -6,6 +6,7 @@ import { useTranslationContext } from '../../../context/TranslationContext'; import type { LocalMessage, UserResponse } from 'stream-chat'; import type { ReactEventHandler } from '../types'; +import { nowNs } from 'stream-chat'; // @deprecated in favor of `channelCapabilities` - TODO: remove in next major release export type PinEnabledUserRoles = Partial< @@ -59,7 +60,7 @@ export const usePinHandler = ( const optimisticMessage: LocalMessage = { ...message, pinned: true, - pinned_at: new Date(), + pinned_at: nowNs(), pinned_by: client.user as UserResponse | undefined, }; diff --git a/src/components/Message/hooks/useReactionHandler.ts b/src/components/Message/hooks/useReactionHandler.ts index 14330efea..5794ca5f7 100644 --- a/src/components/Message/hooks/useReactionHandler.ts +++ b/src/components/Message/hooks/useReactionHandler.ts @@ -18,6 +18,7 @@ import { type ReactionRequest, type ReactionResponse, } from 'stream-chat'; +import { nowNs } from 'stream-chat'; export const reactionHandlerWarning = `Reaction handler was called, but it is missing one of its required arguments. Make sure the ChannelAction and ChannelState contexts are properly set and the hook is initialized with a valid message.`; @@ -38,12 +39,16 @@ 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]; if (add) { - const timestamp = new Date(); + const timestamp = nowNs(); newReactionGroups[reactionType] = hasReaction ? { ...newReactionGroups[reactionType], diff --git a/src/components/Message/utils.tsx b/src/components/Message/utils.tsx index 314560385..4226a2795 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 && @@ -413,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 ec1bec935..d5916e151 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/MessageActions/__tests__/MessageActions.test.tsx b/src/components/MessageActions/__tests__/MessageActions.test.tsx index e69b6b9be..fc7f70a76 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 4ad19266f..41c1e8945 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/MessageList.tsx b/src/components/MessageList/MessageList.tsx index c45b82f06..136904c74 100644 --- a/src/components/MessageList/MessageList.tsx +++ b/src/components/MessageList/MessageList.tsx @@ -473,7 +473,10 @@ export type MessageListProps = Partial hasMore?: boolean; /** Element to be rendered at the top of the thread message list. By default, these are the Message and ThreadStart components */ head?: React.ReactElement; - /** Position to render HeaderComponent */ + /** + * Position to render HeaderComponent, as a timestamp in the same unit as `message.created_at` — + * i.e. unix nanoseconds. Was milliseconds while `created_at` was a `Date`. + */ headerPosition?: number; // todo: data manipulation - should live in MessagePaginator /** Hides the MessageDeleted components from the list, defaults to `false` */ diff --git a/src/components/MessageList/VirtualizedMessageList.tsx b/src/components/MessageList/VirtualizedMessageList.tsx index 1a4b235ce..ded4d9044 100644 --- a/src/components/MessageList/VirtualizedMessageList.tsx +++ b/src/components/MessageList/VirtualizedMessageList.tsx @@ -126,7 +126,8 @@ export type VirtuosoContext = Required< lastOwnMessage?: LocalMessage; /** Message id which was marked as unread. ALl the messages following this message are considered unrea. */ firstUnreadMessageId: string | null; - lastReadDate: Date | null; + /** Unix nanoseconds, as `messagePaginator.unreadStateSnapshot.lastReadAt` carries it. */ + lastReadDate: number | null; /** * The ID of the last message considered read by the current user in the current channel. * All the messages following this message are considered unread. diff --git a/src/components/MessageList/__tests__/MessageList.test.tsx b/src/components/MessageList/__tests__/MessageList.test.tsx index 13dc2e28d..8d4cec074 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, @@ -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,7 @@ describe('MessageList', () => { messages, read: [ { - last_read: new Date(messages[2].created_at).toISOString(), + last_read: messages[2].created_at, last_read_message_id: messages[2].id, unread_messages: 2, user, @@ -665,7 +672,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, }); @@ -685,7 +692,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 +707,7 @@ describe('MessageList', () => { messages, read: [ { - last_read: new Date(lastMessage.created_at).toISOString(), + last_read: lastMessage.created_at, last_read_message_id: lastMessage.id, unread_messages: 0, user, @@ -729,15 +738,15 @@ 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: parentMsg.created_at + msToNs(1001), parent_id: parentMsg.id, }), ); @@ -750,7 +759,7 @@ describe('MessageList', () => { messages, read: [ { - last_read: 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/__tests__/VirtualizedMessageListComponents.test.tsx b/src/components/MessageList/__tests__/VirtualizedMessageListComponents.test.tsx index 37c3cd9d7..c9d9aca4c 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), }), ); @@ -504,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: {}, @@ -529,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 02b291ab5..e89702ca3 100644 --- a/src/components/MessageList/__tests__/utils.test.ts +++ b/src/components/MessageList/__tests__/utils.test.ts @@ -7,8 +7,16 @@ 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'; +import { convertDateToTimestamp } from '../../../mock-builders'; const mockedNanoId = 'V1StGXR8_Z5jdHi6B-myT'; vi.mock('nanoid', () => ({ @@ -20,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 = {}) => { @@ -44,10 +76,12 @@ const runMessageProcessing = (msgData, processMsgParams = {}) => { }; }; +// The separator is a view-model carrying a `Date`, built from the message's wire timestamp — so the +// expectation has to go through the same conversion the list does. const makeDateSeparator = (message) => ({ customType: 'message.date', - date: message.created_at, - id: makeDateMessageId(message.created_at), + date: convertTimestampToDate(message.created_at), + id: makeDateMessageId(convertTimestampToDate(message.created_at)), }); const dateSeparatorInsertedAt = ( @@ -152,28 +186,61 @@ 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); + } + } + } }); }); 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])); @@ -320,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 }, @@ -444,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', () => { @@ -453,9 +564,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; }); @@ -566,10 +683,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( @@ -579,7 +699,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', ); @@ -588,7 +711,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', @@ -630,8 +753,8 @@ describe('getGroupStyles', () => { it('marks a message as bottom when next message is created later than maxTimeBetweenGroupedMessages milliseconds', () => { const maxTimeBetweenGroupedMessages = 10; - message = { ...message, created_at: new Date(12) }; - nextMessage = { ...nextMessage, created_at: new Date(14) }; + message = { ...message, created_at: msToNs(12) }; + nextMessage = { ...nextMessage, created_at: msToNs(14) }; expect( getGroupStyles( message, @@ -645,7 +768,7 @@ describe('getGroupStyles', () => { it('marks a message as single when next and previous message is created later than maxTimeBetweenGroupedMessages milliseconds', () => { const maxTimeBetweenGroupedMessages = 10; - message = { ...message, created_at: new Date(12) }; + message = { ...message, created_at: msToNs(12) }; expect( getGroupStyles( message, @@ -678,7 +801,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', ); @@ -693,7 +816,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', @@ -707,4 +830,109 @@ 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', () => { + // `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('puts the intro at the top when the position precedes every message', () => { + // Asserts the whole list, not just `[0]`: a dropped intro and a moved one both satisfy + // `isIntro(result[0]) === false`. + const result = insertIntro([msg('2026-01-02T00:00:00Z', 'a')], 0); + + expect(result.map((m) => (isIntro(m) ? 'intro' : m.id))).toEqual(['intro', 'a']); + }); + + 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'); + + // A millisecond value precedes every message, so the intro lands at the top — wrong placement, + // but visible rather than dropped. Nanoseconds split the list where they should. + expect( + insertIntro([...messages], asMilliseconds).map((m) => + isIntro(m) ? 'intro' : m.id, + ), + ).toEqual(['intro', 'older', 'newer']); + expect( + insertIntro([...messages], asMilliseconds * NS_PER_MS).map((m) => + isIntro(m) ? 'intro' : m.id, + ), + ).toEqual(['older', 'intro', 'newer']); + }); }); diff --git a/src/components/MessageList/hooks/VirtualizedMessageList/useFloatingDateSeparator.ts b/src/components/MessageList/hooks/VirtualizedMessageList/useFloatingDateSeparator.ts index 7df9746a3..167597430 100644 --- a/src/components/MessageList/hooks/VirtualizedMessageList/useFloatingDateSeparator.ts +++ b/src/components/MessageList/hooks/VirtualizedMessageList/useFloatingDateSeparator.ts @@ -3,6 +3,7 @@ import { useCallback, useState } from 'react'; import type { RenderedMessage } from '../../utils'; import { isDateSeparatorMessage, isIntroMessage } from '../../utils'; import type { LocalMessage } from 'stream-chat'; +import { nsToDate } from 'stream-chat'; export type UseFloatingDateSeparatorParams = { disableDateSeparator: boolean; @@ -38,8 +39,8 @@ function getFloatingDateForFirstMessage( // No preceding date separator; use message's created_at const msg = firstMessage as LocalMessage; const created = msg.created_at; - if (created) { - const d = new Date(created); + if (created != null) { + const d = nsToDate(created); return isNaN(d.getTime()) ? null : d; } return null; diff --git a/src/components/MessageList/hooks/VirtualizedMessageList/useUnreadMessagesNotificationVirtualized.ts b/src/components/MessageList/hooks/VirtualizedMessageList/useUnreadMessagesNotificationVirtualized.ts index 3e7a1d1e1..017b3291c 100644 --- a/src/components/MessageList/hooks/VirtualizedMessageList/useUnreadMessagesNotificationVirtualized.ts +++ b/src/components/MessageList/hooks/VirtualizedMessageList/useUnreadMessagesNotificationVirtualized.ts @@ -42,18 +42,18 @@ export const useUnreadMessagesNotificationVirtualized = ({ const lastRenderedMessage = renderedMessages.slice(-1)[0]; if (!(firstRenderedMessage && lastRenderedMessage)) return; - const firstRenderedMessageTime = new Date( - (firstRenderedMessage as LocalMessage).created_at ?? 0, - ).getTime(); - const lastRenderedMessageTime = new Date( - (lastRenderedMessage as LocalMessage).created_at ?? 0, - ).getTime(); - const lastReadTime = new Date(lastReadAt ?? 0).getTime(); + // All three are wire timestamps, directly comparable. Building `Date`s here produced NaN, + // so the notification never appeared. + const firstRenderedMessageTime = + (firstRenderedMessage as LocalMessage).created_at ?? 0; + const lastRenderedMessageTime = + (lastRenderedMessage as LocalMessage).created_at ?? 0; + 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/components/MessageList/hooks/__tests__/useMarkRead.test.tsx b/src/components/MessageList/hooks/__tests__/useMarkRead.test.tsx index e2efb5713..6c79db694 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/MessageList/hooks/__tests__/useMessageListScrollManager.test.tsx b/src/components/MessageList/hooks/__tests__/useMessageListScrollManager.test.tsx index 2a74f3bec..9f0869dd1 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( { @@ -66,7 +68,7 @@ describe('useUnreadMessagesNotificationVirtualized', () => { }); await act(() => { channel.messagePaginator.setUnreadSnapshot({ - lastReadAt: new Date('1970-1-1'), + lastReadAt: convertDateToTimestamp('1970-01-01'), unreadCount: 1, }); }); @@ -74,9 +76,9 @@ describe('useUnreadMessagesNotificationVirtualized', () => { }); it('should not show notification if unread count is 0', async () => { - const now = new Date(); - const lastRead = new Date(now.getTime() - 1000); - const firstRenderedMsgCreated = new Date(now.getTime() - 500); + const now = nowNs(); + const lastRead = now - msToNs(1000); + const firstRenderedMsgCreated = now - msToNs(500); const messages = [ generateMessage({ created_at: firstRenderedMsgCreated }), generateMessage({ created_at: now }), @@ -93,9 +95,9 @@ describe('useUnreadMessagesNotificationVirtualized', () => { it.each([[true], [false]])( 'should show notification if there are unread messages and first rendered message was created later than last read when showUnreadNotificationAlways is %s', async (showUnreadNotificationAlways) => { - const now = new Date(); - const lastRead = new Date(now.getTime() - 1000); - const firstRenderedMsgCreated = new Date(now.getTime() - 500); + const now = nowNs(); + const lastRead = now - msToNs(1000); + const firstRenderedMsgCreated = now - msToNs(500); const messages = [ generateMessage({ created_at: firstRenderedMsgCreated }), generateMessage({ created_at: now }), @@ -120,10 +122,10 @@ 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 firstRenderedMsgCreated = new Date(now.getTime() - 1002); - const lastRenderedMsgCreated = new Date(now.getTime() - 1001); - const lastRead = new Date(now.getTime() - 1000); + const now = nowNs(); + const firstRenderedMsgCreated = now - msToNs(1002); + const lastRenderedMsgCreated = now - msToNs(1001); + const lastRead = now - msToNs(1000); const messages = [ generateMessage({ created_at: firstRenderedMsgCreated }), generateMessage({ created_at: lastRenderedMsgCreated }), @@ -145,9 +147,9 @@ 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 firstRenderedMsgCreated = new Date(now.getTime() - 1002); - const lastRead = new Date(now.getTime() - 1001); + const now = nowNs(); + const firstRenderedMsgCreated = now - msToNs(1002); + const lastRead = now - msToNs(1001); const messages = [ generateMessage({ created_at: firstRenderedMsgCreated }), generateMessage({ created_at: lastRead }), @@ -169,9 +171,9 @@ describe('useUnreadMessagesNotificationVirtualized', () => { it.each([[true], [false]])( 'should not show notification if the last rendered message was created earlier than last read when showUnreadNotificationAlways is %s', async (showUnreadNotificationAlways) => { - const now = new Date(); - const lastRead = new Date(now.getTime() - 1001); - const lastRenderedMsgCreated = new Date(now.getTime() - 1000); + const now = nowNs(); + const lastRead = now - msToNs(1001); + const lastRenderedMsgCreated = now - msToNs(1000); const messages = [ generateMessage({ created_at: lastRead }), generateMessage({ created_at: lastRenderedMsgCreated }), diff --git a/src/components/MessageList/hooks/useMarkRead.ts b/src/components/MessageList/hooks/useMarkRead.ts index 736b8d238..533fd27d7 100644 --- a/src/components/MessageList/hooks/useMarkRead.ts +++ b/src/components/MessageList/hooks/useMarkRead.ts @@ -4,6 +4,7 @@ import { useMessagePaginator } from '../../../hooks'; import { useStateStore } from '../../../store'; import { useThreadContext } from '../../Threads'; import type { Channel, ChannelConfig, EventPayload } from 'stream-chat'; +import { nowNs } from 'stream-chat'; const readEventsSelector = ({ readEvents }: ChannelConfig) => ({ readEventsEnabled: readEvents.enabled, @@ -53,7 +54,7 @@ export const useMarkRead = ({ messagePaginator.unreadStateSnapshot.next({ ...previous, firstUnreadMessageId: null, - lastReadAt: new Date(), + lastReadAt: nowNs(), lastReadMessageId: loadedItems[loadedItems.length - 1]?.id ?? previous.lastReadMessageId, unreadCount: 0, diff --git a/src/components/MessageList/renderMessages.tsx b/src/components/MessageList/renderMessages.tsx index aba131be6..65d246114 100644 --- a/src/components/MessageList/renderMessages.tsx +++ b/src/components/MessageList/renderMessages.tsx @@ -107,7 +107,7 @@ export function defaultRenderMessages({ , @@ -130,7 +130,7 @@ export function defaultRenderMessages({ }); renderedMessages.push( - + {isFirstUnreadMessage && UnreadMessagesSeparator && ( diff --git a/src/components/MessageList/utils.ts b/src/components/MessageList/utils.ts index a762e3bc1..16359640e 100644 --- a/src/components/MessageList/utils.ts +++ b/src/components/MessageList/utils.ts @@ -4,12 +4,8 @@ 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 = { customType: typeof CUSTOM_MESSAGE_TYPE.intro; @@ -18,10 +14,19 @@ type IntroMessage = { type DateSeparatorMessage = { customType: typeof CUSTOM_MESSAGE_TYPE.date; + /** + * A `Date`, not the wire number: this is a view-model the separator renders from, and keeping it + * a `Date` is what lets `DateSeparator`, the render key and `isDateSeparatorMessage` stay as they + * are. Converted once, where core data enters the list. + */ 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; @@ -35,8 +40,8 @@ type ProcessMessagesContext = { hideDeletedMessages?: boolean; /** Disable date separator display for unread incoming messages */ hideNewMessageSeparator?: boolean; - /** Sets the threshold after everything is considered unread */ - lastRead?: Date | null; + /** Sets the threshold after everything is considered unread. Unix nanoseconds, as `channel.lastRead()` returns. */ + lastRead?: number | null; }; export type ProcessMessagesParams = ProcessMessagesContext & { @@ -106,35 +111,38 @@ export const processMessages = (params: ProcessMessagesParams) => { } const changes: RenderedMessage[] = []; - const messageDate = - (message.created_at && - isDate(message.created_at) && - 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 && - isDate(previousMessage.created_at) - ) { - prevMessageDate = 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 = - (lastRead && message.created_at && new Date(lastRead) < message.created_at) || + (lastRead != null && + message.created_at != null && + lastRead < message.created_at) || 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: message.created_at, - id: makeDateMessageId(message.created_at), + date: messageCreatedAt, + id: makeDateMessageId(messageCreatedAt), unread, - } as DateSeparatorMessage); + }; + changes.push(separator); } } @@ -150,14 +158,12 @@ export const processMessages = (params: ProcessMessagesParams) => { ) { lastDateSeparator = messageDate; - changes.push( - { - customType: CUSTOM_MESSAGE_TYPE.date, - date: message.created_at, - id: makeDateMessageId(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); } @@ -212,7 +218,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; } @@ -225,23 +231,15 @@ export const insertIntro = (messages: RenderedMessage[], headerPosition?: number // else loop over the messages for (let i = 0; i < messages.length; i += 1) { - const messageTime = isDate((messages[i] as LocalMessage).created_at) - ? (messages[i] as LocalMessage).created_at.getTime() - : null; + const messageTime = (messages[i] as LocalMessage).created_at; - const nextMessageTime = isDate((messages[i + 1] as LocalMessage).created_at) - ? (messages[i + 1] as LocalMessage).created_at.getTime() - : null; + 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 (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; @@ -249,6 +247,9 @@ export const insertIntro = (messages: RenderedMessage[], headerPosition?: number } } + // No message is older than the position, so it precedes the whole list and the intro goes first. + // Falling through without inserting dropped the intro entirely (what `headerPosition={0}` did). + newMessages.unshift(intro); return newMessages; }; @@ -285,10 +286,11 @@ export const getGroupStyles = ( (message.reaction_groups && isNonEmptyRecord(message.reaction_groups)) || isMessageEdited(previousMessage) || (maxTimeBetweenGroupedMessages !== undefined && - previousMessage.created_at && - message.created_at && - new Date(message.created_at).getTime() - - new Date(previousMessage.created_at).getTime() > + // 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); const isBottomMessage = @@ -302,10 +304,9 @@ export const getGroupStyles = ( (nextMessage.reaction_groups && isNonEmptyRecord(nextMessage.reaction_groups)) || isMessageEdited(message) || (maxTimeBetweenGroupedMessages !== undefined && - nextMessage.created_at && - message.created_at && - new Date(nextMessage.created_at).getTime() - - new Date(message.created_at).getTime() > + nextMessage.created_at != null && + message.created_at != null && + nsToMs(nextMessage.created_at - message.created_at) > maxTimeBetweenGroupedMessages); if (!isTopMessage && !isBottomMessage) { @@ -378,11 +379,13 @@ export const getIsFirstUnreadMessage = ({ // the separator should not be rendered. if (!unreadCount) return false; - const createdAtTimestamp = message.created_at && new Date(message.created_at).getTime(); - const lastReadTimestamp = lastReadAt?.getTime(); + const createdAtTimestamp = message.created_at; + 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/Poll/PollVote.tsx b/src/components/Poll/PollVote.tsx index 0a0afea68..5d859f9b3 100644 --- a/src/components/Poll/PollVote.tsx +++ b/src/components/Poll/PollVote.tsx @@ -1,3 +1,4 @@ +import { convertTimestampToDate } from 'stream-chat'; import React, { useState } from 'react'; import { Avatar as DefaultAvatar } from '../Avatar'; import { extractDisplayInfo as defaultExtractDisplayInfo } from '../Avatar/utils'; @@ -11,12 +12,12 @@ import { import type { PollVoteResponseData as PollVoteType } from 'stream-chat'; -const PollVoteTimestamp = ({ timestamp }: { timestamp: string | Date }) => { +const PollVoteTimestamp = ({ timestamp }: { timestamp?: string | Date }) => { const { t } = useTranslationContext(); const { handleEnter, handleLeave, tooltipVisible } = useEnterLeaveHandlers(); const [referenceElement, setReferenceElement] = useState(null); - const timestampDate = new Date(timestamp); + const timestampDate = timestamp ? new Date(timestamp) : undefined; return (
{ export const PollVote = ({ vote }: PollVoteProps) => (
- +
); diff --git a/src/components/Poll/__tests__/PollOptionList.test.tsx b/src/components/Poll/__tests__/PollOptionList.test.tsx index aa2caae09..7e141d0ea 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 f91699e48..be6f38dfc 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/Reactions/hooks/useProcessReactions.tsx b/src/components/Reactions/hooks/useProcessReactions.tsx index 5c9d3071a..c8245902f 100644 --- a/src/components/Reactions/hooks/useProcessReactions.tsx +++ b/src/components/Reactions/hooks/useProcessReactions.tsx @@ -5,6 +5,7 @@ import { defaultReactionOptions } from '../reactionOptions'; import type { MessageReactionsProps } from '../MessageReactions'; import type { ReactionsComparator, ReactionSummary } from '../types'; +import { nsToDate } from 'stream-chat'; export type UseProcessReactionsParams = Pick< MessageReactionsProps, @@ -128,9 +129,10 @@ export const useProcessReactions = (params: UseProcessReactionsParams) => { return [ { EmojiComponent: getEmojiByReactionType(reactionType), - firstReactionAt: first_reaction_at ? new Date(first_reaction_at) : null, + firstReactionAt: + first_reaction_at != null ? nsToDate(first_reaction_at) : null, isOwnReaction: isOwnReaction(reactionType), - lastReactionAt: last_reaction_at ? new Date(last_reaction_at) : null, + lastReactionAt: last_reaction_at != null ? nsToDate(last_reaction_at) : null, latestReactedUserNames, reactionCount: count, reactionType, diff --git a/src/components/Search/SearchResults/SearchResultItem.tsx b/src/components/Search/SearchResults/SearchResultItem.tsx index c8b276446..60a9a2e71 100644 --- a/src/components/Search/SearchResults/SearchResultItem.tsx +++ b/src/components/Search/SearchResults/SearchResultItem.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useMemo } from 'react'; import type { ComponentType } from 'react'; -import { formatMessage } from 'stream-chat'; +import { convertTimestampToDate, formatMessage } from 'stream-chat'; import type { Channel, ChannelResponse, @@ -182,7 +182,7 @@ export const UserSearchResultItem = ({ item, onSelect }: UserSearchResultItemPro
diff --git a/src/components/SummarizedMessagePreview/__tests__/useLatestMessagePreview.test.tsx b/src/components/SummarizedMessagePreview/__tests__/useLatestMessagePreview.test.tsx index 3b1b47d50..6bac566c1 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/Thread/ThreadHead.tsx b/src/components/Thread/ThreadHead.tsx index 1fd2931ca..8e2d60a98 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'; @@ -9,9 +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/components/Threads/ThreadList/ThreadListItemUI.tsx b/src/components/Threads/ThreadList/ThreadListItemUI.tsx index 40732e49a..541f38a92 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/__tests__/utils.a11y.test.ts b/src/components/Threads/ThreadList/__tests__/utils.a11y.test.ts index a319664af..97493db3d 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/components/Threads/ThreadList/utils.a11y.ts b/src/components/Threads/ThreadList/utils.a11y.ts index 2dc40eb3f..9a181c35c 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 { convertTimestampToDate } 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: convertTimestampToDate(createdAt)?.toISOString(), t, tDateTimeParser, timestampTranslationKey: 'timestamp.ChannelPreviewTimestamp', diff --git a/src/context/ChatContext.tsx b/src/context/ChatContext.tsx index 5b329db10..56ebdcde4 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 2ab0cb66a..a5ac8edd9 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 9ce83ebf9..d2fe45eac 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 ea28e0b56..fe047b1c7 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 a9ad06020..dea542d1e 100644 --- a/src/mock-builders/event/messageDelivered.ts +++ b/src/mock-builders/event/messageDelivered.ts @@ -1,10 +1,12 @@ import type { Channel, CustomChannelData, + CustomEventData, Event, StreamChat, UserResponse, } from 'stream-chat'; +import { convertDateToTimestamp } from '../generator/time'; type MessageDeliveredEvent = { channel_custom: CustomChannelData; @@ -12,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; @@ -27,20 +32,23 @@ 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'), + custom: {}, 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'), + custom: {}, id: 'test1', - last_active: '2025-09-16T13:22:52.69594176Z', + language: '', + 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 +72,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 d3e1ef328..3942abb95 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 1ce3bebd2..8fa242cd7 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 1e7d62bd4..0c6e45e7d 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 23de346bb..5e12f8328 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 d25627598..c8d20e8cd 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 07e7f2ae9..447c23877 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 2b4e6fd58..c03e5f433 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/index.ts b/src/mock-builders/generator/index.ts index 9d3b27eaa..8c5edd626 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 b43fec361..341b27a5c 100644 --- a/src/mock-builders/generator/message.ts +++ b/src/mock-builders/generator/message.ts @@ -1,20 +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 | string; - updated_at?: Date | 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; }; export const generateMessage = (options?: GenerateMessageOptions): LocalMessage => { + const { timestamp: seed, ...overrides } = options ?? {}; + const timestamp = convertDateToTimestamp(seed); const data = { __html: '

regular

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

regular

', id: nanoid(), mentioned_users: [], @@ -22,9 +30,9 @@ export const generateMessage = (options?: GenerateMessageOptions): LocalMessage status: 'received', text: nanoid(), type: 'regular', - updated_at: new Date(), + updated_at: timestamp, user: null, - ...options, + ...overrides, } as unknown as LocalMessage; 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 11275d196..ca2a38736 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 823c66f3e..ac591750f 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 82411439c..f8ab67c99 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 261663124..164940069 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 cf1dd67da..4cf46c947 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 000000000..3a6514763 --- /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 12b7fbcc7..c89803801 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 1102e5593..be721cac3 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 { convertTimestampToDate } 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. `convertTimestampToDate` 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 : convertTimestampToDate(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 b6ce97daa..305b5ac43 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,10 +135,10 @@ 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', + updated_at: convertDateToTimestamp('2026-03-10T15:53:00.000Z'), user: { id: 'user-1', name: 'Alice' }, }, { @@ -150,10 +151,10 @@ 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', + updated_at: convertDateToTimestamp('2026-02-05T15:53:00.000Z'), user: { id: 'user-2', name: 'Bob' }, }, { @@ -169,10 +170,10 @@ 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', + updated_at: convertDateToTimestamp('2026-02-01T15:53:00.000Z'), user: { id: 'user-1', name: 'Alice' }, }, ]; diff --git a/src/plugins/ChannelDetail/Views/ChannelManagementView/ChannelManagementView.tsx b/src/plugins/ChannelDetail/Views/ChannelManagementView/ChannelManagementView.tsx index 7310c3aaa..0b7add8cc 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/ChannelMediaView/ChannelMediaView.utils.ts b/src/plugins/ChannelDetail/Views/ChannelMediaView/ChannelMediaView.utils.ts index 3859c3597..e7744463a 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/ChannelMediaView/__tests__/ChannelMediaView.test.tsx b/src/plugins/ChannelDetail/Views/ChannelMediaView/__tests__/ChannelMediaView.test.tsx index 15cf994e9..482d6733d 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/ChannelMemberDetailView/ChannelMemberDetail.tsx b/src/plugins/ChannelDetail/Views/ChannelMemberDetailView/ChannelMemberDetail.tsx index a0a98562c..f2e02889d 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'; @@ -35,13 +36,13 @@ 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 }}', { timestamp: t('timestamp.ChannelMembersLastActive', { - timestamp: user.last_active, + timestamp: convertTimestampToDate(user.last_active), }), }, ); diff --git a/src/plugins/ChannelDetail/Views/ChannelMemberDetailView/__tests__/ChannelMemberDetail.test.tsx b/src/plugins/ChannelDetail/Views/ChannelMemberDetailView/__tests__/ChannelMemberDetail.test.tsx index 1a05062c5..4ddae81f7 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/ChannelMembersBrowseView.tsx b/src/plugins/ChannelDetail/Views/ChannelMembersView/ChannelMembersBrowseView.tsx index 429da89c0..66b0131b0 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'; @@ -38,13 +39,13 @@ 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 }}', { timestamp: t('timestamp.ChannelMembersLastActive', { - timestamp: user.last_active, + timestamp: convertTimestampToDate(user.last_active), }), }, ); diff --git a/src/plugins/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersBrowseView.test.tsx b/src/plugins/ChannelDetail/Views/ChannelMembersView/__tests__/ChannelMembersBrowseView.test.tsx index b20b98d36..600aa080f 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', }, diff --git a/src/plugins/ChannelDetail/Views/PinnedMessagesView/PinnedMessagesView.tsx b/src/plugins/ChannelDetail/Views/PinnedMessagesView/PinnedMessagesView.tsx index 91967df6d..5c1f4e6e9 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 { convertTimestampToDate } 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. `convertTimestampToDate` + * rather than `new Date`: a nanosecond value is out of Date's range. + */ +const normalizeTimestamp = (timestamp: PinnedMessage['created_at']) => + timestamp == null ? undefined : convertTimestampToDate(timestamp)?.toISOString(); const getPinnedMessagePreview = ( message: PinnedMessage, diff --git a/src/plugins/ChannelDetail/Views/PinnedMessagesView/__tests__/PinnedMessagesView.test.tsx b/src/plugins/ChannelDetail/Views/PinnedMessagesView/__tests__/PinnedMessagesView.test.tsx index 4b4e8ef57..8ce4efe61 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(); diff --git a/yarn.lock b/yarn.lock index 412b4fb32..479c87a66 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1829,7 +1829,7 @@ __metadata: emoji-mart: "npm:^5.6.0" react: "npm:^19.2.6" react-dom: "npm:^19.2.6" - stream-chat: "npm:10.0.0-rc.7" + stream-chat: "npm:10.0.0-rc.9" stream-chat-react: "workspace:^" typescript: "npm:^6.0.3" vite: "npm:^8.1.3" @@ -1856,7 +1856,7 @@ __metadata: react: "npm:^19.2.6" react-dom: "npm:^19.2.6" sass: "npm:^1.100.0" - stream-chat: "npm:10.0.0-rc.7" + stream-chat: "npm:10.0.0-rc.9" stream-chat-react: "workspace:^" typescript: "npm:^6.0.3" vite: "npm:^8.1.3" @@ -9516,7 +9516,7 @@ __metadata: remark-parse: "npm:^11.0.0" sass: "npm:^1.100.0" semantic-release: "npm:^25.0.3" - stream-chat: "npm:10.0.0-rc.7" + stream-chat: "npm:10.0.0-rc.9" typescript: "npm:^6.0.3" typescript-eslint: "npm:^8.59.4" unified: "npm:^11.0.5" @@ -9534,7 +9534,7 @@ __metadata: modern-normalize: ^3.0.1 react: ^19.0.0 || ^18.0.0 || ^17.0.0 react-dom: ^19.0.0 || ^18.0.0 || ^17.0.0 - stream-chat: 10.0.0-rc.7 + stream-chat: 10.0.0-rc.9 dependenciesMeta: "@parcel/watcher": built: true @@ -9560,9 +9560,9 @@ __metadata: languageName: unknown linkType: soft -"stream-chat@npm:10.0.0-rc.7": - version: 10.0.0-rc.7 - resolution: "stream-chat@npm:10.0.0-rc.7" +"stream-chat@npm:10.0.0-rc.9": + version: 10.0.0-rc.9 + resolution: "stream-chat@npm:10.0.0-rc.9" dependencies: "@stream-io/logger": "npm:^2.0.0" axios: "npm:^1.19.0" @@ -9574,7 +9574,7 @@ __metadata: built: true husky: built: true - checksum: 10c0/91a0fbe0bd0df6107b9ee774c638dcee621931003d552fd5f1813eeb07c736363ff95429e15435c86d5ca11dd1b3bc8d6ac3f914a8c91d8aabf70c0d6892fb2b + checksum: 10c0/4409003abe465e7d75d5b55daf6a197aeec346a9dcbbb84ad729dc2c31155899b6cf464d3a3be3249cf132b1cb3b67e4e7d0e3356379f22160779581b7b38dd1 languageName: node linkType: hard