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
39 changes: 37 additions & 2 deletions ai-docs/ai-migration-v9-to-v10.md
Original file line number Diff line number Diff line change
Expand Up @@ -995,7 +995,42 @@ 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:

- **Dates are `Date` objects** (not ISO strings) on response types (`created_at`, `updated_at`, …).
- **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.

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.

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 }`.
- **`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`
guard turns `Invalid Date` into `null`) rather than being silently rescued.
- Presentational props are unchanged: `MessageFooter` / `MessageDeleted` still take
`date?: string | Date`, and `getDateSeparatorValue` still returns a `Date`. Convert where core
data enters the tree.
- **Outgoing request date fields are still `Date`** — filter bounds (`created_at_before`,
`created_at_around`, …), `remind_at` and `message_timestamp`. `JSON.stringify` emits RFC3339 for a
`Date`, which is the format the request spec declares. Use `nsToDate` when handing a server-sent
timestamp back to the API.
- **Type renames** — `EventTypes`→`EventType`, `Mute`→`UserMuteResponse`,
`PollOption`→`PollOptionResponseData`, `ReadResponse`→`ReadStateResponse`,
`AppSettingsAPIResponse`→`GetApplicationResponse`, `FormatMessageResponse`→`LocalMessage`,
Expand Down Expand Up @@ -1185,7 +1220,7 @@ The `MutedUsersState` type and the `channel.state.mutedUsersStore` handle are re
|---|---|---|
| `data` | `Channel['data']` | The server channel data (name/image/frozen/hidden/blocked/config/…). Republished on `channel.updated` / `channel.hidden` / `channel.visible` and on query/watch. |
| `membership` | `ChannelMemberResponse` | The current user's own membership (role, `pinned_at`, `archived_at`). |
| `muteStatus` | `{ muted: boolean; createdAt: Date \| null; expiresAt: Date \| null }` | Is **this channel** muted for the current user — mirrors `client.mutedChannels`. `channel.muteStatus()` (imperative) is unchanged. |
| `muteStatus` | `{ muted: boolean; createdAt: number \| null; expiresAt: number \| null }` | Is **this channel** muted for the current user — mirrors `client.mutedChannels`. `channel.muteStatus()` (imperative) is unchanged. |
| `initialized` / `offlineMode` / `pendingDisposal` | `boolean` | Lifecycle flags, now store-backed. `channel.initialized` etc. are transparent getters over these. `pendingDisposal` replaces `disconnected` — see §K.8. |
| `watchStatus` | `ChannelWatchStatus` — `'watching'` \| `'wasWatching'` \| `'notWatching'` | Whether this client holds a server-side watch (i.e. whether channel events are flowing) and, when it doesn't, whether the watch should be restored. `Watching` once a `watch: true` query succeeds; `WasWatching` when the WS connection drops (the server keys watches by connection ID, so a reconnect issues a new id and every watch is gone — this records that a re-query is wanted); `NotWatching` when never watched, when the consumer called `stopWatching()`, or on teardown — a deliberate stop is never resurrected by a reconnect. Also makes `channel.watch()`'s silent downgrade — it drops to a non-watching query when there is no connection ID — observable. Read via `channel.watchStatus` or `useStateStore(channel.state, (s) => ({ watchStatus: s.watchStatus }))`; the `ChannelWatchStatus` const is exported from `stream-chat`. |

Expand Down
8 changes: 8 additions & 0 deletions ai-docs/i18n-v10-migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,14 @@ where a plural is the bare `<key>`; a dictionary needs the `_one` / `_other` ent

## 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. This SDK's `getDateString` wrapper
> suppresses the resulting `Invalid Date`, so a missed conversion renders a **blank** timestamp rather
> than an error. The magnitude-based rescue that used to cover this (`normalizeTimestamp` in
> `src/i18n/utils.ts`) is gone. If a timestamp is blank, check the conversion first; see
> [Part I](./ai-migration-v9-to-v10.md) on server-sent dates.

Two steps, and the second is the one that gets missed.

### Step 1 — the dayjs locale
Expand Down
50 changes: 37 additions & 13 deletions examples/ExpoMessaging/app/map/[id].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,38 @@ import MapView, { MapMarker, Marker } from 'react-native-maps';
import { SafeAreaView } from 'react-native-safe-area-context';

import { Stack, useLocalSearchParams } from 'expo-router';
import { Channel, SharedLocationResponse, StreamChat } from 'stream-chat';
import {
Channel,
convertTimestampToDate,
nowNs,
SharedLocationResponse,
StreamChat,
} from 'stream-chat';
import { useChatContext, useHandleLiveLocationEvents, useTheme } from 'stream-chat-expo';

import { AppContext } from '../../context/AppContext';

import type { AppTheme } from '@/types/theme';

export type SharedLiveLocationParamsStringType = SharedLocationResponse & {
/**
* Route params, which expo-router delivers as **strings** — the pressing screen stringifies every
* field of the shared location into the URL.
*
* Declared independently of `SharedLocationResponse` rather than intersected with it. Since v10 that
* type's date fields are unix-nanosecond `number`s, which both violate `useLocalSearchParams`' string
* constraint and misdescribe what actually arrives — `end_at` reaching `convertTimestampToDate` as a
* string made `Number.isFinite` false, so the "ended at" label silently rendered empty.
*/
export type SharedLiveLocationParamsStringType = {
channel_cid: string;
created_at: string;
created_by_device_id: string;
end_at?: string;
latitude: string;
longitude: string;
message_id: string;
updated_at: string;
user_id: string;
};

const MapScreenFooter = ({
Expand All @@ -32,7 +54,7 @@ const MapScreenFooter = ({
isLiveLocationStopped,
}: {
client: StreamChat;
shared_location: SharedLocationResponse;
shared_location: SharedLiveLocationParamsStringType;
locationResponse?: SharedLocationResponse;
isLiveLocationStopped?: boolean;
}) => {
Expand All @@ -43,22 +65,24 @@ const MapScreenFooter = ({
colors: { accent_blue, accent_red, grey },
},
} = useTheme() as unknown as { theme: AppTheme };
const endedAtDate = end_at ? new Date(end_at) : null;
const liveLocationActive = isLiveLocationStopped
? false
: endedAtDate
? endedAtDate > new Date()
: false;
const formattedEndedAt = endedAtDate ? endedAtDate.toLocaleString() : '';
// `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 liveLocationActive =
!isLiveLocationStopped && endAt !== undefined && Number.isFinite(endAt) && endAt > nowNs();
const formattedEndedAt = convertTimestampToDate(endAt)?.toLocaleString() ?? '';

const stopSharingLiveLocation = useCallback(async () => {
if (!channel || !locationResponse) {
return;
}
await channel.stopLiveLocationSharing(locationResponse);
// The request shape, not the response: `stopLiveLocationSharing` stamps `end_at` itself, and
// the response's `end_at` is a wire number the request field cannot take.
await channel.stopLiveLocationSharing({ message_id: locationResponse.message_id });
}, [channel, locationResponse]);

if (!end_at) {
if (end_at == null) {
return null;
}

Expand Down Expand Up @@ -183,7 +207,7 @@ export default function MapScreen() {
ref={mapRef}
style={styles.mapView}
>
{shared_location.end_at ? (
{shared_location.end_at != null ? (
<Marker
coordinate={
!locationResponse
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {

import MapView, { MapMarker, Marker } from 'react-native-maps';

import { SharedLocationResponse, StreamChat } from 'stream-chat';
import { convertTimestampToDate, nowNs, SharedLocationResponseData, StreamChat } from 'stream-chat';
import {
MessageLocationProps,
useChannelContext,
Expand All @@ -26,7 +26,7 @@ const MessageLocationFooter = ({
shared_location,
}: {
client: StreamChat;
shared_location: SharedLocationResponse;
shared_location: SharedLocationResponseData;
}) => {
const { channel } = useChannelContext();
const { end_at, user_id } = shared_location;
Expand All @@ -35,15 +35,18 @@ const MessageLocationFooter = ({
colors: { grey },
},
} = useTheme() as unknown as { theme: AppTheme };
const endedAtDate = end_at ? new Date(end_at) : null;
const liveLocationActive = endedAtDate ? endedAtDate > new Date() : false;
const formattedEndedAt = endedAtDate ? endedAtDate.toLocaleString() : '';
// `end_at` is a unix-**nanosecond** wire timestamp: `new Date(ns)` is out of range, so the
// comparison is done in the wire unit and only the displayed value becomes a `Date`.
const liveLocationActive = end_at != null && end_at > nowNs();
const formattedEndedAt = convertTimestampToDate(end_at)?.toLocaleString() ?? '';

const stopSharingLiveLocation = useCallback(async () => {
await channel.stopLiveLocationSharing(shared_location);
// The request shape, not the response: `stopLiveLocationSharing` stamps `end_at` itself, and
// the response's `end_at` is a wire number the request field cannot take.
await channel.stopLiveLocationSharing({ message_id: shared_location.message_id });
}, [channel, shared_location]);

if (!end_at) {
if (end_at == null) {
return null;
}
const isCurrentUser = user_id === client.user?.id;
Expand Down Expand Up @@ -132,7 +135,7 @@ export const MessageLocation = ({ message }: MessageLocationProps) => {
ref={mapRef}
style={styles.mapView}
>
{shared_location.end_at ? (
{shared_location.end_at != null ? (
<Marker coordinate={{ latitude, longitude }} ref={markerRef}>
<View style={styles.markerWrapper}>
<Image
Expand Down
8 changes: 4 additions & 4 deletions examples/ExpoMessaging/components/UserLogin.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import React, { useCallback } from 'react';
import { View, Text, FlatList, Image, StyleSheet, Pressable } from 'react-native';

import { UserResponse } from 'stream-chat';
import { ClientUser } from 'stream-chat';

import { USERS } from '../constants/ChatUsers';
import { useUserContext } from '../context/UserContext';

const PredefinedUserItem = ({ item }: { item: UserResponse }) => {
const PredefinedUserItem = ({ item }: { item: ClientUser }) => {
const { logIn } = useUserContext();
const handleUserSelect = useCallback(() => {
logIn(item);
Expand All @@ -21,9 +21,9 @@ const PredefinedUserItem = ({ item }: { item: UserResponse }) => {
);
};

const renderItem = ({ item }: { item: UserResponse }) => <PredefinedUserItem item={item} />;
const renderItem = ({ item }: { item: ClientUser }) => <PredefinedUserItem item={item} />;

const keyExtractor = (item: UserResponse) => item.id;
const keyExtractor = (item: ClientUser) => item.id;

const Separator = () => <View style={styles.separator} />;

Expand Down
10 changes: 8 additions & 2 deletions examples/ExpoMessaging/constants/ChatUsers.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { UserResponse } from 'stream-chat';
import { ClientUser } from 'stream-chat';

export const STREAM_API_KEY = 'yjrt5yxw77ev';

Expand All @@ -18,7 +18,13 @@ export const USER_TOKENS: Record<string, string> = {
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoicm9kb2xwaGUifQ.tLl-I8ADBhTKB-x5FB9jK4-am0dELLXgydM6VN9rTL8',
};

export const USERS: Record<string, UserResponse> = {
/**
* Login fixtures, not server responses: these are the payloads handed to `connectUser`, so they
* carry only `id` / `name` / `image`. Annotated `ClientUser` (everything optional but `id`) rather
* than `UserResponse`, which since v10 requires the server-owned fields — `created_at`,
* `updated_at`, `banned`, `language`, `online`, … — that a client has no business inventing.
*/
export const USERS: Record<string, ClientUser> = {
neil: {
id: 'neil',
image: 'https://ca.slack-edge.com/T02RM6X6B-U01173D1D5J-0dead6eea6ea-512',
Expand Down
10 changes: 5 additions & 5 deletions examples/ExpoMessaging/context/UserContext.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { createContext, PropsWithChildren, useContext, useEffect, useState } from 'react';

import AsyncStorage from '@react-native-async-storage/async-storage';
import { UserResponse } from 'stream-chat';
import { ClientUser } from 'stream-chat';

export type UserContextValue = {
user: UserResponse | null;
logIn: (user: UserResponse) => Promise<void>;
user: ClientUser | null;
logIn: (user: ClientUser) => Promise<void>;
logOut: () => Promise<void>;
};

Expand All @@ -16,7 +16,7 @@ export const UserContext = createContext<UserContextValue>({
});

export const UserProvider = ({ children }: PropsWithChildren) => {
const [user, setUser] = useState<UserResponse | null>(null);
const [user, setUser] = useState<ClientUser | null>(null);

useEffect(() => {
const fetchUser = async () => {
Expand All @@ -26,7 +26,7 @@ export const UserProvider = ({ children }: PropsWithChildren) => {
fetchUser();
}, []);

const logIn = async (user: UserResponse) => {
const logIn = async (user: ClientUser) => {
await AsyncStorage.setItem('@stream-io/expo-messaging-user', JSON.stringify(user));
setUser(user);
};
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.8",
"stream-chat": "^10.0.0-rc.9",
"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 @@ -64,7 +64,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.8",
"stream-chat": "^10.0.0-rc.9",
"stream-chat-react-native": "workspace:^",
"stream-chat-react-native-core": "workspace:^"
},
Expand Down
10 changes: 8 additions & 2 deletions examples/SampleApp/src/ChatUsers.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { UserResponse } from 'stream-chat';
import { ClientUser } from 'stream-chat';

export const USER_TOKENS: Record<string, string> = {
e2etest1:
Expand Down Expand Up @@ -26,7 +26,13 @@ export const USER_TOKENS: Record<string, string> = {
rodolphe:
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoicm9kb2xwaGUifQ.tLl-I8ADBhTKB-x5FB9jK4-am0dELLXgydM6VN9rTL8',
};
export const USERS: Record<string, UserResponse> = {
/**
* Login fixtures, not server responses: these are the payloads handed to `connectUser`, so they
* carry only `id` / `name` / `image`. Annotated `ClientUser` (everything optional but `id`) rather
* than `UserResponse`, which since v10 requires the server-owned fields — `created_at`,
* `updated_at`, `banned`, `language`, `online`, … — that a client has no business inventing.
*/
export const USERS: Record<string, ClientUser> = {
neil: {
id: 'neil',
image: 'https://ca.slack-edge.com/T02RM6X6B-U01173D1D5J-0dead6eea6ea-512',
Expand Down
8 changes: 6 additions & 2 deletions examples/SampleApp/src/components/DraftsList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import {
ChannelResponse,
convertTimestampToDate,
DraftMessage,
DraftResponse,
LocalMessage,
Expand All @@ -31,7 +32,8 @@ dayjs.extend(relativeTime);
export type DraftItemProps = {
type?: 'channel' | 'thread';
channel?: ChannelResponse;
date?: string;
/** Unix nanoseconds, as the API sends it. */
date?: number;
message: DraftMessage;
// TODO: Fix the type for thread
thread?: MessageResponseBase;
Expand Down Expand Up @@ -75,7 +77,9 @@ export const DraftItem = ({ type, channel, date, message, thread }: DraftItemPro
<Text style={styles.name}>
{type === 'channel' ? `# ${channelName}` : `Thread in # ${channelName}`}
</Text>
<Text style={[styles.date, { color: grey }]}>{dayjs(date).fromNow()}</Text>
<Text style={[styles.date, { color: grey }]}>
{date === undefined ? '' : dayjs(convertTimestampToDate(date)).fromNow()}
</Text>
</View>
<View style={styles.content}>
<View style={styles.icon}>
Expand Down
Loading
Loading