Skip to content

Commit df60c5c

Browse files
committed
Merge branch 'release-v15' into feat/v15-port/local-unread-count
# Conflicts: # examples/tutorial/package.json # examples/vite/package.json # package.json # yarn.lock
2 parents 9180358 + aaa7fa3 commit df60c5c

19 files changed

Lines changed: 102 additions & 67 deletions

File tree

‎ai-docs/ai-migration-v14-v15.md‎

Lines changed: 37 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -110,28 +110,40 @@ To ingest an ad-hoc channel (e.g. navigating to a DM or search result) into the
110110

111111
## Dates on response types are unix-nanosecond numbers
112112

113-
`stream-chat` now types every **server-sent** date as the unix-nanosecond `number` the API puts on the
113+
`stream-chat` now types every **server-sent** date as the unix-nanosecond number the API puts on the
114114
wire — `created_at`, `updated_at`, `last_read`, and every sibling on a response or event. It is not a
115115
`Date` and not an ISO string, and the React types that carry those values through changed with it.
116116

117-
Two failure modes, neither of which is a type error:
117+
The type is **`TimestampNS`**, a branded `number`. Reading, comparing, sorting and subtracting work as
118+
with any number. Two things change at compile time:
118119

119-
- **Every `Date`-based path is out of range.** `Date` tops out near 8.64e15 ms while a current
120-
timestamp is ~1.79e18, and a date library reads a bare number as **milliseconds** — so both land on
121-
an invalid instance rather than on a plausible wrong date. `.toISOString()` throws
122-
`RangeError: Invalid time value`, usually mid-render; `dayjs(created_at).format()` instead returns
123-
the literal string `Invalid Date` and renders it on screen.
124-
- **A unit mix-up between two `number`s is the silent one.** Comparing a wire timestamp against
125-
`Date.now()`, or adding a millisecond duration to one, produces a plausible-looking number and no
126-
complaint at all — see `headerPosition` below for a case with no type change to warn you.
120+
- **`new Date(timestamp)` is a type error.** `stream-chat`'s published types augment the global
121+
`DateConstructor`, because a nanosecond value is out of `Date`'s range (`Date` tops out near 8.64e15
122+
ms; a current timestamp is ~1.79e18) and yields an Invalid Date whose `.toISOString()` throws.
123+
- **Minting one needs a helper.** A plain `number` is not assignable to a `TimestampNS` field or prop:
124+
use `nowNs()`, `msToNs(ms)`, `dateToNs(date)`, or `asTimestampNS(n)` for a value that is already in
125+
nanoseconds (a fixture, a stored value, the epoch `asTimestampNS(0)`). Arithmetic drops the brand —
126+
wrap the result in `asTimestampNS` when it goes back into a timestamp.
127+
128+
What the compiler still does **not** catch:
129+
130+
- **Date libraries.** A date library reads a bare number as **milliseconds**, so
131+
`dayjs(created_at).format()` returns the literal string `Invalid Date` and renders it on screen.
132+
- **Fallbacks and derived values.** `new Date(ts ?? Date.now())` and `new Date(Math.max(a, b))`
133+
compile, because the argument is no longer purely `TimestampNS`. Convert first, then fall back.
134+
- **A unit mix-up between two numbers.** Comparing a wire timestamp against `Date.now()`, or adding a
135+
millisecond duration to one, produces a plausible-looking number and no complaint at all.
127136

128137
### The public React types that changed
129138

130-
| Type | v14 | v15 |
131-
| ----------------------------------------------------- | ----------------------------- | ------------------------------- |
132-
| `ChatContextValue.latestMessageDatesByChannels` | `Record<ChannelConfId, Date>` | `Record<ChannelConfId, number>` |
133-
| `ProcessMessagesParams.lastRead` (`processMessages`) | `Date \| null` | `number \| null` |
134-
| `VirtualizedMessageList` render props: `lastReadDate` | `Date \| null` | `number \| null` |
139+
| Type | v14 | v15 |
140+
| ----------------------------------------------------- | ------------------- | ----------------------- |
141+
| `ProcessMessagesParams.lastRead` (`processMessages`) | `Date \| null` | `TimestampNS \| null` |
142+
| `VirtualizedMessageList` render props: `lastReadDate` | `Date \| null` | `TimestampNS \| null` |
143+
| `MessageList` `headerPosition` / `insertIntro` | `number` (epoch ms) | `TimestampNS` (unix ns) |
144+
145+
`ChatContextValue.latestMessageDatesByChannels` is not in this table because it is **removed**, not
146+
retyped — see [below](#chatcontextlatestmessagedatesbychannels--removed).
135147

136148
`DateSeparatorMessage` (a member of the exported `RenderedMessage` union) changed shape rather than
137149
type: it **lost its `type: MessageLabel` field**, and `unread` is now optional. The `type` field was
@@ -144,10 +156,10 @@ Comparisons get simpler, not harder — compare and sort the raw numbers and dro
144156

145157
```ts
146158
// v14
147-
if (latestMessageDatesByChannels[cid].getTime() < new Date(message.created_at).getTime()) { … }
159+
if (new Date(a.created_at).getTime() < new Date(b.created_at).getTime()) { … }
148160

149161
// v15
150-
if (latestMessageDatesByChannels[cid] < message.created_at) { … }
162+
if (a.created_at < b.created_at) { … }
151163
```
152164

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

181-
`nsToDate` / `dateToNs` / `nsToMs` / `msToNs` / `nowNs` are exported alongside it for values known to be
182-
present. Note that **outgoing request** date fields are still `Date` (filter bounds like
193+
`nsToDate` / `dateToNs` / `nsToMs` / `msToNs` / `nowNs` / `asTimestampNS` are exported alongside it
194+
for values known to be present. Note that **outgoing request** date fields are still `Date` (filter bounds like
183195
`created_at_before`, plus `remind_at` and `message_timestamp`) — `JSON.stringify` emits RFC3339 for a
184196
`Date`, which is what the request spec declares. Use `nsToDate` when handing a server-sent timestamp
185197
back to the API.
186198

187-
### `MessageList`'s `headerPosition` prop changed unit, not type
199+
### `MessageList`'s `headerPosition` prop changed unit
188200

189201
`headerPosition` is compared against `message.created_at`, so it is now **unix nanoseconds** — it was
190-
epoch milliseconds while `created_at` was a `Date`. The type is still `number`, so nothing warns.
202+
epoch milliseconds while `created_at` was a `Date`. It is typed `TimestampNS`, so a millisecond
203+
`number` no longer compiles: pass `message.created_at` or `msToNs(ms)`.
191204

192205
### Peer-dependency gate before release
193206

@@ -202,7 +215,9 @@ range to the version that exports them and verify from a clean install with no `
202215
A fixture that hands the SDK a `Date` cannot catch either failure mode above, and will diverge from
203216
runtime behavior. The SDK's own suite normalizes through
204217
`mock-builders/generator/time.ts` (`convertDateToTimestamp`), which accepts a `Date`, an ISO string or a
205-
raw wire number so tests stay readable while the value on the wire stays a number.
218+
raw wire number so tests stay readable while the value on the wire stays a number. It returns
219+
`TimestampNS`, so a generated fixture is assignable to the response types; a hand-written literal
220+
(`created_at: 0`, `now - msToNs(1000)`) needs `asTimestampNS(...)`.
206221

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

‎ai-docs/i18n-v15-migration.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -336,7 +336,7 @@ hand them straight to a provider.
336336
const { translators } = useChat({ client, defaultLanguage, i18nInstance });
337337

338338
// v15
339-
const { getAppSettings, latestMessageDatesByChannels, mutes } = useChat({ client });
339+
const { getAppSettings, mutes } = useChat({ client });
340340
const translators = useStreami18n({ client, i18nInstance });
341341
```
342342

‎examples/vite/src/AppSettings/ActionsMenu/WebSocketEventPromptDialog/websocketEventAutomation.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { nowNs } from 'stream-chat';
22
import type {
33
Channel,
4+
ChannelMemberPartialResponse,
45
ChannelMemberResponse,
56
MessageResponse,
67
ReactionResponse,
@@ -25,7 +26,8 @@ type UnknownRecord = Record<string, unknown>;
2526
*/
2627
type EventPayload = UnknownRecord & {
2728
channel?: Partial<WebSocketEventTemplateContext['channel']>;
28-
member?: ChannelMemberResponse;
29+
// Typing events carry the partial member shape (`TypingStartEvent.member`), not a full response.
30+
member?: ChannelMemberResponse | ChannelMemberPartialResponse;
2931
message?: Partial<MessageResponse>;
3032
reaction?: ReactionResponse;
3133
user?: UserResponse;

‎examples/vite/src/AppSettings/ActionsMenu/WebSocketEventPromptDialog/websocketEventTemplates.ts‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type {
44
ChannelMemberResponse,
55
ChannelResponse,
66
StreamChat,
7+
TimestampNS,
78
UserResponse,
89
} from 'stream-chat';
910

@@ -103,9 +104,9 @@ export type WebSocketEventTemplateContext = {
103104
channelType: string;
104105
cid: string;
105106
/** Unix nanoseconds, the unit every server-sent date uses on the wire. */
106-
createdAt: number;
107+
createdAt: TimestampNS;
107108
/** Unix nanoseconds, the unit every server-sent date uses on the wire. */
108-
lastReadAt: number;
109+
lastReadAt: TimestampNS;
109110
memberCount: number;
110111
messageId: string;
111112
otherMember: ChannelMemberResponse;
@@ -123,7 +124,7 @@ type BuildChannelSeedContext = Omit<WebSocketEventTemplateContext, 'channel'> &
123124
channel: Partial<DebugChannelResponse>;
124125
};
125126

126-
const createFallbackUser = (id: string, createdAt: number): DebugUserResponse => ({
127+
const createFallbackUser = (id: string, createdAt: TimestampNS): DebugUserResponse => ({
127128
banned: false,
128129
blocked_user_ids: [],
129130
created_at: createdAt,

‎src/components/Accessibility/__tests__/NotificationAnnouncer.test.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { useNotifications } from '../../Notifications/hooks/useNotifications';
1212
import { TranslationProvider } from '../../../context';
1313
import { mockTranslationContextValue } from 'mock-builders';
1414

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

1818
vi.mock('../../Notifications/hooks/useNotifications', () => ({

‎src/components/Attachment/__tests__/Geolocation.test.tsx‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
initClientWithChannels,
1111
} from '../../../mock-builders';
1212
import type { Channel as ChannelType, StreamChat } from 'stream-chat';
13-
import { msToNs, nowNs } from 'stream-chat';
13+
import { asTimestampNS, msToNs, nowNs } from 'stream-chat';
1414
import { convertDateToTimestamp } from '../../../mock-builders/generator/time';
1515

1616
const GeolocationMapComponent = (props) => (
@@ -116,7 +116,7 @@ describe.each([
116116

117117
it('renders own live location', async () => {
118118
const location = generateLiveLocationResponse({
119-
end_at: nowNs() + msToNs(10000),
119+
end_at: asTimestampNS(nowNs() + msToNs(10000)),
120120
user_id: ownUser.id,
121121
});
122122
await renderComponent({
@@ -142,7 +142,7 @@ describe.each([
142142
});
143143
it("other user's live location", async () => {
144144
const location = generateLiveLocationResponse({
145-
end_at: nowNs() + msToNs(10000),
145+
end_at: asTimestampNS(nowNs() + msToNs(10000)),
146146
user_id: otherUser.id,
147147
});
148148
await renderComponent({

‎src/components/Message/__tests__/MessageTimestamp.test.tsx‎

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -169,9 +169,7 @@ describe('<MessageTimestamp />', () => {
169169
props: { format: 'YYYY' },
170170
});
171171
expect(container).toHaveTextContent(
172-
nsToDate(messageMock.created_at as unknown as number)
173-
.getFullYear()
174-
.toString(),
172+
nsToDate(messageMock.created_at).getFullYear().toString(),
175173
);
176174
});
177175

@@ -188,9 +186,7 @@ describe('<MessageTimestamp />', () => {
188186
props: { format: 'YYYY' },
189187
});
190188
expect(container).toHaveTextContent(
191-
nsToDate(messageMock.created_at as unknown as number)
192-
.getFullYear()
193-
.toString(),
189+
nsToDate(messageMock.created_at).getFullYear().toString(),
194190
);
195191
});
196192

‎src/components/Message/__tests__/ReminderNotification.test.tsx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import React from 'react';
2-
import { Reminder } from 'stream-chat';
2+
import { asTimestampNS, Reminder } from 'stream-chat';
33
import { act, render, type RenderResult } from '@testing-library/react';
44
import { Chat } from '../../Chat';
55
import { ReminderNotification } from '../ReminderNotification';
@@ -41,7 +41,7 @@ describe('ReminderNotification', () => {
4141
// truthiness guard renders "Saved for later" for what is really a long-overdue reminder.
4242
const reminder = new Reminder({
4343
data: generateReminderResponse({
44-
data: { remind_at: 0 },
44+
data: { remind_at: asTimestampNS(0) },
4545
}),
4646
});
4747
const { container } = await renderComponent({ reminder });

‎src/components/MessageList/MessageList.tsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import type {
3939
LocalMessage,
4040
MessageFocusSignalState,
4141
MessagePaginatorState,
42+
TimestampNS,
4243
UnreadSnapshotState,
4344
} from 'stream-chat';
4445
import type { GroupStyle, ProcessMessagesParams, RenderedMessage } from './utils';
@@ -469,7 +470,7 @@ export type MessageListProps = Partial<Pick<MessageProps, PropsDrilledToMessage>
469470
* Position to render HeaderComponent, as a timestamp in the same unit as `message.created_at` —
470471
* i.e. unix nanoseconds. Was milliseconds while `created_at` was a `Date`.
471472
*/
472-
headerPosition?: number;
473+
headerPosition?: TimestampNS;
473474
// todo: data manipulation - should live in MessagePaginator
474475
/** Hides the MessageDeleted components from the list, defaults to `false` */
475476
hideDeletedMessages?: boolean;

‎src/components/MessageList/VirtualizedMessageList.tsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ import type {
7171
MessageFocusSignalState,
7272
MessagePaginatorState,
7373
ChannelState as StreamChannelState,
74+
TimestampNS,
7475
UnreadSnapshotState,
7576
UserResponse,
7677
} from 'stream-chat';
@@ -130,7 +131,7 @@ export type VirtuosoContext = Required<
130131
/** Message id which was marked as unread. ALl the messages following this message are considered unrea. */
131132
firstUnreadMessageId: string | null;
132133
/** Unix nanoseconds, as `messagePaginator.unreadStateSnapshot.lastReadAt` carries it. */
133-
lastReadDate: number | null;
134+
lastReadDate: TimestampNS | null;
134135
/**
135136
* The ID of the last message considered read by the current user in the current channel.
136137
* All the messages following this message are considered unread.

0 commit comments

Comments
 (0)