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
54 changes: 34 additions & 20 deletions ai-docs/ai-migration-v9-to-v10.md
Original file line number Diff line number Diff line change
Expand Up @@ -1086,31 +1086,45 @@ If you call the `stream-chat` client/channel directly or annotate with its types
guides in `stream-chat-js`: `v9-to-v10-migration-guide-{type-renames,other,sort,methods,logging,client-construction}.md`.
Highlights that hit integrator code:

- **Server-sent dates are unix-nanosecond `number`s** on every response and event type (`created_at`,
`updated_at`, `last_read`, …) — not `Date` objects and not ISO strings. Two consequences, neither
of which is a type error:
- **Every `Date`-based path is out of range** (`Date` tops out near 8.64e15 ms; 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`; `dayjs(ns).format()` returns the literal string `Invalid Date`. In this SDK that
string is then swallowed by the `withoutInvalidDate` guard in `src/i18n/utils.ts`, so a missed
conversion shows up as a **blank timestamp** rather than as an error or a wrong date.
- **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.
- **Server-sent dates are unix-nanosecond numbers** on every response and event type (`created_at`,
`updated_at`, `last_read`, …) — not `Date` objects and not ISO strings. They are typed
**`TimestampNS`**, a branded `number`: reading, comparing, sorting and subtracting work as with any
number. At compile time:
- **`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.
- **Minting one needs a helper.** A plain `number` is not assignable to a `TimestampNS` field or
parameter: use `nowNs()`, `msToNs(ms)`, `dateToNs(date)`, or `asTimestampNS(n)` for a value that
is already in nanoseconds — a SQLite row, a fixture, 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(ns).format()` returns the literal string `Invalid Date`. In this SDK that string is then
swallowed by the `withoutInvalidDate` guard in `src/i18n/utils.ts`, so a missed conversion shows
up as a **blank timestamp** rather than as an error or a wrong date.
- **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.

Convert at the boundary with the helpers `stream-chat` exports — `convertTimestampToDate(ts)`
(guarded, returns `undefined` for an absent or non-finite value), or `nsToDate` / `dateToNs` /
`nsToMs` / `msToNs` / `nowNs` when the value is known to be present. Compare and sort the raw
numbers directly; only convert where a `Date` is actually required.
`nsToMs` / `msToNs` / `nowNs` / `asTimestampNS` when the value is known to be present. Compare and
sort the raw numbers directly; only convert where a `Date` is actually required.

What changed on **this SDK's** own surface:
- **`findInMessagesByDate(messages, targetTimestamp)`** takes a unix-nanosecond `number` (was a
`Date`). Exported from the package root.
- **`getChannelUnreadState`** returns `last_read` as a `number`, and `0` — not `new Date(0)` — is
the "never read" sentinel. Guard it with `!= null`, never with truthiness.
- **`useIsChannelMuted`**'s `muteStatus` mirrors core: `{ createdAt: number | null; expiresAt:
number | null; muted: boolean }`.
- **`findInMessagesByDate(messages, targetTimestamp)`** takes a `TimestampNS` (was a `Date`).
Exported from the package root. Pass a server timestamp, or `msToNs(ms)` / `dateToNs(date)`.
- **`getChannelUnreadState`** returns `last_read` as a `TimestampNS`, and the epoch
(`asTimestampNS(0)`) — not `new Date(0)` — is the "never read" sentinel. Guard it with `!= null`,
never with truthiness.
- **`useIsChannelMuted`**'s `muteStatus` is core's `ChannelMuteStatus`: `{ createdAt: TimestampNS |
null; expiresAt: TimestampNS | null; muted: boolean }`.
- **Offline DB rows** hold plain integers. The SDK's read mappers brand them on the way out
(`mapStorableToTimestamp` / `mapStorableToRequiredTimestamp`), so hydrated state carries
`TimestampNS` like live state. If you read the SQLite tables yourself, brand with `asTimestampNS`,
never `msToNs` — the value is already in nanoseconds.
- **`getDateString` no longer rescales by magnitude.** The `normalizeTimestamp` helper in
`src/i18n/utils.ts` that used to convert an out-of-range number for you is gone, so a call site
that skips `convertTimestampToDate` now renders a **blank** timestamp (the `withoutInvalidDate`
Expand Down
3 changes: 2 additions & 1 deletion examples/ExpoMessaging/app/map/[id].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { SafeAreaView } from 'react-native-safe-area-context';

import { Stack, useLocalSearchParams } from 'expo-router';
import {
asTimestampNS,
Channel,
convertTimestampToDate,
nowNs,
Expand Down Expand Up @@ -68,7 +69,7 @@ const MapScreenFooter = ({
// `end_at` arrives as a route-param string holding a unix-**nanosecond** timestamp, so it is
// parsed back to a number before any comparison: `new Date(ns)` is out of range, and
// `convertTimestampToDate` rejects a string outright (`Number.isFinite('1788…')` is false).
const endAt = end_at != null ? Number(end_at) : undefined;
const endAt = end_at != null ? asTimestampNS(Number(end_at)) : undefined;
const liveLocationActive =
!isLiveLocationStopped && endAt !== undefined && Number.isFinite(endAt) && endAt > nowNs();
const formattedEndedAt = convertTimestampToDate(endAt)?.toLocaleString() ?? '';
Expand Down
2 changes: 1 addition & 1 deletion examples/ExpoMessaging/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@
"react-native-teleport": "^1.1.12",
"react-native-web": "^0.21.2",
"react-native-worklets": "0.11.1",
"stream-chat": "^10.0.0-rc.13",
"stream-chat": "^10.0.0-rc.14",
"stream-chat-expo": "workspace:^",
"stream-chat-react-native-core": "workspace:^"
},
Expand Down
2 changes: 1 addition & 1 deletion examples/SampleApp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@
"react-native-teleport": "^1.1.12",
"react-native-video": "^6.19.2",
"react-native-worklets": "^0.12.1",
"stream-chat": "^10.0.0-rc.13",
"stream-chat": "^10.0.0-rc.14",
"stream-chat-react-native": "workspace:^",
"stream-chat-react-native-core": "workspace:^"
},
Expand Down
3 changes: 2 additions & 1 deletion examples/SampleApp/src/components/DraftsList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
DraftResponse,
LocalMessage,
MessageResponse,
TimestampNS,
} from 'stream-chat';
import {
useChatContext,
Expand All @@ -33,7 +34,7 @@ export type DraftItemProps = {
type?: 'channel' | 'thread';
channel?: ChannelResponse;
/** Unix nanoseconds, as the API sends it. */
date?: number;
date?: TimestampNS;
message: DraftMessage;
// TODO: Fix the type for thread
thread?: MessageResponse;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type {
MessageResponse,
ReactionGroupResponse,
ReactionResponse,
TimestampNS,
UserResponse,
} from 'stream-chat';

Expand Down Expand Up @@ -210,7 +211,7 @@ export type WebSocketEventBase = {
channel_id: string;
channel_type: string;
cid: string;
created_at: number;
created_at: TimestampNS;
custom: CustomEventData;
user: UserResponse;
user_id: string;
Expand Down
2 changes: 1 addition & 1 deletion package/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@
"path": "0.12.7",
"react-native-markdown-package": "1.8.2",
"react-native-url-polyfill": "^2.0.0",
"stream-chat": "^10.0.0-rc.13",
"stream-chat": "^10.0.0-rc.14",
"use-sync-external-store": "^1.7.0"
},
"peerDependencies": {
Expand Down
19 changes: 11 additions & 8 deletions package/src/__tests__/offline-support/offline-feature.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,10 @@ import type {
MessageResponse,
ReactionResponse,
StreamChat,
TimestampNS,
UserResponse,
} from 'stream-chat';
import { dateToNs, nowNs, nsToMs } from 'stream-chat';
import { asTimestampNS, dateToNs, nowNs, nsToMs } from 'stream-chat';
import { v4 as uuidv4 } from 'uuid';

// Tests exercise internal APIs on StreamChat (private sync manager, legacy `wsConnection`).
Expand Down Expand Up @@ -138,7 +139,7 @@ export const Generic = () => {
type ReadWithCid = {
cid: string;
/** Unix nanoseconds, as the API sends it. */
last_read: number;
last_read: TimestampNS;
unread_messages: number;
user: ChannelMemberResponse['user'];
};
Expand Down Expand Up @@ -939,7 +940,9 @@ export const Generic = () => {
(a.created_at as number) - (b.created_at as number),
);
// truncate at the middle
const truncatedAt = messages[Number(messages.length / 2)].created_at as number | undefined;
const truncatedAt = messages[Number(messages.length / 2)].created_at as
| TimestampNS
| undefined;
act(() =>
dispatchChannelTruncatedEvent(chatClient, {
...channelToTruncate,
Expand Down Expand Up @@ -984,7 +987,7 @@ export const Generic = () => {

const channelResponse = channels[getRandomInt(0, channels.length - 1)];
const channelToTruncate = channelResponse.channel;
const truncatedAt = 0;
const truncatedAt = asTimestampNS(0);
act(() =>
dispatchChannelTruncatedEvent(chatClient, {
...channelToTruncate,
Expand Down Expand Up @@ -1025,7 +1028,7 @@ export const Generic = () => {
...messages.map((m: Partial<MessageResponse> | LocalMessage) => m.created_at as number),
);
// truncate at the middle
const truncatedAt = latestTimestamp + 1;
const truncatedAt = asTimestampNS(latestTimestamp + 1);
act(() =>
dispatchChannelTruncatedEvent(chatClient, {
...channelToTruncate,
Expand Down Expand Up @@ -1461,7 +1464,7 @@ export const Generic = () => {
type: 'wow',
user: reactionMember.user,
});
const newDate = new Date().toISOString();
const newDate = nowNs();
// the actual content of the reaction_groups does not matter, as we just want to know if it updates to it
// anything impossible given the scenarios is fine
const messageWithNewReaction = {
Expand Down Expand Up @@ -1520,7 +1523,7 @@ export const Generic = () => {
type: 'wow',
user: reactionMember.user,
});
const newDate = new Date().toISOString();
const newDate = nowNs();
const messageWithNewReaction = {
...targetMessage,
latest_reactions: [...(targetMessage.latest_reactions ?? []), newReaction],
Expand Down Expand Up @@ -1577,7 +1580,7 @@ export const Generic = () => {
type: 'wow',
user: reactionMember.user,
});
const newDate = new Date().toISOString();
const newDate = nowNs();
const messageWithNewReaction = {
...targetMessage,
latest_reactions: [...(targetMessage.latest_reactions ?? []), newReaction],
Expand Down
3 changes: 2 additions & 1 deletion package/src/__tests__/offline-support/optimistic-update.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type {
LocalUploadAttachment,
ReactionResponse,
StreamChat,
TimestampNS,
UserResponse,
} from 'stream-chat';
import { dateToNs, localMessageToNewMessagePayload, nowNs } from 'stream-chat';
Expand Down Expand Up @@ -138,7 +139,7 @@ export const OptimisticUpdates = () => {
const allReactions: ReactionResponse[] = [];
const allReads: Array<{
/** Unix nanoseconds, as the API sends it. */
last_read: number;
last_read: TimestampNS;
unread_messages: number;
user: ReturnType<typeof generateUser> | undefined;
}> = [];
Expand Down
4 changes: 1 addition & 3 deletions package/src/components/Channel/Channel.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { PropsWithChildren, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import React, { PropsWithChildren, useCallback, useEffect, useMemo, useState } from 'react';
import { StyleSheet, Text, View } from 'react-native';

import {
Expand Down Expand Up @@ -435,7 +435,6 @@ const ChannelWithContext = (props: PropsWithChildren<ChannelPropsWithContext>) =

const styles = useStyles();
const [deleted, setDeleted] = useState<boolean>(false);
const lastReadRef = useRef<Date | undefined>(undefined);
// The active thread is fully prop-driven: derive it synchronously during render so the reply
// data is present on the first frame (no setState round-trip / one-frame gap). Opening a thread
// is the integrator's job via `onThreadSelect` (they render a Channel with the `thread` prop).
Expand Down Expand Up @@ -520,7 +519,6 @@ const ChannelWithContext = (props: PropsWithChildren<ChannelPropsWithContext>) =

useEffect(() => {
const initChannel = async () => {
lastReadRef.current = new Date();
const unreadCount = channel.countUnread();
const shouldLoadAtFirstUnread = shouldLoadInitialChannelAtFirstUnreadMessage(unreadCount);
if (!channel || !shouldSyncChannel) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Channel } from 'stream-chat';
import type { Channel, ChannelMuteStatus } from 'stream-chat';

import { useStateStore } from '../../../hooks/useStateStore';

Expand All @@ -8,10 +8,9 @@ const defaultMuteStatus = {
muted: false,
};

const selector = (state: {
// Mirrors core's `ChannelMuteStatus`: both timestamps are unix nanoseconds.
muteStatus: { createdAt: number | null; expiresAt: number | null; muted: boolean };
}) => ({ muteStatus: state.muteStatus });
const selector = (state: { muteStatus: ChannelMuteStatus }) => ({
muteStatus: state.muteStatus,
});

/**
* Returns this channel's mute status, sourced reactively from `channel.state.muteStatus` (kept in
Expand Down
10 changes: 5 additions & 5 deletions package/src/i18n/__tests__/dateNormalization.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { convertTimestampToDate } from 'stream-chat';
import { asTimestampNS, convertTimestampToDate } from 'stream-chat';

import { Streami18n } from '../../utils/i18n/Streami18n';
import { getCalendarDateStringForA11y, getDateString } from '../utils';
Expand Down Expand Up @@ -35,7 +35,7 @@ describe('wire timestamps at the i18n boundary', () => {

it('renders the instant a converted wire timestamp represents', () => {
const instant = Date.UTC(2026, 7, 20, 12, 0, 0);
const nanoseconds = instant * 1e6;
const nanoseconds = asTimestampNS(instant * 1e6);

// `timestamp.MessageTimestamp` formats as `LT`, so this pins the actual instant rather than a
// relative word that depends on the clock.
Expand All @@ -45,7 +45,7 @@ describe('wire timestamps at the i18n boundary', () => {
});

it('converts the value measured on device', () => {
const nanoseconds = 1787870023772367000;
const nanoseconds = asTimestampNS(1787870023772367000);

expect(render(convertTimestampToDate(nanoseconds), 'timestamp.PollVote')).not.toMatch(
/Invalid Date/,
Expand All @@ -63,7 +63,7 @@ describe('wire timestamps at the i18n boundary', () => {
});

it('declines a value that cannot be converted at all', () => {
expect(convertTimestampToDate(Number.NaN)).toBeUndefined();
expect(convertTimestampToDate(asTimestampNS(Number.NaN))).toBeUndefined();
expect(convertTimestampToDate(undefined)).toBeUndefined();
expect(convertTimestampToDate(null)).toBeUndefined();
// And the output guard still catches an already-invalid Date, whatever produced it.
Expand All @@ -73,7 +73,7 @@ describe('wire timestamps at the i18n boundary', () => {
it('converts the accessibility date the same way', () => {
const instant = Date.UTC(2026, 7, 20, 12, 0, 0);
const spoken = getCalendarDateStringForA11y({
messageCreatedAt: convertTimestampToDate(instant * 1e6),
messageCreatedAt: convertTimestampToDate(asTimestampNS(instant * 1e6)),
tDateTimeParser,
});

Expand Down
7 changes: 4 additions & 3 deletions package/src/mock-builders/generator/time.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { dateToNs, msToNs, nowNs } from 'stream-chat';
import { asTimestampNS, dateToNs, msToNs, nowNs } from 'stream-chat';
import type { TimestampNS } from 'stream-chat';

/**
* Normalizes whatever a test hands a generator into the unix-**nanosecond** number the API puts on
Expand All @@ -10,9 +11,9 @@ import { dateToNs, msToNs, nowNs } from 'stream-chat';
*
* A bare `number` is taken to be nanoseconds already, matching the SDK's unit everywhere else.
*/
export const convertDateToTimestamp = (value?: Date | number | string): number => {
export const convertDateToTimestamp = (value?: Date | number | string): TimestampNS => {
if (value === undefined) return nowNs();
if (value instanceof Date) return dateToNs(value);
if (typeof value === 'number') return value;
if (typeof value === 'number') return asTimestampNS(value);
return msToNs(Date.parse(value));
};
3 changes: 2 additions & 1 deletion package/src/store/apis/deleteMessagesForChannel.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { nowNs } from 'stream-chat';
import type { TimestampNS } from 'stream-chat';

import { SqliteClient } from '../SqliteClient';

Expand All @@ -9,7 +10,7 @@ export const deleteMessagesForChannel = async ({
}: {
cid: string;
/** Unix nanoseconds, as the API sends it. */
truncated_at?: number;
truncated_at?: TimestampNS;
execute?: boolean;
}) => {
// `createdAt` holds unix nanoseconds, so the cutoff is one too and the comparison is numeric.
Expand Down
6 changes: 3 additions & 3 deletions package/src/store/mappers/mapStorableToChannel.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { ChannelStateResponseFields } from 'stream-chat';

import { mapStorableToTimestamp } from './mapStorableToTimestamp';
import { mapStorableToRequiredTimestamp, mapStorableToTimestamp } from './mapStorableToTimestamp';

import type { TableRow } from '../types';

Expand Down Expand Up @@ -44,7 +44,7 @@ export const mapStorableToChannel = (
cid,
config: config && JSON.parse(config),
cooldown,
created_at: mapStorableToTimestamp(createdAt) ?? 0,
created_at: mapStorableToRequiredTimestamp(createdAt),
created_by_id: createdById,
deleted_at: mapStorableToTimestamp(deletedAt),
disabled,
Expand All @@ -61,7 +61,7 @@ export const mapStorableToChannel = (
truncated_by: truncatedBy,
truncated_by_id: truncatedById,
type,
updated_at: mapStorableToTimestamp(updatedAt) ?? 0,
updated_at: mapStorableToRequiredTimestamp(updatedAt),
...(extraData ? JSON.parse(extraData) : {}),
},
};
Expand Down
4 changes: 2 additions & 2 deletions package/src/store/mappers/mapStorableToDraft.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { mapStorableToChannel } from './mapStorableToChannel';
import { mapStorableToDraftMessage } from './mapStorableToDraftMessage';

import { mapStorableToMessage } from './mapStorableToMessage';
import { mapStorableToTimestamp } from './mapStorableToTimestamp';
import { mapStorableToRequiredTimestamp } from './mapStorableToTimestamp';

import type { TableRow, TableRowJoinedDraftMessage, TableRowJoinedUser } from '../types';

Expand Down Expand Up @@ -34,7 +34,7 @@ export const mapStorableToDraft = ({
return {
channel: channel.channel,
channel_cid: cid,
created_at: mapStorableToTimestamp(createdAt) ?? 0,
created_at: mapStorableToRequiredTimestamp(createdAt),
message,
parent_id: parentId,
quoted_message: quotedMessage,
Expand Down
Loading
Loading