Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 37 additions & 22 deletions ai-docs/ai-migration-v14-v15.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,28 +110,40 @@ To ingest an ad-hoc channel (e.g. navigating to a DM or search result) into the

## 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
`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:
The type is **`TimestampNS`**, a branded `number`. Reading, comparing, sorting and subtracting work as
with any number. Two things change at compile time:

- **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.
- **`new Date(timestamp)` is a type error.** `stream-chat`'s published types augment the global
`DateConstructor`, because a nanosecond value is out of `Date`'s range (`Date` tops out near 8.64e15
ms; a current timestamp is ~1.79e18) and yields an Invalid Date whose `.toISOString()` throws.
- **Minting one needs a helper.** A plain `number` is not assignable to a `TimestampNS` field or prop:
use `nowNs()`, `msToNs(ms)`, `dateToNs(date)`, or `asTimestampNS(n)` for a value that is already in
nanoseconds (a fixture, a stored value, the epoch `asTimestampNS(0)`). Arithmetic drops the brand —
wrap the result in `asTimestampNS` when it goes back into a timestamp.

What the compiler still does **not** catch:

- **Date libraries.** A date library reads a bare number as **milliseconds**, so
`dayjs(created_at).format()` returns the literal string `Invalid Date` and renders it on screen.
- **Fallbacks and derived values.** `new Date(ts ?? Date.now())` and `new Date(Math.max(a, b))`
compile, because the argument is no longer purely `TimestampNS`. Convert first, then fall back.
- **A unit mix-up between two numbers.** Comparing a wire timestamp against `Date.now()`, or adding a
millisecond duration to one, produces a plausible-looking number and no complaint at all.

### The public React types that changed

| Type | v14 | v15 |
| ----------------------------------------------------- | ----------------------------- | ------------------------------- |
| `ChatContextValue.latestMessageDatesByChannels` | `Record<ChannelConfId, Date>` | `Record<ChannelConfId, number>` |
| `ProcessMessagesParams.lastRead` (`processMessages`) | `Date \| null` | `number \| null` |
| `VirtualizedMessageList` render props: `lastReadDate` | `Date \| null` | `number \| null` |
| Type | v14 | v15 |
| ----------------------------------------------------- | ------------------- | ----------------------- |
| `ProcessMessagesParams.lastRead` (`processMessages`) | `Date \| null` | `TimestampNS \| null` |
| `VirtualizedMessageList` render props: `lastReadDate` | `Date \| null` | `TimestampNS \| null` |
| `MessageList` `headerPosition` / `insertIntro` | `number` (epoch ms) | `TimestampNS` (unix ns) |

`ChatContextValue.latestMessageDatesByChannels` is not in this table because it is **removed**, not
retyped — see [below](#chatcontextlatestmessagedatesbychannels--removed).

`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
Expand All @@ -144,10 +156,10 @@ Comparisons get simpler, not harder — compare and sort the raw numbers and dro

```ts
// v14
if (latestMessageDatesByChannels[cid].getTime() < new Date(message.created_at).getTime()) { … }
if (new Date(a.created_at).getTime() < new Date(b.created_at).getTime()) { … }

// v15
if (latestMessageDatesByChannels[cid] < message.created_at) { … }
if (a.created_at < b.created_at) { … }
```

### Presentational props still take `Date`
Expand Down Expand Up @@ -178,16 +190,17 @@ const createdAt = convertTimestampToDate(message.created_at);
<DateSeparator date={convertTimestampToDate(message.created_at) ?? new Date()} />
```

`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
`nsToDate` / `dateToNs` / `nsToMs` / `msToNs` / `nowNs` / `asTimestampNS` 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
### `MessageList`'s `headerPosition` prop changed unit

`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.
epoch milliseconds while `created_at` was a `Date`. It is typed `TimestampNS`, so a millisecond
`number` no longer compiles: pass `message.created_at` or `msToNs(ms)`.

### Peer-dependency gate before release

Expand All @@ -202,7 +215,9 @@ range to the version that exports them and verify from a clean install with no `
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.
raw wire number so tests stay readable while the value on the wire stays a number. It returns
`TimestampNS`, so a generated fixture is assignable to the response types; a hand-written literal
(`created_at: 0`, `now - msToNs(1000)`) needs `asTimestampNS(...)`.

## i18n: English-only bundle, namespaced translation keys

Expand Down
2 changes: 1 addition & 1 deletion ai-docs/i18n-v15-migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -336,7 +336,7 @@ hand them straight to a provider.
const { translators } = useChat({ client, defaultLanguage, i18nInstance });

// v15
const { getAppSettings, latestMessageDatesByChannels, mutes } = useChat({ client });
const { getAppSettings, mutes } = useChat({ client });
const translators = useStreami18n({ client, i18nInstance });
```

Expand Down
2 changes: 1 addition & 1 deletion examples/tutorial/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
"emoji-mart": "^5.6.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"stream-chat": "10.0.0-rc.13",
"stream-chat": "10.0.0-rc.14",
"stream-chat-react": "workspace:^"
},
"devDependencies": {
Expand Down
2 changes: 1 addition & 1 deletion examples/vite/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
"modern-normalize": "^3.0.1",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"stream-chat": "10.0.0-rc.13",
"stream-chat": "10.0.0-rc.14",
"stream-chat-react": "workspace:^"
},
"devDependencies": {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { nowNs } from 'stream-chat';
import type {
Channel,
ChannelMemberPartialResponse,
ChannelMemberResponse,
MessageResponse,
ReactionResponse,
Expand All @@ -25,7 +26,8 @@ type UnknownRecord = Record<string, unknown>;
*/
type EventPayload = UnknownRecord & {
channel?: Partial<WebSocketEventTemplateContext['channel']>;
member?: ChannelMemberResponse;
// Typing events carry the partial member shape (`TypingStartEvent.member`), not a full response.
member?: ChannelMemberResponse | ChannelMemberPartialResponse;
message?: Partial<MessageResponse>;
reaction?: ReactionResponse;
user?: UserResponse;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type {
ChannelMemberResponse,
ChannelResponse,
StreamChat,
TimestampNS,
UserResponse,
} from 'stream-chat';

Expand Down Expand Up @@ -103,9 +104,9 @@ export type WebSocketEventTemplateContext = {
channelType: string;
cid: string;
/** Unix nanoseconds, the unit every server-sent date uses on the wire. */
createdAt: number;
createdAt: TimestampNS;
/** Unix nanoseconds, the unit every server-sent date uses on the wire. */
lastReadAt: number;
lastReadAt: TimestampNS;
memberCount: number;
messageId: string;
otherMember: ChannelMemberResponse;
Expand All @@ -123,7 +124,7 @@ type BuildChannelSeedContext = Omit<WebSocketEventTemplateContext, 'channel'> &
channel: Partial<DebugChannelResponse>;
};

const createFallbackUser = (id: string, createdAt: number): DebugUserResponse => ({
const createFallbackUser = (id: string, createdAt: TimestampNS): DebugUserResponse => ({
banned: false,
blocked_user_ids: [],
created_at: createdAt,
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@
"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.13"
"stream-chat": "^10.0.0-rc.14"
},
"peerDependenciesMeta": {
"@breezystack/lamejs": {
Expand Down Expand Up @@ -201,7 +201,7 @@
"react-dom": "^19.2.6",
"sass": "^1.100.0",
"semantic-release": "^25.0.3",
"stream-chat": "10.0.0-rc.13",
"stream-chat": "10.0.0-rc.14",
"typescript": "^6.0.3",
"typescript-eslint": "^8.59.4",
"vite": "^8.1.3",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { useNotifications } from '../../Notifications/hooks/useNotifications';
import { TranslationProvider } from '../../../context';
import { mockTranslationContextValue } from 'mock-builders';

import type { Notification } from '../../../../../stream-chat-js/src';
import type { Notification } from 'stream-chat';
import { mockT } from '../../../mock-builders/translator';

vi.mock('../../Notifications/hooks/useNotifications', () => ({
Expand Down
6 changes: 3 additions & 3 deletions src/components/Attachment/__tests__/Geolocation.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
initClientWithChannels,
} from '../../../mock-builders';
import type { Channel as ChannelType, StreamChat } from 'stream-chat';
import { msToNs, nowNs } from 'stream-chat';
import { asTimestampNS, msToNs, nowNs } from 'stream-chat';
import { convertDateToTimestamp } from '../../../mock-builders/generator/time';

const GeolocationMapComponent = (props) => (
Expand Down Expand Up @@ -116,7 +116,7 @@ describe.each([

it('renders own live location', async () => {
const location = generateLiveLocationResponse({
end_at: nowNs() + msToNs(10000),
end_at: asTimestampNS(nowNs() + msToNs(10000)),
user_id: ownUser.id,
});
await renderComponent({
Expand All @@ -142,7 +142,7 @@ describe.each([
});
it("other user's live location", async () => {
const location = generateLiveLocationResponse({
end_at: nowNs() + msToNs(10000),
end_at: asTimestampNS(nowNs() + msToNs(10000)),
user_id: otherUser.id,
});
await renderComponent({
Expand Down
8 changes: 2 additions & 6 deletions src/components/Message/__tests__/MessageTimestamp.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -169,9 +169,7 @@ describe('<MessageTimestamp />', () => {
props: { format: 'YYYY' },
});
expect(container).toHaveTextContent(
nsToDate(messageMock.created_at as unknown as number)
.getFullYear()
.toString(),
nsToDate(messageMock.created_at).getFullYear().toString(),
);
});

Expand All @@ -188,9 +186,7 @@ describe('<MessageTimestamp />', () => {
props: { format: 'YYYY' },
});
expect(container).toHaveTextContent(
nsToDate(messageMock.created_at as unknown as number)
.getFullYear()
.toString(),
nsToDate(messageMock.created_at).getFullYear().toString(),
);
});

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React from 'react';
import { Reminder } from 'stream-chat';
import { asTimestampNS, Reminder } from 'stream-chat';
import { act, render, type RenderResult } from '@testing-library/react';
import { Chat } from '../../Chat';
import { ReminderNotification } from '../ReminderNotification';
Expand Down Expand Up @@ -41,7 +41,7 @@ describe('ReminderNotification', () => {
// truthiness guard renders "Saved for later" for what is really a long-overdue reminder.
const reminder = new Reminder({
data: generateReminderResponse({
data: { remind_at: 0 },
data: { remind_at: asTimestampNS(0) },
}),
});
const { container } = await renderComponent({ reminder });
Expand Down
3 changes: 2 additions & 1 deletion src/components/MessageList/MessageList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import type {
LocalMessage,
MessageFocusSignalState,
MessagePaginatorState,
TimestampNS,
UnreadSnapshotState,
} from 'stream-chat';
import type { GroupStyle, ProcessMessagesParams, RenderedMessage } from './utils';
Expand Down Expand Up @@ -469,7 +470,7 @@ export type MessageListProps = Partial<Pick<MessageProps, PropsDrilledToMessage>
* 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;
headerPosition?: TimestampNS;
// todo: data manipulation - should live in MessagePaginator
/** Hides the MessageDeleted components from the list, defaults to `false` */
hideDeletedMessages?: boolean;
Expand Down
3 changes: 2 additions & 1 deletion src/components/MessageList/VirtualizedMessageList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ import type {
MessageFocusSignalState,
MessagePaginatorState,
ChannelState as StreamChannelState,
TimestampNS,
UnreadSnapshotState,
UserResponse,
} from 'stream-chat';
Expand Down Expand Up @@ -130,7 +131,7 @@ export type VirtuosoContext = Required<
/** Message id which was marked as unread. ALl the messages following this message are considered unrea. */
firstUnreadMessageId: string | null;
/** Unix nanoseconds, as `messagePaginator.unreadStateSnapshot.lastReadAt` carries it. */
lastReadDate: number | null;
lastReadDate: TimestampNS | 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.
Expand Down
Loading
Loading