diff --git a/ai-docs/ai-migration-v9-to-v10.md b/ai-docs/ai-migration-v9-to-v10.md index 6f400cbede..f410a229f2 100644 --- a/ai-docs/ai-migration-v9-to-v10.md +++ b/ai-docs/ai-migration-v9-to-v10.md @@ -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`, @@ -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`. | diff --git a/ai-docs/i18n-v10-migration.md b/ai-docs/i18n-v10-migration.md index 4a0d5ce6d8..0c40bbbbde 100644 --- a/ai-docs/i18n-v10-migration.md +++ b/ai-docs/i18n-v10-migration.md @@ -181,6 +181,14 @@ where a plural is the bare ``; 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 diff --git a/examples/ExpoMessaging/app/map/[id].tsx b/examples/ExpoMessaging/app/map/[id].tsx index 59083e80de..3add7168f2 100644 --- a/examples/ExpoMessaging/app/map/[id].tsx +++ b/examples/ExpoMessaging/app/map/[id].tsx @@ -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 = ({ @@ -32,7 +54,7 @@ const MapScreenFooter = ({ isLiveLocationStopped, }: { client: StreamChat; - shared_location: SharedLocationResponse; + shared_location: SharedLiveLocationParamsStringType; locationResponse?: SharedLocationResponse; isLiveLocationStopped?: boolean; }) => { @@ -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; } @@ -183,7 +207,7 @@ export default function MapScreen() { ref={mapRef} style={styles.mapView} > - {shared_location.end_at ? ( + {shared_location.end_at != null ? ( { const { channel } = useChannelContext(); const { end_at, user_id } = shared_location; @@ -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; @@ -132,7 +135,7 @@ export const MessageLocation = ({ message }: MessageLocationProps) => { ref={mapRef} style={styles.mapView} > - {shared_location.end_at ? ( + {shared_location.end_at != null ? ( { +const PredefinedUserItem = ({ item }: { item: ClientUser }) => { const { logIn } = useUserContext(); const handleUserSelect = useCallback(() => { logIn(item); @@ -21,9 +21,9 @@ const PredefinedUserItem = ({ item }: { item: UserResponse }) => { ); }; -const renderItem = ({ item }: { item: UserResponse }) => ; +const renderItem = ({ item }: { item: ClientUser }) => ; -const keyExtractor = (item: UserResponse) => item.id; +const keyExtractor = (item: ClientUser) => item.id; const Separator = () => ; diff --git a/examples/ExpoMessaging/constants/ChatUsers.ts b/examples/ExpoMessaging/constants/ChatUsers.ts index 476fa248d5..97f4727e02 100644 --- a/examples/ExpoMessaging/constants/ChatUsers.ts +++ b/examples/ExpoMessaging/constants/ChatUsers.ts @@ -1,4 +1,4 @@ -import { UserResponse } from 'stream-chat'; +import { ClientUser } from 'stream-chat'; export const STREAM_API_KEY = 'yjrt5yxw77ev'; @@ -18,7 +18,13 @@ export const USER_TOKENS: Record = { 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoicm9kb2xwaGUifQ.tLl-I8ADBhTKB-x5FB9jK4-am0dELLXgydM6VN9rTL8', }; -export const USERS: Record = { +/** + * 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 = { neil: { id: 'neil', image: 'https://ca.slack-edge.com/T02RM6X6B-U01173D1D5J-0dead6eea6ea-512', diff --git a/examples/ExpoMessaging/context/UserContext.tsx b/examples/ExpoMessaging/context/UserContext.tsx index fa4160f0d1..69e390d2e9 100644 --- a/examples/ExpoMessaging/context/UserContext.tsx +++ b/examples/ExpoMessaging/context/UserContext.tsx @@ -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; + user: ClientUser | null; + logIn: (user: ClientUser) => Promise; logOut: () => Promise; }; @@ -16,7 +16,7 @@ export const UserContext = createContext({ }); export const UserProvider = ({ children }: PropsWithChildren) => { - const [user, setUser] = useState(null); + const [user, setUser] = useState(null); useEffect(() => { const fetchUser = async () => { @@ -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); }; diff --git a/examples/ExpoMessaging/package.json b/examples/ExpoMessaging/package.json index d27b067e06..67969eaba7 100644 --- a/examples/ExpoMessaging/package.json +++ b/examples/ExpoMessaging/package.json @@ -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:^" }, diff --git a/examples/SampleApp/package.json b/examples/SampleApp/package.json index d18363ec4e..754cf639f5 100644 --- a/examples/SampleApp/package.json +++ b/examples/SampleApp/package.json @@ -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:^" }, diff --git a/examples/SampleApp/src/ChatUsers.ts b/examples/SampleApp/src/ChatUsers.ts index dec7d186c1..6e76b6d673 100644 --- a/examples/SampleApp/src/ChatUsers.ts +++ b/examples/SampleApp/src/ChatUsers.ts @@ -1,4 +1,4 @@ -import { UserResponse } from 'stream-chat'; +import { ClientUser } from 'stream-chat'; export const USER_TOKENS: Record = { e2etest1: @@ -26,7 +26,13 @@ export const USER_TOKENS: Record = { rodolphe: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoicm9kb2xwaGUifQ.tLl-I8ADBhTKB-x5FB9jK4-am0dELLXgydM6VN9rTL8', }; -export const USERS: Record = { +/** + * 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 = { neil: { id: 'neil', image: 'https://ca.slack-edge.com/T02RM6X6B-U01173D1D5J-0dead6eea6ea-512', diff --git a/examples/SampleApp/src/components/DraftsList.tsx b/examples/SampleApp/src/components/DraftsList.tsx index f228f411ee..47af3d74b7 100644 --- a/examples/SampleApp/src/components/DraftsList.tsx +++ b/examples/SampleApp/src/components/DraftsList.tsx @@ -8,6 +8,7 @@ import dayjs from 'dayjs'; import relativeTime from 'dayjs/plugin/relativeTime'; import { ChannelResponse, + convertTimestampToDate, DraftMessage, DraftResponse, LocalMessage, @@ -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; @@ -75,7 +77,9 @@ export const DraftItem = ({ type, channel, date, message, thread }: DraftItemPro {type === 'channel' ? `# ${channelName}` : `Thread in # ${channelName}`} - {dayjs(date).fromNow()} + + {date === undefined ? '' : dayjs(convertTimestampToDate(date)).fromNow()} + diff --git a/examples/SampleApp/src/components/LocationSharing/MessageLocation.tsx b/examples/SampleApp/src/components/LocationSharing/MessageLocation.tsx index a0ee374870..f77327c960 100644 --- a/examples/SampleApp/src/components/LocationSharing/MessageLocation.tsx +++ b/examples/SampleApp/src/components/LocationSharing/MessageLocation.tsx @@ -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, @@ -26,21 +26,24 @@ const MessageLocationFooter = ({ shared_location, }: { client: StreamChat; - shared_location: SharedLocationResponse; + shared_location: SharedLocationResponseData; }) => { const { channel } = useChannelContext(); const { end_at, user_id } = shared_location; useTheme(); const { grey } = useLegacyColors(); - const liveLocationActive = end_at && new Date(end_at) > new Date(); - const endedAtDate = end_at ? new Date(end_at) : null; - 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; @@ -71,7 +74,7 @@ const MessageLocationFooter = ({ const MessageLocationComponent = ({ shared_location, }: { - shared_location: SharedLocationResponse; + shared_location: SharedLocationResponseData; }) => { const { client } = useChatContext(); const { end_at, latitude, longitude } = shared_location || {}; @@ -126,7 +129,7 @@ const MessageLocationComponent = ({ ref={mapRef} style={styles.mapView} > - {end_at ? ( + {end_at != null ? ( - {dayjs(item.created_at).calendar(undefined, { + {dayjs(convertTimestampToDate(item.created_at)).calendar(undefined, { lastDay: 'DD/MM', // The day before ( Yesterday at 2:30 AM ) lastWeek: 'DD/MM', // Last week ( Last Monday at 2:30 AM ) sameDay: 'h:mm A', // The same day ( Today at 2:30 AM ) diff --git a/examples/SampleApp/src/components/Reminders/ReminderBanner.tsx b/examples/SampleApp/src/components/Reminders/ReminderBanner.tsx index 4fa117917a..d069063e25 100644 --- a/examples/SampleApp/src/components/Reminders/ReminderBanner.tsx +++ b/examples/SampleApp/src/components/Reminders/ReminderBanner.tsx @@ -1,6 +1,6 @@ import { StyleSheet, Text, View } from 'react-native'; -import { ReminderResponse, ReminderState } from 'stream-chat'; +import { nsToDate, nsToMs, ReminderResponseData, ReminderState } from 'stream-chat'; import { useMessageReminder, useTheme, @@ -14,7 +14,7 @@ const reminderStateSelector = (state: ReminderState) => ({ timeLeftMs: state.timeLeftMs, }); -export const ReminderBanner = (item: ReminderResponse) => { +export const ReminderBanner = (item: ReminderResponseData) => { useTheme(); const { accent_blue, accent_red } = useLegacyColors(); const { t } = useTranslationContext(); @@ -22,17 +22,19 @@ export const ReminderBanner = (item: ReminderResponse) => { const reminder = useMessageReminder(message_id); const { timeLeftMs } = useStateStore(reminder?.state, reminderStateSelector) ?? {}; const stopRefreshBoundaryMs = reminder?.timer.stopRefreshBoundaryMs; + // `remindAt` is a unix-**nanosecond** wire timestamp while `stopRefreshBoundaryMs` is a + // millisecond duration, so the timestamp is converted before the two are added. 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; - if (!reminder?.remindAt) { + if (reminder?.remindAt == null) { return 🔖; - } else if (reminder.remindAt && timeLeftMs) { + } else if (timeLeftMs != null) { return ( { {isBehindRefreshBoundary ? t('Due since {{ dueSince }}', { dueSince: t('timestamp/ReminderNotification', { - timestamp: reminder.remindAt, + timestamp: nsToDate(reminder.remindAt), }), }) : t('Due {{ timeLeft }}', { diff --git a/examples/SampleApp/src/components/Reminders/ReminderItem.tsx b/examples/SampleApp/src/components/Reminders/ReminderItem.tsx index 9211ed6378..1507b6ec83 100644 --- a/examples/SampleApp/src/components/Reminders/ReminderItem.tsx +++ b/examples/SampleApp/src/components/Reminders/ReminderItem.tsx @@ -5,7 +5,7 @@ import { Alert, AlertButton, Pressable, StyleSheet, Text, View } from 'react-nat import { NavigationProp, useNavigation } from '@react-navigation/native'; import Swipeable from 'react-native-gesture-handler/ReanimatedSwipeable'; -import { ReminderResponse } from 'stream-chat'; +import { ReminderResponseData } from 'stream-chat'; import { Delete, useMessagePreviewText, @@ -21,7 +21,7 @@ import { useLegacyColors } from '../../theme/useLegacyColors'; import type { StackNavigatorParamList } from '../../types'; export const ReminderItem = ( - item: ReminderResponse & { onDeleteHandler?: (id: string) => void }, + item: ReminderResponseData & { onDeleteHandler?: (id: string) => void }, ) => { const { channel, message } = item; const navigation = useNavigation>(); @@ -70,7 +70,7 @@ export const ReminderItem = ( onPress: async () => { await client.reminders.upsertReminder({ messageId: item.message_id, - remind_at: new Date(new Date().getTime() + offsetMs).toISOString(), + remind_at: new Date(Date.now() + offsetMs), }); }, style: 'default', @@ -81,7 +81,7 @@ export const ReminderItem = ( onPress: async () => { await client.reminders.upsertReminder({ messageId: item.message_id, - remind_at: null, + remind_at: undefined, }); }, style: 'default', diff --git a/examples/SampleApp/src/components/Reminders/RemindersList.tsx b/examples/SampleApp/src/components/Reminders/RemindersList.tsx index 602d4b05ee..85233bc20a 100644 --- a/examples/SampleApp/src/components/Reminders/RemindersList.tsx +++ b/examples/SampleApp/src/components/Reminders/RemindersList.tsx @@ -9,7 +9,7 @@ import { View, } from 'react-native'; -import { ReminderResponse } from 'stream-chat'; +import { ReminderResponseData } from 'stream-chat'; import { useChatContext, useTheme, useQueryReminders } from 'stream-chat-react-native'; import { ReminderItem } from './ReminderItem'; @@ -29,7 +29,7 @@ type TabItemType = { title: string; }; -const renderItem = ({ item }: { item: ReminderResponse }) => ; +const renderItem = ({ item }: { item: ReminderResponseData }) => ; export const RemindersList = () => { const [selectedTab, setSelectedTab] = useState(tabs[0]); @@ -41,8 +41,12 @@ export const RemindersList = () => { useEffect(() => { client.reminders.paginator.filters = {}; - client.reminders.paginator.sort = { remind_at: 1 }; - }, [client.reminders.paginator]); + // v10 sort shape: `SortParamRequest[]`, not the v9 `{ field: direction }` object. + client.reminders.paginator.sort = [{ direction: 1, field: 'remind_at' }]; + // The paginator does not self-load, so without this the list stays empty until a filter chip + // is tapped — `onChangeTab` was the only caller of `queryNextReminders`. + client.reminders.queryNextReminders(); + }, [client.reminders, client.reminders.paginator]); const onChangeTab = useCallback( async (tab: TabItemType) => { @@ -51,11 +55,11 @@ export const RemindersList = () => { client.reminders.paginator.filters = {}; } else if (tab.key === 'overdue') { client.reminders.paginator.filters = { - remind_at: { $lte: new Date().toISOString() }, + remind_at: { $lte: new Date() }, }; } else if (tab.key === 'upcoming') { client.reminders.paginator.filters = { - remind_at: { $gt: new Date().toISOString() }, + remind_at: { $gt: new Date() }, }; } else if (tab.key === 'scheduled') { client.reminders.paginator.filters = { diff --git a/examples/SampleApp/src/components/UserSearch/UserSearchResults.tsx b/examples/SampleApp/src/components/UserSearch/UserSearchResults.tsx index 3e1c20bc4b..1bfcfd7784 100644 --- a/examples/SampleApp/src/components/UserSearch/UserSearchResults.tsx +++ b/examples/SampleApp/src/components/UserSearch/UserSearchResults.tsx @@ -12,6 +12,7 @@ import Svg, { Defs, LinearGradient, Rect, Stop } from 'react-native-svg'; import dayjs from 'dayjs'; import calendar from 'dayjs/plugin/calendar'; +import { convertTimestampToDate } from 'stream-chat'; import type { UserResponse } from 'stream-chat'; import { useTheme, useViewport, UserAvatar } from 'stream-chat-react-native'; @@ -218,7 +219,7 @@ export const UserSearchResults: React.FC = ({ > {item.name} - {showOnlineStatus && ( + {showOnlineStatus && item.last_active != null && ( = ({ }, ]} > - Last online {dayjs(item.last_active).calendar()} + Last online {dayjs(convertTimestampToDate(item.last_active)).calendar()} )} diff --git a/examples/SampleApp/src/components/WebSocketEventPromptDialog/websocketEventAutomation.ts b/examples/SampleApp/src/components/WebSocketEventPromptDialog/websocketEventAutomation.ts index 4c80335e52..050a094583 100644 --- a/examples/SampleApp/src/components/WebSocketEventPromptDialog/websocketEventAutomation.ts +++ b/examples/SampleApp/src/components/WebSocketEventPromptDialog/websocketEventAutomation.ts @@ -1,3 +1,4 @@ +import { nowNs } from 'stream-chat'; import type { Channel, Event, @@ -211,7 +212,7 @@ const buildFreshMessageUpdatePayload = ({ user: context.currentUser, }); const targetMessage = getKnownMessage({ context, state }) ?? fallbackMessage; - const timestamp = new Date().toISOString(); + const timestamp = nowNs(); const message = eventType === 'message.deleted' ? ({ diff --git a/examples/SampleApp/src/components/WebSocketEventPromptDialog/websocketEventTemplates.ts b/examples/SampleApp/src/components/WebSocketEventPromptDialog/websocketEventTemplates.ts index 6fb98e1af7..aa481b1c1b 100644 --- a/examples/SampleApp/src/components/WebSocketEventPromptDialog/websocketEventTemplates.ts +++ b/examples/SampleApp/src/components/WebSocketEventPromptDialog/websocketEventTemplates.ts @@ -1,3 +1,4 @@ +import { nowNs } from 'stream-chat'; import type { Channel, ChannelResponse, @@ -42,8 +43,6 @@ type MessagePaginatorLike = { }; }; -const nowIso = () => new Date().toISOString(); - const normalizeUser = (user: Partial | null | undefined, fallbackId: string) => { const id = user?.id || fallbackId; @@ -53,7 +52,7 @@ const normalizeUser = (user: Partial | null | undefined, fallbackI name: user?.name || id, online: user?.online ?? true, role: user?.role || 'user', - updated_at: user?.updated_at || nowIso(), + updated_at: user?.updated_at ?? nowNs(), } as UserResponse; }; @@ -153,14 +152,8 @@ export const toMessageResponse = ( message: LocalMessage | MessageResponse, context: WebSocketEventTemplateContext, ) => { - const createdAt = - message.created_at instanceof Date - ? message.created_at.toISOString() - : message.created_at || nowIso(); - const updatedAt = - message.updated_at instanceof Date - ? message.updated_at.toISOString() - : message.updated_at || createdAt; + const createdAt = message.created_at ?? nowNs(); + const updatedAt = message.updated_at ?? createdAt; const user = message.user ? normalizeUser(message.user, message.user.id) : context.currentUser; return { @@ -197,7 +190,7 @@ const buildBasePayload = ( channel_id: context.channelData.id, channel_type: context.channelData.type, cid: context.cid, - created_at: nowIso(), + created_at: nowNs(), type: eventType, user, user_id: user.id, @@ -216,7 +209,7 @@ export const buildMessage = ({ type?: MessageResponse['type']; user: UserResponse; }) => { - const timestamp = nowIso(); + const timestamp = nowNs(); return { cid: context.cid, @@ -250,7 +243,7 @@ export const buildReaction = ({ reactionUserShape: ReactionUserShape; user: UserResponse; }) => { - const timestamp = nowIso(); + const timestamp = nowNs(); return { created_at: timestamp, @@ -279,7 +272,7 @@ export const buildMessageWithReaction = ({ removed?: boolean; }) => { const reactionUserId = getReactionUserId(reaction); - const timestamp = nowIso(); + const timestamp = nowNs(); const sameReaction = (candidate: ReactionResponse) => candidate.type === reaction.type && getReactionUserId(candidate) === reactionUserId; const baseLatestReactions = message.latest_reactions ?? []; @@ -309,7 +302,7 @@ export const buildMessageWithReaction = ({ ...currentGroups, [reaction.type]: { count: nextCount, - first_reaction_at: currentGroup?.first_reaction_at || timestamp, + first_reaction_at: currentGroup?.first_reaction_at ?? timestamp, last_reaction_at: timestamp, sum_scores: nextCount, }, @@ -430,7 +423,7 @@ export const buildDefaultWebSocketEventPayload = ({ message: { ...latestMessage, text: `${latestMessage.text || 'Synthetic chat traffic'} (updated)`, - updated_at: nowIso(), + updated_at: nowNs(), } as MessageResponse, message_id: latestMessage.id, }; @@ -440,10 +433,10 @@ export const buildDefaultWebSocketEventPayload = ({ ...basePayload, message: { ...latestMessage, - deleted_at: nowIso(), + deleted_at: nowNs(), text: '', type: 'deleted', - updated_at: nowIso(), + updated_at: nowNs(), } as MessageResponse, message_id: latestMessage.id, }; diff --git a/examples/SampleApp/src/screens/MapScreen.tsx b/examples/SampleApp/src/screens/MapScreen.tsx index 098a18be08..7310b14fd8 100644 --- a/examples/SampleApp/src/screens/MapScreen.tsx +++ b/examples/SampleApp/src/screens/MapScreen.tsx @@ -14,7 +14,7 @@ import { SafeAreaView } from 'react-native-safe-area-context'; import { RouteProp } from '@react-navigation/native'; import { NativeStackNavigationProp } from '@react-navigation/native-stack'; -import { SharedLocationResponse, StreamChat } from 'stream-chat'; +import { convertTimestampToDate, nowNs, SharedLocationResponse, StreamChat } from 'stream-chat'; import { useChatContext, useHandleLiveLocationEvents, useTheme } from 'stream-chat-react-native'; import { useStreamChatContext } from '../context/StreamChatContext'; @@ -51,20 +51,21 @@ const MapScreenFooter = ({ const { end_at, user_id } = shared_location; useTheme(); const { accent_blue, accent_red, grey } = useLegacyColors(); - const liveLocationActive = isLiveLocationStopped - ? false - : end_at && new Date(end_at) > new Date(); - const endedAtDate = end_at ? new Date(end_at) : null; - 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 = !isLiveLocationStopped && end_at != null && end_at > nowNs(); + const formattedEndedAt = convertTimestampToDate(end_at)?.toLocaleString() ?? ''; const stopSharingLiveLocation = useCallback(async () => { if (!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; } @@ -185,7 +186,7 @@ export const MapScreen = ({ route }: MapScreenProps) => { ref={mapRef} style={styles.mapView} > - {shared_location.end_at ? ( + {shared_location.end_at != null ? ( { // console.error('Error creating reminder:', _error); diff --git a/package/package.json b/package/package.json index 8fb79908d5..1b11aff44c 100644 --- a/package/package.json +++ b/package/package.json @@ -78,7 +78,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.8", + "stream-chat": "^10.0.0-rc.9", "use-sync-external-store": "^1.5.0" }, "peerDependencies": { diff --git a/package/src/__tests__/offline-support/offline-feature.tsx b/package/src/__tests__/offline-support/offline-feature.tsx index 14f364f1b6..dda9cd485e 100644 --- a/package/src/__tests__/offline-support/offline-feature.tsx +++ b/package/src/__tests__/offline-support/offline-feature.tsx @@ -17,6 +17,7 @@ import type { StreamChat, UserResponse, } from 'stream-chat'; +import { dateToNs, nowNs, nsToMs } from 'stream-chat'; import { v4 as uuidv4 } from 'uuid'; // Tests exercise internal APIs on StreamChat (private sync manager, legacy `wsConnection`). @@ -136,7 +137,8 @@ export const Generic = () => { type MemberWithCid = ChannelMemberResponse & { cid: string }; type ReadWithCid = { cid: string; - last_read: Date; + /** Unix nanoseconds, as the API sends it. */ + last_read: number; unread_messages: number; user: ChannelMemberResponse['user']; }; @@ -203,7 +205,9 @@ export const Generic = () => { const reads: ReadWithCid[] = members.map((member: MemberWithCid) => ({ cid, - last_read: new Date(new Date().setDate(new Date().getDate() - getRandomInt(0, 20))), + last_read: dateToNs( + new Date(new Date().setDate(new Date().getDate() - getRandomInt(0, 20))), + ), unread_messages: 0, user: member.user, })); @@ -932,11 +936,10 @@ export const Generic = () => { const messages = channelResponse.messages; messages.sort( (a: Partial | LocalMessage, b: Partial | LocalMessage) => - new Date(a.created_at as string | Date).getTime() - - new Date(b.created_at as string | Date).getTime(), + (a.created_at as number) - (b.created_at as number), ); // truncate at the middle - const truncatedAt = messages[Number(messages.length / 2)].created_at as Date | undefined; + const truncatedAt = messages[Number(messages.length / 2)].created_at as number | undefined; act(() => dispatchChannelTruncatedEvent(chatClient, { ...channelToTruncate, @@ -981,7 +984,7 @@ export const Generic = () => { const channelResponse = channels[getRandomInt(0, channels.length - 1)]; const channelToTruncate = channelResponse.channel; - const truncatedAt = new Date(0); + const truncatedAt = 0; act(() => dispatchChannelTruncatedEvent(chatClient, { ...channelToTruncate, @@ -1019,12 +1022,10 @@ export const Generic = () => { const channelToTruncate = channelResponse.channel; const messages = channelResponse.messages; const latestTimestamp = Math.max( - ...messages.map((m: Partial | LocalMessage) => - new Date(m.created_at as string | Date).getTime(), - ), + ...messages.map((m: Partial | LocalMessage) => m.created_at as number), ); // truncate at the middle - const truncatedAt = new Date(latestTimestamp + 1); + const truncatedAt = latestTimestamp + 1; act(() => dispatchChannelTruncatedEvent(chatClient, { ...channelToTruncate, @@ -1735,7 +1736,7 @@ export const Generic = () => { const targetChannel = channels[getRandomInt(0, channels.length - 1)]; const targetMember = targetChannel.members[getRandomInt(0, targetChannel.members.length - 1)]; - const readTimestamp = new Date().toISOString(); + const readTimestamp = nowNs(); act(() => { // `last_read` is not on `Event` (the real field is `last_read_at`), but the test fixture @@ -1765,10 +1766,7 @@ export const Generic = () => { // FIXME: Currently missing from the DB, uncomment when added. // expect(matchingReadRows[0].firstUnreadMessageId).toBe('123'); expect( - Math.abs( - new Date(matchingReadRows[0].lastRead as string).getTime() - - new Date(readTimestamp).getTime(), - ), + Math.abs(nsToMs(matchingReadRows[0].lastRead as number) - nsToMs(readTimestamp)), ).toBeLessThanOrEqual(1); }); }); @@ -1786,7 +1784,7 @@ export const Generic = () => { // `userID` is now a read-only getter derived from `user.id`; setting `user` is enough. chatClient.user = targetMember.user; - const readTimestamp = new Date().toISOString(); + const readTimestamp = nowNs(); act(() => { dispatchNotificationMarkUnread( @@ -1815,10 +1813,7 @@ export const Generic = () => { // FIXME: Currently missing from the DB, uncomment when added. // expect(matchingReadRows[0].firstUnreadMessageId).toBe('123'); expect( - Math.abs( - new Date(matchingReadRows[0].lastRead as string).getTime() - - new Date(readTimestamp).getTime(), - ), + Math.abs(nsToMs(matchingReadRows[0].lastRead as number) - nsToMs(readTimestamp)), ).toBeLessThanOrEqual(1); }); }); diff --git a/package/src/__tests__/offline-support/optimistic-update.tsx b/package/src/__tests__/offline-support/optimistic-update.tsx index 5fe0492110..7160006188 100644 --- a/package/src/__tests__/offline-support/optimistic-update.tsx +++ b/package/src/__tests__/offline-support/optimistic-update.tsx @@ -11,7 +11,7 @@ import type { StreamChat, UserResponse, } from 'stream-chat'; -import { localMessageToNewMessagePayload } from 'stream-chat'; +import { dateToNs, localMessageToNewMessagePayload, nowNs } from 'stream-chat'; import { v4 as uuidv4 } from 'uuid'; import { Channel as ChannelRaw } from '../../components/Channel/Channel'; @@ -127,7 +127,8 @@ export const OptimisticUpdates = () => { const allMembers: ChannelMemberResponse[] = []; const allReactions: ReactionResponse[] = []; const allReads: Array<{ - last_read: Date; + /** Unix nanoseconds, as the API sends it. */ + last_read: number; unread_messages: number; user: ReturnType | undefined; }> = []; @@ -168,7 +169,9 @@ export const OptimisticUpdates = () => { }); const reads = members.map((member: ChannelMemberResponse) => ({ - last_read: new Date(new Date().setDate(new Date().getDate() - getRandomInt(0, 20))), + last_read: dateToNs( + new Date(new Date().setDate(new Date().getDate() - getRandomInt(0, 20))), + ), unread_messages: getRandomInt(0, messages.length), user: member.user, })); @@ -583,9 +586,9 @@ export const OptimisticUpdates = () => { }; const editedMessage = { ...message, - message_text_updated_at: new Date(), + message_text_updated_at: nowNs(), text: editedText, - updated_at: new Date(), + updated_at: nowNs(), }; await getOfflineDb(chatClient).addPendingTask({ channelId: channel.id, diff --git a/package/src/components/AITypingIndicatorView/hooks/__tests__/useAIState.test.ts b/package/src/components/AITypingIndicatorView/hooks/__tests__/useAIState.test.ts index 99ccd4c354..a33824e5d2 100644 --- a/package/src/components/AITypingIndicatorView/hooks/__tests__/useAIState.test.ts +++ b/package/src/components/AITypingIndicatorView/hooks/__tests__/useAIState.test.ts @@ -3,6 +3,7 @@ import { act, renderHook } from '@testing-library/react-native'; import { AIStates } from 'stream-chat'; import { initiateClientWithChannels } from '../../../../mock-builders/api/initiateClientWithChannels'; +import { convertDateToTimestamp } from '../../../../mock-builders/generator/time'; import { useAIState } from '../useAIState'; describe('useAIState', () => { @@ -24,7 +25,7 @@ describe('useAIState', () => { client.dispatchEvent({ ai_state: AIStates.Generating, cid: channel.cid, - created_at: new Date('2024-01-01T00:00:00.000Z'), + created_at: convertDateToTimestamp('2024-01-01T00:00:00.000Z'), custom: {}, message_id: 'message-id', type: 'ai_indicator.update', @@ -35,7 +36,7 @@ describe('useAIState', () => { act(() => { client.dispatchEvent({ cid: channel.cid, - created_at: new Date('2024-01-01T00:00:00.000Z'), + created_at: convertDateToTimestamp('2024-01-01T00:00:00.000Z'), custom: {}, type: 'ai_indicator.clear', }); diff --git a/package/src/components/Channel/__tests__/Channel.test.tsx b/package/src/components/Channel/__tests__/Channel.test.tsx index 3e956e6929..d6ef15ed8f 100644 --- a/package/src/components/Channel/__tests__/Channel.test.tsx +++ b/package/src/components/Channel/__tests__/Channel.test.tsx @@ -22,6 +22,7 @@ import dispatchConnectionChanged from '../../../mock-builders/event/connectionCh import { generateChannelResponse } from '../../../mock-builders/generator/channel'; import { generateMember } from '../../../mock-builders/generator/member'; import { generateMessage } from '../../../mock-builders/generator/message'; +import { convertDateToTimestamp } from '../../../mock-builders/generator/time'; import { generateUser } from '../../../mock-builders/generator/user'; import { getTestClientWithUser } from '../../../mock-builders/mock'; import { Attachment } from '../../Attachment/Attachment'; @@ -526,7 +527,7 @@ describe('Channel initial load useEffect', () => { const read_data: typeof channel.state.read = {}; read_data[chatClient.user!.id] = { - last_read: new Date(), + last_read: convertDateToTimestamp(), user, } as unknown as (typeof channel.state.read)[string]; @@ -564,7 +565,7 @@ describe('Channel initial load useEffect', () => { const read_data: typeof channel.state.read = {}; read_data[chatClient.user!.id] = { - last_read: new Date(), + last_read: convertDateToTimestamp(), unread_messages: numberOfUnreadMessages, user, }; diff --git a/package/src/components/Channel/__tests__/isAttachmentEqualHandler.test.tsx b/package/src/components/Channel/__tests__/isAttachmentEqualHandler.test.tsx index c16e7a1889..fd036be53f 100644 --- a/package/src/components/Channel/__tests__/isAttachmentEqualHandler.test.tsx +++ b/package/src/components/Channel/__tests__/isAttachmentEqualHandler.test.tsx @@ -15,6 +15,7 @@ import { toChannelResponse } from '../../../mock-builders/event/utils'; import { generateChannelResponse } from '../../../mock-builders/generator/channel'; import { generateMember } from '../../../mock-builders/generator/member'; import { generateMessage } from '../../../mock-builders/generator/message'; +import { convertDateToTimestamp } from '../../../mock-builders/generator/time'; import { generateUser } from '../../../mock-builders/generator/user'; import { getTestClientWithUser } from '../../../mock-builders/mock'; import { Channel } from '../../Channel/Channel'; @@ -114,7 +115,7 @@ describe('isAttachmentEqualHandler', () => { attachments: [ { customField: 'custom-field-2', type: 'test' } as AttachmentWithCustomField, ], - updated_at: new Date(), + updated_at: convertDateToTimestamp(), }, toChannelResponse(channel), ); diff --git a/package/src/components/ChannelDetails/__tests__/members/ChannelMemberItem.test.tsx b/package/src/components/ChannelDetails/__tests__/members/ChannelMemberItem.test.tsx index 0149bb5251..e02d280845 100644 --- a/package/src/components/ChannelDetails/__tests__/members/ChannelMemberItem.test.tsx +++ b/package/src/components/ChannelDetails/__tests__/members/ChannelMemberItem.test.tsx @@ -11,6 +11,7 @@ import { ChatContext } from '../../../../contexts/chatContext/ChatContext'; import { defaultTheme } from '../../../../contexts/themeContext/utils/theme'; import { TranslationProvider } from '../../../../contexts/translationContext/TranslationContext'; import { generateMember } from '../../../../mock-builders/generator/member'; +import { convertDateToTimestamp } from '../../../../mock-builders/generator/time'; import { generateUser } from '../../../../mock-builders/generator/user'; import { ChannelMemberItem } from '../../components/members/ChannelMemberItem'; import type { GetMemberRoles } from '../../hooks/members/useMemberRoles'; @@ -160,7 +161,7 @@ describe('ChannelMemberItem activity status', () => { it('shows a "Last seen ..." string for an offline member with last_active', () => { jest.useFakeTimers().setSystemTime(new Date('2026-05-13T12:00:00Z')); - const tenMinutesAgo = new Date('2026-05-13T11:50:00Z'); + const tenMinutesAgo = convertDateToTimestamp('2026-05-13T11:50:00Z'); renderRow({ member: memberFor({ last_active: tenMinutesAgo, online: false }) }); diff --git a/package/src/components/ChannelDetails/__tests__/useChannelDetailsMembersPreview.test.tsx b/package/src/components/ChannelDetails/__tests__/useChannelDetailsMembersPreview.test.tsx index 1bd718b410..94cb175fdf 100644 --- a/package/src/components/ChannelDetails/__tests__/useChannelDetailsMembersPreview.test.tsx +++ b/package/src/components/ChannelDetails/__tests__/useChannelDetailsMembersPreview.test.tsx @@ -3,6 +3,7 @@ import type { Channel, ChannelMemberResponse } from 'stream-chat'; import { generateChannelState } from '../../../mock-builders/generator/channelState'; import { generateMember } from '../../../mock-builders/generator/member'; +import { convertDateToTimestamp } from '../../../mock-builders/generator/time'; import { generateUser } from '../../../mock-builders/generator/user'; import { useChannelDetailsMembersPreview } from '../hooks/useChannelDetailsMembersPreview'; @@ -27,7 +28,7 @@ const buildChannel = ({ const buildMember = (id: string, created_at?: string) => generateMember({ - created_at: created_at ? new Date(created_at) : undefined, + created_at: created_at ? convertDateToTimestamp(created_at) : undefined, user: generateUser({ id, name: id }), }); diff --git a/package/src/components/ChannelDetails/__tests__/useFileAttachmentListSections.test.tsx b/package/src/components/ChannelDetails/__tests__/useFileAttachmentListSections.test.tsx index a37029ce2d..32e4bd8d1e 100644 --- a/package/src/components/ChannelDetails/__tests__/useFileAttachmentListSections.test.tsx +++ b/package/src/components/ChannelDetails/__tests__/useFileAttachmentListSections.test.tsx @@ -14,6 +14,7 @@ import { generateImageAttachment, } from '../../../mock-builders/generator/attachment'; import { generateMessage } from '../../../mock-builders/generator/message'; +import { convertDateToTimestamp } from '../../../mock-builders/generator/time'; import { Streami18n } from '../../../utils/i18n/Streami18n'; import { useFileAttachmentListSections } from '../hooks/useFileAttachmentListSections'; @@ -35,7 +36,7 @@ const messageAt = ( ): MessageResponse => generateMessage({ attachments: attachments as never, - created_at: new Date(createdAt), + created_at: convertDateToTimestamp(createdAt), id, }) as unknown as MessageResponse; diff --git a/package/src/components/ChannelDetails/__tests__/useUserActivityStatus.test.tsx b/package/src/components/ChannelDetails/__tests__/useUserActivityStatus.test.tsx index e4f160ecfc..e595c22fa4 100644 --- a/package/src/components/ChannelDetails/__tests__/useUserActivityStatus.test.tsx +++ b/package/src/components/ChannelDetails/__tests__/useUserActivityStatus.test.tsx @@ -7,6 +7,7 @@ import { TranslationProvider, type TranslationContextValue, } from '../../../contexts/translationContext/TranslationContext'; +import { convertDateToTimestamp } from '../../../mock-builders/generator/time'; import { Streami18n } from '../../../utils/i18n/Streami18n'; import { useUserActivityStatus } from '../hooks/useUserActivityStatus'; @@ -46,7 +47,7 @@ describe('useUserActivityStatus', () => { it('returns a relative "Last seen ..." string when offline with a valid last_active', () => { jest.useFakeTimers().setSystemTime(new Date('2026-05-13T12:00:00Z')); - const tenMinutesAgo = new Date('2026-05-13T11:50:00Z'); + const tenMinutesAgo = convertDateToTimestamp('2026-05-13T11:50:00Z'); const { result } = renderHook( () => useUserActivityStatus(userFor({ last_active: tenMinutesAgo, online: false })), diff --git a/package/src/components/ChannelDetails/components/__tests__/navigation-section/FileAttachmentList.test.tsx b/package/src/components/ChannelDetails/components/__tests__/navigation-section/FileAttachmentList.test.tsx index 403b83b973..d2abde5395 100644 --- a/package/src/components/ChannelDetails/components/__tests__/navigation-section/FileAttachmentList.test.tsx +++ b/package/src/components/ChannelDetails/components/__tests__/navigation-section/FileAttachmentList.test.tsx @@ -18,6 +18,7 @@ import { } from '../../../../../contexts/translationContext/TranslationContext'; import { generateFileAttachment } from '../../../../../mock-builders/generator/attachment'; import { generateMessage } from '../../../../../mock-builders/generator/message'; +import { convertDateToTimestamp } from '../../../../../mock-builders/generator/time'; import { Streami18n } from '../../../../../utils/i18n/Streami18n'; import type { FileAttachmentItemProps } from '../../navigation-section/FileAttachmentItem'; import { FileAttachmentList } from '../../navigation-section/FileAttachmentList'; @@ -191,12 +192,12 @@ describe('FileAttachmentList', () => { it('renders a row per attachment', () => { const messageA = generateMessage({ attachments: [generateFileAttachment()] as never, - created_at: new Date('2026-03-15T00:00:00.000Z'), + created_at: convertDateToTimestamp('2026-03-15T00:00:00.000Z'), id: 'm-1', }) as unknown as MessageResponse; const messageB = generateMessage({ attachments: [generateFileAttachment()] as never, - created_at: new Date('2026-03-16T00:00:00.000Z'), + created_at: convertDateToTimestamp('2026-03-16T00:00:00.000Z'), id: 'm-2', }) as unknown as MessageResponse; @@ -211,12 +212,12 @@ describe('FileAttachmentList', () => { it('groups messages under month section headers in newest-first order', () => { const march = generateMessage({ attachments: [generateFileAttachment()] as never, - created_at: new Date('2026-03-15T00:00:00.000Z'), + created_at: convertDateToTimestamp('2026-03-15T00:00:00.000Z'), id: 'm-mar', }) as unknown as MessageResponse; const february = generateMessage({ attachments: [generateFileAttachment()] as never, - created_at: new Date('2026-02-10T00:00:00.000Z'), + created_at: convertDateToTimestamp('2026-02-10T00:00:00.000Z'), id: 'm-feb', }) as unknown as MessageResponse; diff --git a/package/src/components/ChannelDetails/hooks/useFileAttachmentListSections.ts b/package/src/components/ChannelDetails/hooks/useFileAttachmentListSections.ts index f8105020f7..5d235b5565 100644 --- a/package/src/components/ChannelDetails/hooks/useFileAttachmentListSections.ts +++ b/package/src/components/ChannelDetails/hooks/useFileAttachmentListSections.ts @@ -1,10 +1,11 @@ import { useMemo } from 'react'; import { - type Attachment, + convertTimestampToDate, isAudioAttachment, isFileAttachment, isScrapedContent, + type Attachment, type MessageResponse, } from 'stream-chat'; @@ -60,7 +61,7 @@ export const useFileAttachmentListSections = ( continue; } const formatted = getDateString({ - messageCreatedAt: message.created_at as string | Date | undefined, + messageCreatedAt: convertTimestampToDate(message.created_at), t, tDateTimeParser, timestampTranslationKey: 'timestamp.FileAttachmentListSection', diff --git a/package/src/components/ChannelDetails/hooks/useUserActivityStatus.ts b/package/src/components/ChannelDetails/hooks/useUserActivityStatus.ts index 01c8536d0f..52a43acf5f 100644 --- a/package/src/components/ChannelDetails/hooks/useUserActivityStatus.ts +++ b/package/src/components/ChannelDetails/hooks/useUserActivityStatus.ts @@ -2,6 +2,8 @@ import { useMemo } from 'react'; import type { UserResponse } from 'stream-chat'; +import { convertTimestampToDate } from 'stream-chat'; + import { useTranslationContext } from '../../../contexts/translationContext/TranslationContext'; import { getDateString } from '../../../i18n/utils'; @@ -21,9 +23,9 @@ export const useUserActivityStatus = (user?: UserResponse): string => { return useMemo(() => { if (user?.online) return t('common.presence.online.label', 'Online'); - if (user?.last_active) { + if (user?.last_active != null) { const lastSeen = getDateString({ - messageCreatedAt: user.last_active, + messageCreatedAt: convertTimestampToDate(user.last_active), t, tDateTimeParser, timestampTranslationKey: 'timestamp.UserActivityStatus', diff --git a/package/src/components/ChannelList/hooks/utils/index.ts b/package/src/components/ChannelList/hooks/utils/index.ts index e5c117adf0..a451a5ef4d 100644 --- a/package/src/components/ChannelList/hooks/utils/index.ts +++ b/package/src/components/ChannelList/hooks/utils/index.ts @@ -9,7 +9,7 @@ export const isChannelPinned = (channel: Channel) => { const member = channel.state.membership; - return !!member?.pinned_at; + return member?.pinned_at != null; }; export const isChannelArchived = (channel: Channel) => { @@ -19,7 +19,7 @@ export const isChannelArchived = (channel: Channel) => { const member = channel.state.membership; - return !!member?.archived_at; + return member?.archived_at != null; }; export const shouldConsiderArchivedChannels = (filters: ChannelListProps['filters']) => { diff --git a/package/src/components/ChannelPreview/ChannelPreviewStatus.tsx b/package/src/components/ChannelPreview/ChannelPreviewStatus.tsx index 6ebdf242cf..9b8d89ba19 100644 --- a/package/src/components/ChannelPreview/ChannelPreviewStatus.tsx +++ b/package/src/components/ChannelPreview/ChannelPreviewStatus.tsx @@ -1,6 +1,8 @@ import React, { useMemo } from 'react'; import { StyleSheet, Text } from 'react-native'; +import { convertTimestampToDate } from 'stream-chat'; + import type { ChannelPreviewProps } from './ChannelPreview'; import type { ChannelPreviewViewPropsWithContext } from './ChannelPreviewView'; @@ -23,12 +25,12 @@ export const ChannelPreviewStatus = (props: ChannelPreviewStatusProps) => { const styles = useStyles(); const created_at = lastMessage?.created_at; - const latestMessageDate = created_at ? new Date(created_at) : new Date(); + const latestMessageDate = convertTimestampToDate(created_at); const formattedDate = useMemo( () => getDateString({ - messageCreatedAt: created_at, + messageCreatedAt: convertTimestampToDate(created_at), t, tDateTimeParser, timestampTranslationKey: 'timestamp.ChannelPreviewStatus', @@ -40,7 +42,7 @@ export const ChannelPreviewStatus = (props: ChannelPreviewStatusProps) => { () => getCalendarDateStringForA11y({ calendarFormatOverrides: { sameDay: 'LT' }, - messageCreatedAt: created_at, + messageCreatedAt: convertTimestampToDate(created_at), tDateTimeParser, userLanguage, }), diff --git a/package/src/components/ChannelPreview/__tests__/ChannelPreview.test.tsx b/package/src/components/ChannelPreview/__tests__/ChannelPreview.test.tsx index bdd2163370..2de0c4ddaf 100644 --- a/package/src/components/ChannelPreview/__tests__/ChannelPreview.test.tsx +++ b/package/src/components/ChannelPreview/__tests__/ChannelPreview.test.tsx @@ -26,6 +26,7 @@ import dispatchNotificationMarkUnread from '../../../mock-builders/event/notific import { toChannelResponse } from '../../../mock-builders/event/utils'; import { generateChannelResponse } from '../../../mock-builders/generator/channel'; import { generateMessage } from '../../../mock-builders/generator/message'; +import { convertDateToTimestamp } from '../../../mock-builders/generator/time'; import { generateUser } from '../../../mock-builders/generator/user'; import { getTestClientWithUser } from '../../../mock-builders/mock'; import { Chat } from '../../Chat/Chat'; @@ -85,7 +86,7 @@ const seedUnread = (channel: Channel, userId: string, unread_messages: number) = read: { ...channel.state.read, [userId]: { - last_read: new Date(), + last_read: convertDateToTimestamp(), unread_messages, user: { id: userId } as UserResponse, }, @@ -302,7 +303,7 @@ describe('ChannelPreview', () => { chatClient, { cid: channel?.cid }, { - last_read_at: new Date(), + last_read_at: convertDateToTimestamp(), unread_channels: 2, unread_messages: 5, user: { id: clientUser.id } as UserResponseCommonFields, diff --git a/package/src/components/ChannelPreview/hooks/__tests__/useChannelPreviewMuted.test.tsx b/package/src/components/ChannelPreview/hooks/__tests__/useChannelPreviewMuted.test.tsx index 253ee48ce7..1e29df00b9 100644 --- a/package/src/components/ChannelPreview/hooks/__tests__/useChannelPreviewMuted.test.tsx +++ b/package/src/components/ChannelPreview/hooks/__tests__/useChannelPreviewMuted.test.tsx @@ -1,6 +1,7 @@ import { act, renderHook } from '@testing-library/react-native'; import { Channel, StateStore } from 'stream-chat'; +import { convertDateToTimestamp } from '../../../../mock-builders/generator/time'; import { useIsChannelMuted } from '../useIsChannelMuted'; describe('useChannelPreviewMuted', () => { @@ -25,7 +26,7 @@ describe('useChannelPreviewMuted', () => { act(() => { channel.state.partialNext({ - muteStatus: { createdAt: new Date(), expiresAt: null, muted: true }, + muteStatus: { createdAt: convertDateToTimestamp(), expiresAt: null, muted: true }, }); }); diff --git a/package/src/components/ChannelPreview/hooks/__tests__/useIsChannelPinned.test.tsx b/package/src/components/ChannelPreview/hooks/__tests__/useIsChannelPinned.test.tsx index 5dbcc10ca2..40b36e6d0f 100644 --- a/package/src/components/ChannelPreview/hooks/__tests__/useIsChannelPinned.test.tsx +++ b/package/src/components/ChannelPreview/hooks/__tests__/useIsChannelPinned.test.tsx @@ -1,6 +1,7 @@ import { act, renderHook } from '@testing-library/react-native'; import { Channel, ChannelMemberResponse, StateStore } from 'stream-chat'; +import { convertDateToTimestamp } from '../../../../mock-builders/generator/time'; import { useIsChannelPinned } from '../useIsChannelPinned'; describe('useIsChannelPinned', () => { @@ -17,7 +18,9 @@ describe('useIsChannelPinned', () => { }); it('returns true when membership has a pinned_at timestamp', () => { - const channel = buildMockChannel({ pinned_at: '2026-06-15T08:00:00.000Z' }); + const channel = buildMockChannel({ + pinned_at: convertDateToTimestamp('2026-06-15T08:00:00.000Z'), + }); const { result } = renderHook(() => useIsChannelPinned(channel)); expect(result.current).toBe(true); }); @@ -30,7 +33,7 @@ describe('useIsChannelPinned', () => { act(() => { channel.state.partialNext({ membership: { - pinned_at: new Date('2026-06-15T08:00:00.000Z'), + pinned_at: convertDateToTimestamp('2026-06-15T08:00:00.000Z'), } as ChannelMemberResponse, }); }); diff --git a/package/src/components/ChannelPreview/hooks/useChannelPreviewData.ts b/package/src/components/ChannelPreview/hooks/useChannelPreviewData.ts index f0d00b345f..c5aa72b5fe 100644 --- a/package/src/components/ChannelPreview/hooks/useChannelPreviewData.ts +++ b/package/src/components/ChannelPreview/hooks/useChannelPreviewData.ts @@ -49,7 +49,7 @@ export const useChannelPreviewData = (channel: Channel, client: StreamChat) => { } = useStateStore(channel.state, previewStateSelector) ?? {}; const muted = muteStatus?.muted ?? false; - const pinned = Boolean(membership?.pinned_at); + const pinned = membership?.pinned_at != null; // muted channels always render a zeroed unread count const unread = muted ? 0 : (reactiveUnread ?? 0); diff --git a/package/src/components/ChannelPreview/hooks/useChannelPreviewPollLabel.ts b/package/src/components/ChannelPreview/hooks/useChannelPreviewPollLabel.ts index b15b556fa9..9ea82337b8 100644 --- a/package/src/components/ChannelPreview/hooks/useChannelPreviewPollLabel.ts +++ b/package/src/components/ChannelPreview/hooks/useChannelPreviewPollLabel.ts @@ -32,7 +32,7 @@ export const useChannelPreviewPollLabel = ({ pollId }: UseChannelPreviewPollLabe latestVotesByOption ? Object.values(latestVotesByOption) .map((votes) => votes?.[0]) - .sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()) + .sort((a, b) => b.created_at - a.created_at) : [], [latestVotesByOption], ); diff --git a/package/src/components/ChannelPreview/hooks/useIsChannelMuted.ts b/package/src/components/ChannelPreview/hooks/useIsChannelMuted.ts index 4ae6f1846b..7807b010fc 100644 --- a/package/src/components/ChannelPreview/hooks/useIsChannelMuted.ts +++ b/package/src/components/ChannelPreview/hooks/useIsChannelMuted.ts @@ -9,7 +9,8 @@ const defaultMuteStatus = { }; const selector = (state: { - muteStatus: { createdAt: Date | null; expiresAt: Date | null; muted: boolean }; + // Mirrors core's `ChannelMuteStatus`: both timestamps are unix nanoseconds. + muteStatus: { createdAt: number | null; expiresAt: number | null; muted: boolean }; }) => ({ muteStatus: state.muteStatus }); /** diff --git a/package/src/components/ChannelPreview/hooks/useIsChannelPinned.ts b/package/src/components/ChannelPreview/hooks/useIsChannelPinned.ts index 083f1bcf96..9e83d86f34 100644 --- a/package/src/components/ChannelPreview/hooks/useIsChannelPinned.ts +++ b/package/src/components/ChannelPreview/hooks/useIsChannelPinned.ts @@ -4,5 +4,5 @@ import { useChannelMembershipState } from '../../../hooks/useChannelMembershipSt export const useIsChannelPinned = (channel: Channel) => { const membership = useChannelMembershipState(channel); - return Boolean(membership?.pinned_at); + return membership?.pinned_at != null; }; diff --git a/package/src/components/Message/MessageItemView/MessageHeader.tsx b/package/src/components/Message/MessageItemView/MessageHeader.tsx index 74f2e29e2d..28ef164112 100644 --- a/package/src/components/Message/MessageItemView/MessageHeader.tsx +++ b/package/src/components/Message/MessageItemView/MessageHeader.tsx @@ -109,8 +109,8 @@ export const MessageHeader = (props: MessageHeaderProps) => { const { alignment, message } = useMessageContext(); const reminder = useMessageReminder(message.id); - const shouldShowSavedForLaterHeader = reminder && !reminder.remindAt; - const shouldShowReminderHeader = reminder && reminder.remindAt; + const shouldShowSavedForLaterHeader = reminder != null && reminder.remindAt == null; + const shouldShowReminderHeader = reminder?.remindAt != null; const shouldShowPinnedHeader = !!message?.pinned; const shouldShowSentToChannelHeader = !!message?.show_in_channel; @@ -129,7 +129,7 @@ export const MessageHeader = (props: MessageHeaderProps) => { message={message} shouldShowSavedForLaterHeader={shouldShowSavedForLaterHeader} shouldShowPinnedHeader={shouldShowPinnedHeader} - shouldShowReminderHeader={!!shouldShowReminderHeader} + shouldShowReminderHeader={shouldShowReminderHeader} shouldShowSentToChannelHeader={shouldShowSentToChannelHeader} {...props} /> diff --git a/package/src/components/Message/MessageItemView/MessageItemView.tsx b/package/src/components/Message/MessageItemView/MessageItemView.tsx index b65a805257..1cf62e9eb8 100644 --- a/package/src/components/Message/MessageItemView/MessageItemView.tsx +++ b/package/src/components/Message/MessageItemView/MessageItemView.tsx @@ -1,6 +1,8 @@ import React, { useMemo } from 'react'; import { Dimensions, StyleSheet, View } from 'react-native'; +import { convertTimestampToDate } from 'stream-chat'; + import { SwipableMessageWrapper } from './MessageBubble'; import { useComponentsContext } from '../../../contexts/componentsContext/ComponentsContext'; @@ -210,11 +212,17 @@ const MessageItemViewWithContext = (props: MessageItemViewPropsWithContext) => { setQuotedMessage(message); }); + // Hoisted: `MessageFooter` and `MessageDeleted` memoize on `prevDate === nextDate`. + const messageCreatedAt = useMemo( + () => convertTimestampToDate(message.created_at), + [message.created_at], + ); + const itemViewContent = ( {alignment === 'left' ? : null} {isMessageTypeDeleted ? ( - + ) : ( { {reactionListPosition === 'bottom' && ReactionListBottom ? ( ) : null} - + )} {MessageSpacer ? : null} diff --git a/package/src/components/Message/MessageItemView/MessageWrapper.tsx b/package/src/components/Message/MessageItemView/MessageWrapper.tsx index 63785a8a6d..cf15c73300 100644 --- a/package/src/components/Message/MessageItemView/MessageWrapper.tsx +++ b/package/src/components/Message/MessageItemView/MessageWrapper.tsx @@ -53,12 +53,12 @@ export const MessageWrapper = React.memo(function MessageWrapper(props: MessageW noGroupByUser, }); - const createdAtTimestamp = message.created_at && new Date(message.created_at).getTime(); + // Wire timestamps throughout, directly comparable. `new Date(ns)` yielded NaN, so the unread + // separator never rendered. + const createdAtTimestamp = message.created_at; const nextMessageId = nextMessage?.id; const nextMessageIsOwn = nextMessage?.user?.id === client.userID; - const nextMessageCreatedAt = nextMessage?.created_at - ? new Date(nextMessage.created_at).getTime() - : undefined; + const nextMessageCreatedAt = nextMessage?.created_at ?? undefined; // The unread separator belongs above the first UNREAD message from another user — i.e. on the row // whose newer neighbour is that first unread. We locate it per-message inside the selector so @@ -85,14 +85,14 @@ export const MessageWrapper = React.memo(function MessageWrapper(props: MessageW // interleaved among the unreads: an own message sent after the boundary is not "read" by // this test, so `own → unread-from-another` transitions further down never start a second // separator. Chronological ordering guarantees exactly one read→unread transition. - const lastReadAtMs = snapshot.lastReadAt?.getTime() ?? 0; + const lastReadAt = snapshot.lastReadAt ?? 0; const nextIsUnreadFromOther = !!nextMessageId && !nextMessageIsOwn && nextMessageCreatedAt !== undefined && - nextMessageCreatedAt > lastReadAtMs; + nextMessageCreatedAt > lastReadAt; const thisIsRead = - typeof createdAtTimestamp === 'number' && createdAtTimestamp <= lastReadAtMs; + typeof createdAtTimestamp === 'number' && createdAtTimestamp <= lastReadAt; showUnreadSeparator = nextIsUnreadFromOther && thisIsRead; } else { showUnreadSeparator = false; diff --git a/package/src/components/Message/hooks/useProcessReactions.ts b/package/src/components/Message/hooks/useProcessReactions.ts index 7ea35c4bdd..01c2df0cf0 100644 --- a/package/src/components/Message/hooks/useProcessReactions.ts +++ b/package/src/components/Message/hooks/useProcessReactions.ts @@ -1,6 +1,6 @@ import { ComponentType, useMemo } from 'react'; -import { ReactionGroupResponse, ReactionResponse } from 'stream-chat'; +import { convertTimestampToDate, ReactionGroupResponse, ReactionResponse } from 'stream-chat'; import { useChatContext } from '../../../contexts'; import { @@ -105,9 +105,9 @@ export const useProcessReactions = (props: UseProcessReactionsParams) => { return { count, - firstReactionAt: first_reaction_at ? new Date(first_reaction_at) : null, + firstReactionAt: convertTimestampToDate(first_reaction_at) ?? null, Icon: getEmojiByReactionType(reactionType, supportedReactions), - lastReactionAt: last_reaction_at ? new Date(last_reaction_at) : null, + lastReactionAt: convertTimestampToDate(last_reaction_at) ?? null, latestReactedUserNames, own: isOwnReaction(reactionType, own_reactions, latest_reactions, client.userID), type: reactionType, diff --git a/package/src/components/MessageInput/__tests__/SendMessageDisallowedIndicator.test.tsx b/package/src/components/MessageInput/__tests__/SendMessageDisallowedIndicator.test.tsx index 591298b268..34bd5da1d2 100644 --- a/package/src/components/MessageInput/__tests__/SendMessageDisallowedIndicator.test.tsx +++ b/package/src/components/MessageInput/__tests__/SendMessageDisallowedIndicator.test.tsx @@ -17,6 +17,7 @@ import { generateLocalFileUploadAttachmentData } from '../../../mock-builders/at import { generateMessage } from '../../../mock-builders/generator/message'; +import { convertDateToTimestamp } from '../../../mock-builders/generator/time'; import { AttachmentPickerStore } from '../../../state-store/attachment-picker-store'; import { AttachmentPickerContent } from '../../AttachmentPicker/components/AttachmentPickerContent'; import { AttachmentPickerSelectionBar } from '../../AttachmentPicker/components/AttachmentPickerSelectionBar'; @@ -160,7 +161,7 @@ describe('SendMessageDisallowedIndicator', () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any, cid: channel.cid, - created_at: new Date(), + created_at: convertDateToTimestamp(), custom: {}, type: 'channel.updated', }); diff --git a/package/src/components/MessageList/MessageFlashList.tsx b/package/src/components/MessageList/MessageFlashList.tsx index 7a4c858e1a..547c2c637d 100644 --- a/package/src/components/MessageList/MessageFlashList.tsx +++ b/package/src/components/MessageList/MessageFlashList.tsx @@ -13,6 +13,8 @@ import Animated from 'react-native-reanimated'; import type { FlashListProps, FlashListRef } from '@shopify/flash-list'; import type { Channel, EventPayload, LocalMessage } from 'stream-chat'; +import { convertTimestampToDate } from 'stream-chat'; + import { useMarkRead } from './hooks/useMarkRead'; import { useMessageList } from './hooks/useMessageList'; @@ -22,6 +24,7 @@ import { useTypingUsers } from './hooks/useTypingUsers'; import { InlineLoadingMoreIndicator } from './InlineLoadingMoreIndicator'; import { InlineLoadingMoreRecentIndicator } from './InlineLoadingMoreRecentIndicator'; import { InlineLoadingMoreRecentThreadIndicator } from './InlineLoadingMoreRecentThreadIndicator'; +import { getMessageListItemCacheKey } from './utils/buildMessageListWithNeighbours'; import { AttachmentPickerContextValue, @@ -80,15 +83,8 @@ try { FlashList = undefined; } -const keyExtractor = (item: LocalMessage) => { - if (item.id) { - return item.id; - } - if (item.created_at) { - return typeof item.created_at === 'string' ? item.created_at : item.created_at.toISOString(); - } - return Date.now().toString(); -}; +// Delegates to the neighbour-cache key so the render key and the cache key cannot drift apart. +const keyExtractor = (item: LocalMessage, index: number) => getMessageListItemCacheKey(item, index); const flatListViewabilityConfig: ViewabilityConfig = { viewAreaCoveragePercentThreshold: 1, @@ -563,8 +559,8 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => if ( isMessageRemovedFromMessageList || - (topMessageBeforeUpdate.current?.created_at && - topMessageAfterUpdate?.created_at && + (topMessageBeforeUpdate.current?.created_at != null && + topMessageAfterUpdate?.created_at != null && topMessageBeforeUpdate.current.created_at < topMessageAfterUpdate.created_at) ) { channelResyncScrollSet.current = false; @@ -698,13 +694,13 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => const isMessageTypeDeleted = lastItem.item.type === 'deleted'; if ( - lastItem?.item?.created_at && !isMessageTypeDeleted && - typeof lastItem.item.created_at !== 'string' && - lastItem.item.created_at.toDateString() !== stickyHeaderDateRef.current?.toDateString() + lastItem.item.created_at != null && + convertTimestampToDate(lastItem.item.created_at)?.toDateString() !== + stickyHeaderDateRef.current?.toDateString() ) { - stickyHeaderDateRef.current = lastItem.item.created_at; - setStickyHeaderDate(lastItem.item.created_at); + stickyHeaderDateRef.current = convertTimestampToDate(lastItem.item.created_at); + setStickyHeaderDate(convertTimestampToDate(lastItem.item.created_at)); } }, ); @@ -745,8 +741,8 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => const lastItemMessage = lastItem.item; const lastItemCreatedAt = lastItemMessage.created_at; - const unreadIndicatorDate = channelUnreadState?.last_read?.getTime(); - const lastItemDate = lastItemCreatedAt.getTime(); + const unreadIndicatorDate = channelUnreadState?.last_read; + const lastItemDate = lastItemCreatedAt; if ( !channel.messagePaginator.hasMoreTail && @@ -768,7 +764,7 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => setIsUnreadNotificationOpen(false); return; } - if (unreadIndicatorDate && lastItemDate > unreadIndicatorDate) { + if (unreadIndicatorDate != null && lastItemDate > unreadIndicatorDate) { setIsUnreadNotificationOpen(true); } else { setIsUnreadNotificationOpen(false); diff --git a/package/src/components/MessageList/MessageList.tsx b/package/src/components/MessageList/MessageList.tsx index 656d95efad..bb420b88cb 100644 --- a/package/src/components/MessageList/MessageList.tsx +++ b/package/src/components/MessageList/MessageList.tsx @@ -17,6 +17,8 @@ import debounce from 'lodash/debounce'; import type { Channel, EventPayload, LocalMessage } from 'stream-chat'; +import { convertTimestampToDate } from 'stream-chat'; + import { useMarkRead } from './hooks/useMarkRead'; import { useMessageList } from './hooks/useMessageList'; @@ -29,6 +31,7 @@ import { InlineLoadingMoreRecentThreadIndicator } from './InlineLoadingMoreRecen import { buildMessageListWithNeighbours, + getMessageListItemCacheKey, MessageListItemWithNeighbours, } from './utils/buildMessageListWithNeighbours'; @@ -169,16 +172,9 @@ const useStyles = () => { ); }; -const keyExtractor = (derivedItem: MessageListItemWithNeighbours) => { - const { message: item } = derivedItem; - if (item.id) { - return item.id; - } - if (item.created_at) { - return typeof item.created_at === 'string' ? item.created_at : item.created_at.toISOString(); - } - return Date.now().toString(); -}; +// Delegates to the neighbour-cache key so the render key and the cache key cannot drift apart. +const keyExtractor = (derivedItem: MessageListItemWithNeighbours, index: number) => + getMessageListItemCacheKey(derivedItem.message, index); const flatListViewabilityConfig: ViewabilityConfig = { viewAreaCoveragePercentThreshold: 1, @@ -519,13 +515,13 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { const isMessageTypeDeleted = lastMessage.type === 'deleted'; if ( - lastMessage?.created_at && !isMessageTypeDeleted && - typeof lastMessage.created_at !== 'string' && - lastMessage.created_at.toDateString() !== stickyHeaderDateRef.current?.toDateString() + lastMessage.created_at != null && + convertTimestampToDate(lastMessage.created_at)?.toDateString() !== + stickyHeaderDateRef.current?.toDateString() ) { - stickyHeaderDateRef.current = lastMessage.created_at; - setStickyHeaderDate(lastMessage.created_at); + stickyHeaderDateRef.current = convertTimestampToDate(lastMessage.created_at); + setStickyHeaderDate(convertTimestampToDate(lastMessage.created_at)); } } }, @@ -566,8 +562,8 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { const lastItemMessage = lastItem.message; const lastItemCreatedAt = lastItemMessage.created_at; - const unreadIndicatorDate = channelUnreadState?.last_read?.getTime(); - const lastItemDate = lastItemCreatedAt.getTime(); + const unreadIndicatorDate = channelUnreadState?.last_read; + const lastItemDate = lastItemCreatedAt; if ( !channel.messagePaginator.hasMoreTail && @@ -589,7 +585,7 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { setIsUnreadNotificationOpen(false); return; } - if (unreadIndicatorDate && lastItemDate > unreadIndicatorDate) { + if (unreadIndicatorDate != null && lastItemDate > unreadIndicatorDate) { setIsUnreadNotificationOpen(true); } else { setIsUnreadNotificationOpen(false); @@ -727,8 +723,8 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { if ( isMessageRemovedFromMessageList || - (topMessageBeforeUpdate.current?.created_at && - topMessageAfterUpdate?.created_at && + (topMessageBeforeUpdate.current?.created_at != null && + topMessageAfterUpdate?.created_at != null && topMessageBeforeUpdate.current.created_at < topMessageAfterUpdate.created_at) ) { channelResyncScrollSet.current = false; diff --git a/package/src/components/MessageList/MessageSystem.tsx b/package/src/components/MessageList/MessageSystem.tsx index 3e5161e709..39281ee696 100644 --- a/package/src/components/MessageList/MessageSystem.tsx +++ b/package/src/components/MessageList/MessageSystem.tsx @@ -1,7 +1,7 @@ import React, { useMemo } from 'react'; import { StyleProp, StyleSheet, Text, View, ViewStyle } from 'react-native'; -import { LocalMessage } from 'stream-chat'; +import { convertTimestampToDate, LocalMessage } from 'stream-chat'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; import { useTranslationContext } from '../../contexts/translationContext/TranslationContext'; @@ -44,7 +44,7 @@ export const MessageSystem = (props: MessageSystemProps) => { const formattedDate = useMemo( () => getDateString({ - messageCreatedAt: createdAt, + messageCreatedAt: convertTimestampToDate(createdAt), t, tDateTimeParser, timestampTranslationKey: 'timestamp.MessageSystem', diff --git a/package/src/components/MessageList/__tests__/MessageList.test.tsx b/package/src/components/MessageList/__tests__/MessageList.test.tsx index d9ea116030..9e62282d1d 100644 --- a/package/src/components/MessageList/__tests__/MessageList.test.tsx +++ b/package/src/components/MessageList/__tests__/MessageList.test.tsx @@ -5,6 +5,8 @@ import { FlatList } from 'react-native'; import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react-native'; import type { UserResponse } from 'stream-chat'; +import { msToNs } from 'stream-chat'; + import { OverlayProvider } from '../../../contexts/overlayContext/OverlayProvider'; import { getOrCreateChannelApi } from '../../../mock-builders/api/getOrCreateChannel'; @@ -353,7 +355,7 @@ describe('MessageList', () => { read: [ { user: user1, - last_read: new Date(base + 5000).toISOString(), + last_read: msToNs(base + 5000), last_read_message_id: '5', unread_messages: 5, }, @@ -414,7 +416,7 @@ describe('MessageList', () => { read: [ { user: user1, - last_read: new Date(base + 5000).toISOString(), + last_read: msToNs(base + 5000), last_read_message_id: '5', unread_messages: 4, }, diff --git a/package/src/components/MessageList/__tests__/ScrollToBottomButton.test.tsx b/package/src/components/MessageList/__tests__/ScrollToBottomButton.test.tsx index 0fa4c7270e..dab1e4b551 100644 --- a/package/src/components/MessageList/__tests__/ScrollToBottomButton.test.tsx +++ b/package/src/components/MessageList/__tests__/ScrollToBottomButton.test.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { cleanup, fireEvent, render, waitFor } from '@testing-library/react-native'; +import { nowNs } from 'stream-chat'; import type { ChannelContextValue } from '../../../contexts/channelContext/ChannelContext'; import { ChannelProvider } from '../../../contexts/channelContext/ChannelContext'; @@ -79,7 +80,7 @@ describe('ScrollToBottomButton', () => { state: generateChannelState({ read: { me: { - last_read: new Date(), + last_read: nowNs(), unread_messages: 3, user: { id: 'me' }, }, diff --git a/package/src/components/MessageList/__tests__/buildMessageListWithNeighbours.test.ts b/package/src/components/MessageList/__tests__/buildMessageListWithNeighbours.test.ts index 7eec5cd4fb..6a8b7e8651 100644 --- a/package/src/components/MessageList/__tests__/buildMessageListWithNeighbours.test.ts +++ b/package/src/components/MessageList/__tests__/buildMessageListWithNeighbours.test.ts @@ -1,7 +1,10 @@ import { LocalMessage } from 'stream-chat'; +import { convertDateToTimestamp } from '../../../mock-builders/generator/time'; + import { buildMessageListWithNeighbours, + getMessageListItemCacheKey, MessageListItemWithNeighbours, } from '../utils/buildMessageListWithNeighbours'; @@ -49,3 +52,33 @@ describe('buildMessageListWithNeighbours', () => { expect(row2.nextMessage?.id).toBe('m2'); }); }); + +// The key is both the FlatList/FlashList render key and the neighbour-cache key, so it has to be +// stable across renders and identical between the two uses. `created_at` is unix nanoseconds, which +// makes the epoch `0` — a legitimate value that a truthiness guard mistakes for "absent". +describe('getMessageListItemCacheKey', () => { + it('prefers the message id', () => { + expect(getMessageListItemCacheKey(createMessage('m1'), 3)).toBe('m1'); + }); + + it('falls back to created_at for an id-less message', () => { + const message = { + created_at: convertDateToTimestamp('2026-01-01T15:53:00.000Z'), + } as LocalMessage; + + expect(getMessageListItemCacheKey(message, 3)).toBe(String(message.created_at)); + }); + + it('treats the epoch as a real timestamp rather than a missing one', () => { + // A truthiness guard skips `created_at` here and returns the index instead, which shifts as + // older pages load — and used to return `Date.now()`, a different key on every render. + const message = { created_at: 0 } as LocalMessage; + + expect(getMessageListItemCacheKey(message, 3)).toBe('0'); + expect(getMessageListItemCacheKey(message, 7)).toBe('0'); + }); + + it('falls back to the index only when there is nothing else', () => { + expect(getMessageListItemCacheKey({} as LocalMessage, 3)).toBe('index-3'); + }); +}); diff --git a/package/src/components/MessageList/__tests__/useMessageDateSeparator.test.ts b/package/src/components/MessageList/__tests__/useMessageDateSeparator.test.ts index 901ea76a8c..62255361ee 100644 --- a/package/src/components/MessageList/__tests__/useMessageDateSeparator.test.ts +++ b/package/src/components/MessageList/__tests__/useMessageDateSeparator.test.ts @@ -1,7 +1,8 @@ import { renderHook } from '@testing-library/react-native'; -import { LocalMessage } from 'stream-chat'; +import { convertTimestampToDate, LocalMessage } from 'stream-chat'; +import { convertDateToTimestamp } from '../../../mock-builders/generator/time'; import { useMessageDateSeparator } from '../hooks/useMessageDateSeparator'; describe('useMessageDateSeparator', () => { @@ -10,17 +11,17 @@ describe('useMessageDateSeparator', () => { beforeEach(() => { messages = [ { - created_at: new Date('2020-01-01T00:00:00.000Z'), + created_at: convertDateToTimestamp('2020-01-01T00:00:00.000Z'), id: '1', text: 'Hello', }, { - created_at: new Date('2020-01-02T00:00:00.000Z'), + created_at: convertDateToTimestamp('2020-01-02T00:00:00.000Z'), id: '2', text: 'World', }, { - created_at: new Date('2020-01-03T00:00:00.000Z'), + created_at: convertDateToTimestamp('2020-01-03T00:00:00.000Z'), id: '3', text: 'Hello World', }, @@ -47,18 +48,18 @@ describe('useMessageDateSeparator', () => { const { result } = renderHook(() => useMessageDateSeparator({ message: messages[1], previousMessage: messages[0] }), ); - expect(result.current).toBe(messages[1].created_at); + expect(result.current).toEqual(convertTimestampToDate(messages[1].created_at)); }); it('should return undefined if the message is the same day as the previous message', () => { const messages = [ { - created_at: new Date('2020-01-01T01:00:00.000Z'), + created_at: convertDateToTimestamp('2020-01-01T01:00:00.000Z'), id: '1', text: 'Hello', }, { - created_at: new Date('2020-01-01T02:00:00.000Z'), + created_at: convertDateToTimestamp('2020-01-01T02:00:00.000Z'), id: '2', text: 'World', }, @@ -66,7 +67,7 @@ describe('useMessageDateSeparator', () => { const { result: resultOfFirstMessage } = renderHook(() => useMessageDateSeparator({ message: messages[0], previousMessage: undefined }), ); - expect(resultOfFirstMessage.current).toBe(messages[0].created_at); + expect(resultOfFirstMessage.current).toEqual(convertTimestampToDate(messages[0].created_at)); const { result: resultOfSecondMessage } = renderHook(() => useMessageDateSeparator({ message: messages[1], previousMessage: messages[0] }), ); diff --git a/package/src/components/MessageList/hooks/useMarkRead.ts b/package/src/components/MessageList/hooks/useMarkRead.ts index 9e5e4f8cc5..5084859db2 100644 --- a/package/src/components/MessageList/hooks/useMarkRead.ts +++ b/package/src/components/MessageList/hooks/useMarkRead.ts @@ -1,5 +1,7 @@ import type { Channel } from 'stream-chat'; +import { nowNs } from 'stream-chat'; + import { useChatContext } from '../../../contexts/chatContext/ChatContext'; import { useStableCallback } from '../../../hooks'; import { MarkReadFunctionOptions } from '../../Channel/Channel'; @@ -69,7 +71,7 @@ export const useMarkRead = (channel: Channel) => { const previous = channel.messagePaginator.unreadStateSnapshot.getLatestValue(); channel.messagePaginator.unreadStateSnapshot.next({ firstUnreadMessageId: null, - lastReadAt: new Date(), + lastReadAt: nowNs(), lastReadMessageId: loadedItems[loadedItems.length - 1]?.id ?? previous.lastReadMessageId, unreadCount: 0, }); diff --git a/package/src/components/MessageList/hooks/useMessageDateSeparator.ts b/package/src/components/MessageList/hooks/useMessageDateSeparator.ts index 32433fa936..9682efa79e 100644 --- a/package/src/components/MessageList/hooks/useMessageDateSeparator.ts +++ b/package/src/components/MessageList/hooks/useMessageDateSeparator.ts @@ -1,6 +1,7 @@ import { useMemo } from 'react'; import { LocalMessage } from 'stream-chat'; +import { nsToDate } from 'stream-chat'; export const getDateSeparatorValue = ({ hideDateSeparators, @@ -15,11 +16,19 @@ export const getDateSeparatorValue = ({ return undefined; } - const previousMessageDate = previousMessage?.created_at.toDateString(); - const messageDate = message?.created_at.toDateString(); + // Nullish rather than truthy: `0` is a legitimate wire timestamp (the epoch), and treating it as + // "no date" would collapse the grouping key and suppress the separator. + const previousMessageDate = + previousMessage?.created_at != null + ? nsToDate(previousMessage.created_at).toDateString() + : undefined; + const messageDate = + message?.created_at != null ? nsToDate(message.created_at).toDateString() : undefined; if (previousMessageDate !== messageDate) { - return message?.created_at; + // A `Date`, because the separator components that render this are presentational and keep their + // `date?: Date` props. Converted once, here, where core data leaves the message. + return message?.created_at != null ? nsToDate(message.created_at) : undefined; } return undefined; diff --git a/package/src/components/MessageList/utils/buildMessageListWithNeighbours.ts b/package/src/components/MessageList/utils/buildMessageListWithNeighbours.ts index ed5fb7180c..c624cf103b 100644 --- a/package/src/components/MessageList/utils/buildMessageListWithNeighbours.ts +++ b/package/src/components/MessageList/utils/buildMessageListWithNeighbours.ts @@ -6,12 +6,19 @@ export type MessageListItemWithNeighbours = { message: LocalMessage; }; +/** + * The stable identity of a message row, used both as the FlatList/FlashList render key and as the + * neighbour-cache key. Those two must agree: if they diverge for the same message the cache stores + * a row under one key while the list renders it under another, and memoisation silently never hits. + */ export const getMessageListItemCacheKey = (item: LocalMessage, index: number) => { if (item.id) { return item.id; } - if (item.created_at) { - return typeof item.created_at === 'string' ? item.created_at : item.created_at.toISOString(); + // Nullish, not truthy: `created_at` is unix nanoseconds and `0` is a legitimate value (the + // epoch). Treating it as absent falls through to the index, which shifts as pages load. + if (item.created_at != null) { + return String(item.created_at); } return `index-${index}`; }; diff --git a/package/src/components/MessageList/utils/getGroupStyles.ts b/package/src/components/MessageList/utils/getGroupStyles.ts index 9e17250172..c5134a379c 100644 --- a/package/src/components/MessageList/utils/getGroupStyles.ts +++ b/package/src/components/MessageList/utils/getGroupStyles.ts @@ -1,5 +1,7 @@ import { LocalMessage } from 'stream-chat'; +import { nsToMs } from 'stream-chat'; + import { isEditedMessage } from '../../../utils/utils'; export type MessageGroupStylesParams = { @@ -44,8 +46,7 @@ export const getGroupStyle = ({ userId !== nextMessage?.user?.id || nextMessageDateSeparatorDate || (maxTimeBetweenGroupedMessages !== undefined && - (nextMessage.created_at as Date).getTime() - (message.created_at as Date).getTime() > - maxTimeBetweenGroupedMessages) || + nsToMs(nextMessage.created_at - message.created_at) > maxTimeBetweenGroupedMessages) || isEditedMessage(message); /** diff --git a/package/src/components/Poll/components/PollAnswersList.tsx b/package/src/components/Poll/components/PollAnswersList.tsx index a41d8573fd..3f77e67b0a 100644 --- a/package/src/components/Poll/components/PollAnswersList.tsx +++ b/package/src/components/Poll/components/PollAnswersList.tsx @@ -1,7 +1,7 @@ import React, { useCallback, useMemo, useState } from 'react'; import { FlatList, type FlatListProps, StyleSheet, Text, View } from 'react-native'; -import { PollVoteResponseData } from 'stream-chat'; +import { convertTimestampToDate, PollVoteResponseData } from 'stream-chat'; import { PollButtonProps } from './Button'; import { PollInputDialog } from './PollInputDialog'; @@ -88,7 +88,7 @@ export const PollAnswerListItem = ({ answer }: { answer: PollVoteResponseData }) const dateString = useMemo( () => getDateString({ - messageCreatedAt: answer.updated_at, + messageCreatedAt: convertTimestampToDate(answer.updated_at), t, tDateTimeParser, timestampTranslationKey: 'timestamp.PollVote', diff --git a/package/src/components/Poll/components/PollResults/PollVote.tsx b/package/src/components/Poll/components/PollResults/PollVote.tsx index 6ca625587e..57f8af108b 100644 --- a/package/src/components/Poll/components/PollResults/PollVote.tsx +++ b/package/src/components/Poll/components/PollResults/PollVote.tsx @@ -2,7 +2,7 @@ import React, { useMemo } from 'react'; import { StyleSheet, Text, View } from 'react-native'; -import { PollVoteResponseData as PollVoteClass } from 'stream-chat'; +import { convertTimestampToDate, PollVoteResponseData as PollVoteClass } from 'stream-chat'; import { useTheme, useTranslationContext } from '../../../../contexts'; import { getDateString } from '../../../../i18n/utils'; @@ -28,7 +28,7 @@ export const PollVote = ({ vote }: { vote: PollVoteClass }) => { const dateString = useMemo( () => getDateString({ - messageCreatedAt: vote.created_at, + messageCreatedAt: convertTimestampToDate(vote.created_at), t, tDateTimeParser, timestampTranslationKey: 'timestamp.PollVote', diff --git a/package/src/components/Thread/__tests__/__snapshots__/Thread.test.tsx.snap b/package/src/components/Thread/__tests__/__snapshots__/Thread.test.tsx.snap index 0ec8b8c379..0ac947ce44 100644 --- a/package/src/components/Thread/__tests__/__snapshots__/Thread.test.tsx.snap +++ b/package/src/components/Thread/__tests__/__snapshots__/Thread.test.tsx.snap @@ -47,12 +47,12 @@ exports[`Thread should match thread snapshot 1`] = ` "message": { "attachments": [], "cid": "messaging:test-channel", - "created_at": 2020-05-05T14:50:00.000Z, + "created_at": 1588690200000000000, "deleted_at": undefined, "error": undefined, "html": "

regular

", "id": "38ef6f7c-3090-5759-a37f-ab0053aadb96", - "message_text_updated_at": 2020-05-05T14:50:00.000Z, + "message_text_updated_at": 1588690200000000000, "parent_id": "b4612a73-fa2b-5787-bf71-1adc8f291a04", "pinned_at": undefined, "quoted_message": undefined, @@ -60,28 +60,28 @@ exports[`Thread should match thread snapshot 1`] = ` "status": "received", "text": "Message6", "type": "regular", - "updated_at": 2020-05-05T14:50:00.000Z, + "updated_at": 1588690200000000000, "user": { "banned": false, - "created_at": 2020-04-27T13:39:49.331Z, + "created_at": 1587994789331000000, "id": "arthur", "image": "https://i.imgur.com/LuuGvh0.png", "name": "Arthur", "online": false, "role": "user", - "updated_at": 2020-04-27T13:39:49.332Z, + "updated_at": 1587994789332000000, }, }, "nextMessage": undefined, "previousMessage": { "attachments": [], "cid": "messaging:test-channel", - "created_at": 2020-05-05T14:50:00.000Z, + "created_at": 1588690200000000000, "deleted_at": undefined, "error": undefined, "html": "

regular

", "id": "516efa25-5d29-5c9a-ad2d-4cc183e785bd", - "message_text_updated_at": 2020-05-05T14:50:00.000Z, + "message_text_updated_at": 1588690200000000000, "parent_id": "b4612a73-fa2b-5787-bf71-1adc8f291a04", "pinned_at": undefined, "quoted_message": undefined, @@ -89,16 +89,16 @@ exports[`Thread should match thread snapshot 1`] = ` "status": "received", "text": "Message5", "type": "regular", - "updated_at": 2020-05-05T14:50:00.000Z, + "updated_at": 1588690200000000000, "user": { "banned": false, - "created_at": 2020-04-27T13:39:49.331Z, + "created_at": 1587994789331000000, "id": "finn", "image": "https://i.imgur.com/spueyAP.png", "name": "Finn", "online": false, "role": "user", - "updated_at": 2020-04-27T13:39:49.332Z, + "updated_at": 1587994789332000000, }, }, }, @@ -106,12 +106,12 @@ exports[`Thread should match thread snapshot 1`] = ` "message": { "attachments": [], "cid": "messaging:test-channel", - "created_at": 2020-05-05T14:50:00.000Z, + "created_at": 1588690200000000000, "deleted_at": undefined, "error": undefined, "html": "

regular

", "id": "516efa25-5d29-5c9a-ad2d-4cc183e785bd", - "message_text_updated_at": 2020-05-05T14:50:00.000Z, + "message_text_updated_at": 1588690200000000000, "parent_id": "b4612a73-fa2b-5787-bf71-1adc8f291a04", "pinned_at": undefined, "quoted_message": undefined, @@ -119,27 +119,27 @@ exports[`Thread should match thread snapshot 1`] = ` "status": "received", "text": "Message5", "type": "regular", - "updated_at": 2020-05-05T14:50:00.000Z, + "updated_at": 1588690200000000000, "user": { "banned": false, - "created_at": 2020-04-27T13:39:49.331Z, + "created_at": 1587994789331000000, "id": "finn", "image": "https://i.imgur.com/spueyAP.png", "name": "Finn", "online": false, "role": "user", - "updated_at": 2020-04-27T13:39:49.332Z, + "updated_at": 1587994789332000000, }, }, "nextMessage": { "attachments": [], "cid": "messaging:test-channel", - "created_at": 2020-05-05T14:50:00.000Z, + "created_at": 1588690200000000000, "deleted_at": undefined, "error": undefined, "html": "

regular

", "id": "38ef6f7c-3090-5759-a37f-ab0053aadb96", - "message_text_updated_at": 2020-05-05T14:50:00.000Z, + "message_text_updated_at": 1588690200000000000, "parent_id": "b4612a73-fa2b-5787-bf71-1adc8f291a04", "pinned_at": undefined, "quoted_message": undefined, @@ -147,27 +147,27 @@ exports[`Thread should match thread snapshot 1`] = ` "status": "received", "text": "Message6", "type": "regular", - "updated_at": 2020-05-05T14:50:00.000Z, + "updated_at": 1588690200000000000, "user": { "banned": false, - "created_at": 2020-04-27T13:39:49.331Z, + "created_at": 1587994789331000000, "id": "arthur", "image": "https://i.imgur.com/LuuGvh0.png", "name": "Arthur", "online": false, "role": "user", - "updated_at": 2020-04-27T13:39:49.332Z, + "updated_at": 1587994789332000000, }, }, "previousMessage": { "attachments": [], "cid": "messaging:test-channel", - "created_at": 2020-05-05T14:50:00.000Z, + "created_at": 1588690200000000000, "deleted_at": undefined, "error": undefined, "html": "

regular

", "id": "82a83b16-b611-527c-b3ac-765ef6220490", - "message_text_updated_at": 2020-05-05T14:50:00.000Z, + "message_text_updated_at": 1588690200000000000, "parent_id": "b4612a73-fa2b-5787-bf71-1adc8f291a04", "pinned_at": undefined, "quoted_message": undefined, @@ -175,16 +175,16 @@ exports[`Thread should match thread snapshot 1`] = ` "status": "received", "text": "Message4", "type": "regular", - "updated_at": 2020-05-05T14:50:00.000Z, + "updated_at": 1588690200000000000, "user": { "banned": false, - "created_at": 2020-04-27T13:39:49.331Z, + "created_at": 1587994789331000000, "id": "arthur", "image": "https://i.imgur.com/LuuGvh0.png", "name": "Arthur", "online": false, "role": "user", - "updated_at": 2020-04-27T13:39:49.332Z, + "updated_at": 1587994789332000000, }, }, }, @@ -192,12 +192,12 @@ exports[`Thread should match thread snapshot 1`] = ` "message": { "attachments": [], "cid": "messaging:test-channel", - "created_at": 2020-05-05T14:50:00.000Z, + "created_at": 1588690200000000000, "deleted_at": undefined, "error": undefined, "html": "

regular

", "id": "82a83b16-b611-527c-b3ac-765ef6220490", - "message_text_updated_at": 2020-05-05T14:50:00.000Z, + "message_text_updated_at": 1588690200000000000, "parent_id": "b4612a73-fa2b-5787-bf71-1adc8f291a04", "pinned_at": undefined, "quoted_message": undefined, @@ -205,27 +205,27 @@ exports[`Thread should match thread snapshot 1`] = ` "status": "received", "text": "Message4", "type": "regular", - "updated_at": 2020-05-05T14:50:00.000Z, + "updated_at": 1588690200000000000, "user": { "banned": false, - "created_at": 2020-04-27T13:39:49.331Z, + "created_at": 1587994789331000000, "id": "arthur", "image": "https://i.imgur.com/LuuGvh0.png", "name": "Arthur", "online": false, "role": "user", - "updated_at": 2020-04-27T13:39:49.332Z, + "updated_at": 1587994789332000000, }, }, "nextMessage": { "attachments": [], "cid": "messaging:test-channel", - "created_at": 2020-05-05T14:50:00.000Z, + "created_at": 1588690200000000000, "deleted_at": undefined, "error": undefined, "html": "

regular

", "id": "516efa25-5d29-5c9a-ad2d-4cc183e785bd", - "message_text_updated_at": 2020-05-05T14:50:00.000Z, + "message_text_updated_at": 1588690200000000000, "parent_id": "b4612a73-fa2b-5787-bf71-1adc8f291a04", "pinned_at": undefined, "quoted_message": undefined, @@ -233,16 +233,16 @@ exports[`Thread should match thread snapshot 1`] = ` "status": "received", "text": "Message5", "type": "regular", - "updated_at": 2020-05-05T14:50:00.000Z, + "updated_at": 1588690200000000000, "user": { "banned": false, - "created_at": 2020-04-27T13:39:49.331Z, + "created_at": 1587994789331000000, "id": "finn", "image": "https://i.imgur.com/spueyAP.png", "name": "Finn", "online": false, "role": "user", - "updated_at": 2020-04-27T13:39:49.332Z, + "updated_at": 1587994789332000000, }, }, "previousMessage": undefined, diff --git a/package/src/components/ThreadList/ThreadListItem.tsx b/package/src/components/ThreadList/ThreadListItem.tsx index 97135486dd..7fc51724c5 100644 --- a/package/src/components/ThreadList/ThreadListItem.tsx +++ b/package/src/components/ThreadList/ThreadListItem.tsx @@ -3,6 +3,7 @@ import { Pressable, StyleSheet, Text, View } from 'react-native'; import { AttachmentManagerState, + convertTimestampToDate, DraftMessage, LocalMessage, TextComposerState, @@ -202,7 +203,7 @@ export const ThreadListItem = (props: ThreadListItemProps) => { const dateString = useMemo( () => getDateString({ - messageCreatedAt: timestamp, + messageCreatedAt: convertTimestampToDate(timestamp), t, tDateTimeParser, timestampTranslationKey, @@ -212,7 +213,7 @@ export const ThreadListItem = (props: ThreadListItemProps) => { const deletedAtDateString = useMemo( () => getDateString({ - messageCreatedAt: deletedAt as Date | undefined, + messageCreatedAt: convertTimestampToDate(deletedAt), t, tDateTimeParser, timestampTranslationKey, diff --git a/package/src/contexts/liveLocationManagerContext/hooks/useHandleLiveLocationEvents.ts b/package/src/contexts/liveLocationManagerContext/hooks/useHandleLiveLocationEvents.ts index 1762ce52a0..8c82734884 100644 --- a/package/src/contexts/liveLocationManagerContext/hooks/useHandleLiveLocationEvents.ts +++ b/package/src/contexts/liveLocationManagerContext/hooks/useHandleLiveLocationEvents.ts @@ -2,6 +2,8 @@ import { useEffect, useState } from 'react'; import { Channel, EventPayload, SharedLocationResponse } from 'stream-chat'; +import { nowNs } from 'stream-chat'; + import { useChatContext } from '../../../contexts/chatContext/ChatContext'; export type UseLiveLocationsEventsParams = { @@ -45,7 +47,7 @@ export const useHandleLiveLocationEvents = ({ setLocationResponse(message.shared_location as SharedLocationResponse); onLocationUpdate?.(message.shared_location as SharedLocationResponse); } - if (shared_location.end_at && shared_location.end_at <= new Date()) { + if (shared_location.end_at != null && shared_location.end_at <= nowNs()) { setIsLiveLocationStopped(true); } }; diff --git a/package/src/hooks/actions/useChannelActionItems.tsx b/package/src/hooks/actions/useChannelActionItems.tsx index 8c20837ce0..c97d746cd8 100644 --- a/package/src/hooks/actions/useChannelActionItems.tsx +++ b/package/src/hooks/actions/useChannelActionItems.tsx @@ -353,8 +353,8 @@ export const useChannelActionItems = ({ const membership = useChannelMembershipState(channel); const channelActions = useChannelActions(channel); const isDirectChat = useIsDirectChat(channel); - const isPinned = Boolean(membership?.pinned_at); - const isArchived = Boolean(membership?.archived_at); + const isPinned = membership?.pinned_at != null; + const isArchived = membership?.archived_at != null; const { muted: channelMuteActive } = useIsChannelMuted(channel); const otherUser = isDirectChat ? getOtherUserInDirectChannel(channel)?.user : undefined; diff --git a/package/src/hooks/messagePreview/useMessageDeliveryStatus.ts b/package/src/hooks/messagePreview/useMessageDeliveryStatus.ts index 5ede497116..a05e571b3c 100644 --- a/package/src/hooks/messagePreview/useMessageDeliveryStatus.ts +++ b/package/src/hooks/messagePreview/useMessageDeliveryStatus.ts @@ -64,7 +64,7 @@ export const useMessageDeliveryStatus = ({ const currentUserId = client.user?.id; const isOwnMessage = !!currentUserId && lastMessage?.user?.id === currentUserId; - if (!lastMessage?.created_at || !isOwnMessage) { + if (lastMessage?.created_at == null || !isOwnMessage) { return undefined; } diff --git a/package/src/hooks/messagePreview/useMessagePreviewText.tsx b/package/src/hooks/messagePreview/useMessagePreviewText.tsx index f2ea5b0ca6..a3c9b88cfb 100644 --- a/package/src/hooks/messagePreview/useMessagePreviewText.tsx +++ b/package/src/hooks/messagePreview/useMessagePreviewText.tsx @@ -1,12 +1,14 @@ import dayjs from 'dayjs'; import { DraftMessage, - SharedLocation, + SharedLocationResponseData, LocalMessage, MessageResponse, PollState, } from 'stream-chat'; +import { nowNs } from 'stream-chat'; + import { useGroupedAttachments } from './useGroupedAttachments'; import { useChatContext } from '../../contexts/chatContext/ChatContext'; @@ -46,10 +48,12 @@ export const useMessagePreviewText = ({ } if (message?.shared_location) { - // Draft messages type `shared_location` loosely, hence the cast. `end_at` is optional - // because a static location has no expiry — only a live one does. - const { end_at: endAt } = message.shared_location as SharedLocation; - if (endAt && new Date(endAt) > new Date()) { + // Draft messages type `shared_location` loosely, hence the cast. The RESPONSE shape, not the + // request one: the value here came off a message, so `end_at` is the wire number rather than + // the `Date` an outgoing request would carry. `end_at` is optional because a static location + // has no expiry — only a live one does. + const { end_at: endAt } = message.shared_location as SharedLocationResponseData; + if (endAt != null && endAt > nowNs()) { return t('messagePreview.liveLocation.label', 'Live Location'); } return t('messagePreview.location.label', 'Location'); diff --git a/package/src/hooks/useQueryReminders.ts b/package/src/hooks/useQueryReminders.ts index 0a96e7f4c1..41ba523268 100644 --- a/package/src/hooks/useQueryReminders.ts +++ b/package/src/hooks/useQueryReminders.ts @@ -2,6 +2,8 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { EventPayload, PaginatorState, ReminderFilters, ReminderResponseData } from 'stream-chat'; +import { nowNs } from 'stream-chat'; + import { useStateStore } from './useStateStore'; import { useChatContext } from '../contexts/chatContext/ChatContext'; @@ -15,21 +17,21 @@ const selector = (nextValue: PaginatorState) => // Utility to sort reminders by remind_at date in ascending order const sortRemindersByDate = (reminders: ReminderResponseData[]) => { return reminders.sort((a, b) => { - if (!a.remind_at || !b.remind_at) { + if (a.remind_at == null || b.remind_at == null) { return 0; // If either remind_at is missing, keep original order } // Sort by remind_at date - return new Date(a.remind_at).getTime() - new Date(b.remind_at).getTime(); + return a.remind_at - b.remind_at; }); }; // Utility functions to check reminder status const isReminderOverdue = (reminder?: ReminderResponseData) => { - return reminder?.remind_at && new Date(reminder.remind_at) < new Date(); + return reminder?.remind_at != null && reminder.remind_at < nowNs(); }; const isReminderUpcoming = (reminder?: ReminderResponseData) => { - return reminder?.remind_at && new Date(reminder.remind_at) > new Date(); + return reminder?.remind_at != null && reminder.remind_at > nowNs(); }; // Utility to check if all reminders should be shown based on filters @@ -104,10 +106,10 @@ export const useQueryReminders = () => { return prevData; // No update needed if reminder not found } - if (existingReminder.remind_at && !event.reminder?.remind_at) { + if (existingReminder.remind_at != null && event.reminder?.remind_at == null) { return prevData.filter((item) => item.message_id !== event.reminder?.message_id); } - if (!existingReminder.remind_at && event.reminder?.remind_at) { + if (existingReminder.remind_at == null && event.reminder?.remind_at != null) { return prevData.filter((item) => item.message_id !== event.reminder?.message_id); } if (isReminderOverdue(existingReminder) && !isReminderOverdue(event.reminder)) { diff --git a/package/src/i18n/__tests__/dateNormalization.test.ts b/package/src/i18n/__tests__/dateNormalization.test.ts index f845e51ee0..ddbb5e3174 100644 --- a/package/src/i18n/__tests__/dateNormalization.test.ts +++ b/package/src/i18n/__tests__/dateNormalization.test.ts @@ -1,21 +1,23 @@ +import { convertTimestampToDate } from 'stream-chat'; + import { Streami18n } from '../../utils/i18n/Streami18n'; import { getCalendarDateStringForA11y, getDateString } from '../utils'; /** - * Timestamps that reach the UI in a shape core's formatters cannot handle. + * Where a wire timestamp becomes something renderable. * - * The API expresses timestamps as nanoseconds since the epoch, and `stream-chat` converts them for - * the fields its response decoders name. `PollResponseData`'s decoder names `latest_answers` and - * `own_votes` but **not** `latest_votes_by_option`, so a poll vote's `created_at` arrives as a raw - * integer near 1e18 — measured on a device as `1787870023772367000`. That is past the largest value - * `new Date` accepts, so Day.js builds an invalid instance and `format()` renders the literal string - * `"Invalid Date"`, which is what the poll results screen showed next to the voter's name. + * The API expresses every server-sent timestamp as nanoseconds since the epoch — measured on a + * device as `1787870023772367000`. That is past the largest value `new Date` accepts, so handing one + * straight to a date library builds an invalid instance and `format()` renders the literal string + * `"Invalid Date"`, which is what the poll results screen once showed next to a voter's name. * - * Every date the SDK renders goes through these wrappers, so the guard belongs here rather than at - * any one of the ~14 call sites: the shape a timestamp arrived in is not something a call site can - * see. + * These formatters used to rescale such a value themselves, guessing from its magnitude. They no + * longer do: each of the ~14 call sites converts with `convertTimestampToDate` where core data enters + * the tree. This suite pins both halves of that contract — the conversion renders the right + * instant, and an *unconverted* value renders nothing rather than a plausible-looking wrong date, + * so a missed conversion shows up as a blank instead of hiding. */ -describe('date normalization', () => { +describe('wire timestamps at the i18n boundary', () => { let t: Awaited>['t']; let tDateTimeParser: Awaited>['tDateTimeParser']; @@ -25,52 +27,53 @@ describe('date normalization', () => { const render = (messageCreatedAt: unknown, timestampTranslationKey: string) => getDateString({ - // The declared type is `string | Date`; the whole point is that reality is wider. messageCreatedAt: messageCreatedAt as string | Date, t, tDateTimeParser, timestampTranslationKey, }); - it('renders a nanosecond timestamp as the instant it represents', () => { + it('renders the instant a converted wire timestamp represents', () => { const instant = Date.UTC(2026, 7, 20, 12, 0, 0); const nanoseconds = instant * 1e6; - // `timestamp.MessageTimestamp` formats as `LT`, so the assertion pins the actual instant rather - // than a relative word that depends on the clock. - expect(render(nanoseconds, 'timestamp.MessageTimestamp')).toBe( - render(new Date(instant), 'timestamp.MessageTimestamp'), + // `timestamp.MessageTimestamp` formats as `LT`, so this pins the actual instant rather than a + // relative word that depends on the clock. + expect(render(convertTimestampToDate(nanoseconds), 'timestamp.MessageTimestamp')).toBe( + '12:00 PM', ); - expect(render(nanoseconds, 'timestamp.MessageTimestamp')).toBe('12:00 PM'); }); - it('is the regression case measured on device', () => { - // The exact value the poll results screen rendered as "Invalid Date". - expect(render(1787870023772367000, 'timestamp.PollVote')).not.toMatch(/Invalid Date/); - expect(render(1787870023772367000, 'timestamp.MessageTimestamp')).toBe( - render(new Date(1787870023772367000 / 1e6), 'timestamp.MessageTimestamp'), + it('converts the value measured on device', () => { + const nanoseconds = 1787870023772367000; + + expect(render(convertTimestampToDate(nanoseconds), 'timestamp.PollVote')).not.toMatch( + /Invalid Date/, + ); + expect(render(convertTimestampToDate(nanoseconds), 'timestamp.MessageTimestamp')).toBe( + render(new Date(nanoseconds / 1e6), 'timestamp.MessageTimestamp'), ); }); - it('leaves a millisecond timestamp alone', () => { - // In range, so not rescaled — an integrator passing epoch millis through the public - // `getDateString` must keep working. - const instant = Date.UTC(2026, 7, 20, 12, 0, 0); - expect(render(instant, 'timestamp.MessageTimestamp')).toBe('12:00 PM'); + it('renders nothing for a wire timestamp that was never converted', () => { + // The formatters no longer rescale by magnitude, and a nanosecond value is out of `Date`'s + // range, so there is no instant to show. `null` is what every caller already treats as "omit + // the element", so a missed conversion surfaces as a blank timestamp. + expect(render(1787870023772367000, 'timestamp.MessageTimestamp')).toBeNull(); }); - it('renders nothing rather than the words "Invalid Date"', () => { - // Out of range even after rescaling (anything past 8.64e15 nanoseconds-worth), so there is no - // instant to show. `null` is what every caller already treats as "omit the element". - expect(render(1e22, 'timestamp.MessageTimestamp')).toBeNull(); - // And the output guard catches an already-invalid Date, whatever produced it. + it('declines a value that cannot be converted at all', () => { + expect(convertTimestampToDate(Number.NaN)).toBeUndefined(); + expect(convertTimestampToDate(undefined)).toBeUndefined(); + expect(convertTimestampToDate(null)).toBeUndefined(); + // And the output guard still catches an already-invalid Date, whatever produced it. expect(render(new Date('nonsense'), 'timestamp.MessageTimestamp')).toBeNull(); }); - it('normalizes the accessibility date the same way', () => { + it('converts the accessibility date the same way', () => { const instant = Date.UTC(2026, 7, 20, 12, 0, 0); const spoken = getCalendarDateStringForA11y({ - messageCreatedAt: (instant * 1e6) as unknown as Date, + messageCreatedAt: convertTimestampToDate(instant * 1e6), tDateTimeParser, }); diff --git a/package/src/i18n/utils.ts b/package/src/i18n/utils.ts index 86eaf2251a..e1be56beaa 100644 --- a/package/src/i18n/utils.ts +++ b/package/src/i18n/utils.ts @@ -35,32 +35,6 @@ export { predefinedFormatters, } from 'stream-chat/i18n'; -/** - * The largest value `new Date(ms)` accepts before it clips to an invalid instance. - * ECMA-262 `TimeClip`, 8.64e15 ms — about ±273,790 years. - */ -const MAX_TIME_VALUE = 8.64e15; - -/** - * Rescales a timestamp that arrived in nanoseconds. - * - * The API expresses timestamps as nanoseconds since the epoch and `stream-chat` converts them on the - * way in — but only for the fields its response decoders name. `latest_votes_by_option` is not one of - * them (unlike `latest_answers` and `own_votes` beside it), so a poll vote's `created_at` reaches the - * UI as a raw integer around 1e18. That is past `MAX_TIME_VALUE`, so Day.js builds an invalid instance - * and `format()` renders the literal string `"Invalid Date"` next to the voter's name. - * - * Only out-of-range numbers are touched, so a millisecond timestamp an integrator passes through the - * public `getDateString` keeps working. The `1e6` divisor is the conversion core's own `DatetimeType` - * decoder applies, so a rescaled value lands on the same instant core would have produced. - */ -const normalizeTimestamp = (value: T): T | Date | undefined => { - if (typeof value !== 'number' || Math.abs(value) <= MAX_TIME_VALUE) return value; - - const milliseconds = Math.floor(value / 1e6); - return Math.abs(milliseconds) <= MAX_TIME_VALUE ? new Date(milliseconds) : undefined; -}; - /** * `null` means "nothing renderable", which every caller already handles by omitting the element. * `"Invalid Date"` is what Day.js formats an unparseable instance into, and it reaches the screen as @@ -70,30 +44,26 @@ const withoutInvalidDate = (result: T) => typeof result === 'string' && result.includes('Invalid Date') ? null : result; /** - * This SDK's date formatters: core's, with the timestamp normalized on the way in and an invalid - * result suppressed on the way out. Wrapped rather than fixed at the ~14 call sites, because the - * shape a timestamp arrives in is not something a call site can see. + * This SDK's date formatters: core's, with an invalid result suppressed on the way out. + * + * These used to also rescale a nanosecond timestamp on the way in, because `latest_votes_by_option` + * reached the UI as a raw integer core's decoders did not convert. That rescale is gone: every + * server-sent timestamp is now a nanosecond number by contract, and each call site converts with + * `convertTimestampToDate` where core data enters the tree. Guessing from the magnitude here would + * hide a missed conversion instead of surfacing it. */ export const getDateString: typeof coreGetDateString = ({ messageCreatedAt, ...rest }) => - withoutInvalidDate( - coreGetDateString({ ...rest, messageCreatedAt: normalizeTimestamp(messageCreatedAt) }), - ); + withoutInvalidDate(coreGetDateString({ ...rest, messageCreatedAt })); export const getDateStringForA11y: typeof coreGetDateStringForA11y = ({ messageCreatedAt, ...rest -}) => - withoutInvalidDate( - coreGetDateStringForA11y({ ...rest, messageCreatedAt: normalizeTimestamp(messageCreatedAt) }), - ); +}) => withoutInvalidDate(coreGetDateStringForA11y({ ...rest, messageCreatedAt })); export const getCalendarDateStringForA11y: typeof coreGetCalendarDateStringForA11y = ({ messageCreatedAt, ...rest }) => { - const result = coreGetCalendarDateStringForA11y({ - ...rest, - messageCreatedAt: normalizeTimestamp(messageCreatedAt), - }); + const result = coreGetCalendarDateStringForA11y({ ...rest, messageCreatedAt }); return withoutInvalidDate(result) ?? undefined; }; diff --git a/package/src/mock-builders/api/channelMocks.tsx b/package/src/mock-builders/api/channelMocks.tsx index 611da7ae64..5939caa73a 100644 --- a/package/src/mock-builders/api/channelMocks.tsx +++ b/package/src/mock-builders/api/channelMocks.tsx @@ -6,6 +6,7 @@ import { GROUP_CHANNEL_MEMBERS_MOCK, ONE_MEMBER_WITH_EMPTY_USER, } from '../../mock-builders/api/queryMembers'; +import { convertDateToTimestamp } from '../generator/time'; // Test fixtures intentionally supply runtime-shaped values (Date objects for // date fields, custom `type` strings, a mock `Channel` instance for the @@ -32,8 +33,8 @@ const CHANNEL_WITH_MESSAGES_TEXT = { cid: 'stridkncnng', command: 'giphy', command_info: { name: 'string' }, - created_at: new Date('2021-02-12T12:12:35.862Z'), - deleted_at: new Date('2021-02-12T12:12:35.862Z'), + created_at: convertDateToTimestamp('2021-02-12T12:12:35.862Z'), + deleted_at: convertDateToTimestamp('2021-02-12T12:12:35.862Z'), id: 'ljkblk', text: 'jkbkbiubicbi', type: 'regular', @@ -46,8 +47,8 @@ const CHANNEL_WITH_MESSAGES_TEXT = { cid: 'stridodong', command: 'giphy', command_info: { name: 'string' }, - created_at: new Date('2021-02-12T12:12:35.862Z'), - deleted_at: new Date('2021-02-12T12:12:35.862Z'), + created_at: convertDateToTimestamp('2021-02-12T12:12:35.862Z'), + deleted_at: convertDateToTimestamp('2021-02-12T12:12:35.862Z'), id: 'jbkjb', text: 'jkbkbiubicbi', type: 'regular', @@ -75,8 +76,8 @@ const CHANNEL_WITH_MESSAGE_COMMAND = { cid: 'stridkncnng', command: 'giphy', command_info: { name: 'string' }, - created_at: new Date('2021-02-12T12:12:35.862Z'), - deleted_at: new Date('2021-02-12T12:12:35.862Z'), + created_at: convertDateToTimestamp('2021-02-12T12:12:35.862Z'), + deleted_at: convertDateToTimestamp('2021-02-12T12:12:35.862Z'), id: 'ljkblk', user: mockUser({ id: 'okechukwu' }), }), @@ -87,8 +88,8 @@ const CHANNEL_WITH_MESSAGE_COMMAND = { cid: 'stridodong', command: 'giphy', command_info: { name: 'string' }, - created_at: new Date('2021-02-12T12:12:35.862Z'), - deleted_at: new Date('2021-02-12T12:12:35.862Z'), + created_at: convertDateToTimestamp('2021-02-12T12:12:35.862Z'), + deleted_at: convertDateToTimestamp('2021-02-12T12:12:35.862Z'), id: 'jbkjb', user: mockUser({ id: 'okechukwu' }), }), @@ -129,8 +130,8 @@ const CHANNEL_WITH_MESSAGES_ATTACHMENTS = { } as Attachment, ], channel: CHANNEL, - created_at: new Date('2021-02-12T12:12:35.862Z'), - deleted_at: new Date('2021-02-12T12:12:35.862Z'), + created_at: convertDateToTimestamp('2021-02-12T12:12:35.862Z'), + deleted_at: convertDateToTimestamp('2021-02-12T12:12:35.862Z'), id: 'ljkblk', user: mockUser({ id: 'okechukwu' }), }), @@ -145,8 +146,8 @@ const LATEST_MESSAGE = mockMessage({ cid: 'string', command: 'giphy', command_info: { name: 'string' }, - created_at: new Date('2021-02-12T12:12:35.862Z'), - deleted_at: new Date('2021-02-12T12:12:35.862Z'), + created_at: convertDateToTimestamp('2021-02-12T12:12:35.862Z'), + deleted_at: convertDateToTimestamp('2021-02-12T12:12:35.862Z'), id: 'string', text: 'jkbkbiubicbi', type: 'regular', @@ -154,13 +155,13 @@ const LATEST_MESSAGE = mockMessage({ }); const FORMATTED_MESSAGE = fromPartial({ - created_at: new Date('2021-02-12T12:12:35.862282Z'), + created_at: convertDateToTimestamp('2021-02-12T12:12:35.862282Z'), deleted_at: undefined, id: '', - pinned_at: new Date('2021-02-12T12:12:35.862282Z'), + pinned_at: convertDateToTimestamp('2021-02-12T12:12:35.862282Z'), status: 'received', type: 'regular', - updated_at: new Date('2021-02-12T12:12:35.862282Z'), + updated_at: convertDateToTimestamp('2021-02-12T12:12:35.862282Z'), }); const CHANNEL_WITH_MENTIONED_USERS = { @@ -171,8 +172,8 @@ const CHANNEL_WITH_MENTIONED_USERS = { attachments: [], cid: 'stridkncnng', command_info: { name: 'string' }, - created_at: new Date('2021-02-12T12:12:35.862Z'), - deleted_at: new Date('2021-02-12T12:12:35.862Z'), + created_at: convertDateToTimestamp('2021-02-12T12:12:35.862Z'), + deleted_at: convertDateToTimestamp('2021-02-12T12:12:35.862Z'), mentioned_users: [ { id: 'Max', name: 'Max' }, { id: 'Ada', name: 'Ada' }, @@ -185,8 +186,8 @@ const CHANNEL_WITH_MENTIONED_USERS = { attachments: [], cid: 'stridodong', command_info: { name: 'string' }, - created_at: new Date('2021-02-12T12:12:35.862Z'), - deleted_at: new Date('2021-02-12T12:12:35.862Z'), + created_at: convertDateToTimestamp('2021-02-12T12:12:35.862Z'), + deleted_at: convertDateToTimestamp('2021-02-12T12:12:35.862Z'), mentioned_users: [ { id: 'Max', name: 'Max' }, { id: 'Ada', name: 'Ada' }, @@ -205,8 +206,8 @@ const CHANNEL_WITH_EMPTY_MESSAGE = { attachments: [], cid: 'stridkncnng', command_info: { name: 'string' }, - created_at: new Date('2021-02-12T12:12:35.862Z'), - deleted_at: new Date('2021-02-12T12:12:35.862Z'), + created_at: convertDateToTimestamp('2021-02-12T12:12:35.862Z'), + deleted_at: convertDateToTimestamp('2021-02-12T12:12:35.862Z'), mentioned_users: [ { id: 'Max', name: 'Max' }, { id: 'Ada', name: 'Ada' }, @@ -218,8 +219,8 @@ const CHANNEL_WITH_EMPTY_MESSAGE = { attachments: [], cid: 'stridodong', command_info: { name: 'string' }, - created_at: new Date('2021-02-12T12:12:35.862Z'), - deleted_at: new Date('2021-02-12T12:12:35.862Z'), + created_at: convertDateToTimestamp('2021-02-12T12:12:35.862Z'), + deleted_at: convertDateToTimestamp('2021-02-12T12:12:35.862Z'), mentioned_users: [ { id: 'Max', name: 'Max' }, { id: 'Ada', name: 'Ada' }, diff --git a/package/src/mock-builders/api/queryMembers.ts b/package/src/mock-builders/api/queryMembers.ts index 2afa093220..925cdd6ca8 100644 --- a/package/src/mock-builders/api/queryMembers.ts +++ b/package/src/mock-builders/api/queryMembers.ts @@ -3,6 +3,8 @@ import type { ChannelMemberResponse } from 'stream-chat'; import { mockedApiResponse, type MockedApiResponse } from './utils'; +import { convertDateToTimestamp } from '../generator/time'; + /** * Returns the api response for queryMembers api * @@ -20,10 +22,10 @@ export const CHANNEL_MEMBERS: ChannelMemberResponse[] = [ fromPartial({ banned: false, channel_role: 'channel_member', - created_at: '2021-01-27T11:54:34.173125Z', + created_at: convertDateToTimestamp('2021-01-27T11:54:34.173125Z'), role: 'member', shadow_banned: false, - updated_at: '2021-02-12T12:12:35.862282Z', + updated_at: convertDateToTimestamp('2021-02-12T12:12:35.862282Z'), user: { id: 'ben', name: 'ben', @@ -33,10 +35,10 @@ export const CHANNEL_MEMBERS: ChannelMemberResponse[] = [ fromPartial({ banned: false, channel_role: 'channel_member', - created_at: '2021-01-27T11:54:34.173125Z', + created_at: convertDateToTimestamp('2021-01-27T11:54:34.173125Z'), role: 'member', shadow_banned: false, - updated_at: '2021-02-12T12:12:35.862282Z', + updated_at: convertDateToTimestamp('2021-02-12T12:12:35.862282Z'), user: { id: 'nick', name: 'nick', @@ -46,10 +48,10 @@ export const CHANNEL_MEMBERS: ChannelMemberResponse[] = [ fromPartial({ banned: false, channel_role: 'channel_member', - created_at: '2021-01-27T11:54:34.173125Z', + created_at: convertDateToTimestamp('2021-01-27T11:54:34.173125Z'), role: 'member', shadow_banned: false, - updated_at: '2021-02-12T12:12:35.862282Z', + updated_at: convertDateToTimestamp('2021-02-12T12:12:35.862282Z'), user: { id: 'okechukwu nwagba', name: 'okechukwu nwagba', @@ -59,10 +61,10 @@ export const CHANNEL_MEMBERS: ChannelMemberResponse[] = [ fromPartial({ banned: false, channel_role: 'channel_member', - created_at: '2021-01-28T09:08:43.274508Z', + created_at: convertDateToTimestamp('2021-01-28T09:08:43.274508Z'), role: 'member', shadow_banned: false, - updated_at: '2021-02-12T12:12:35.862282Z', + updated_at: convertDateToTimestamp('2021-02-12T12:12:35.862282Z'), user: { id: 'qatest1', name: 'qatest1', @@ -73,10 +75,10 @@ export const CHANNEL_MEMBERS: ChannelMemberResponse[] = [ fromPartial({ banned: false, channel_role: 'channel_member', - created_at: '2021-01-27T11:54:34.173125Z', + created_at: convertDateToTimestamp('2021-01-27T11:54:34.173125Z'), role: 'member', shadow_banned: false, - updated_at: '2021-02-12T12:12:35.862282Z', + updated_at: convertDateToTimestamp('2021-02-12T12:12:35.862282Z'), user: { id: 'thierry', name: 'thierry', @@ -89,10 +91,10 @@ export const ONE_CHANNEL_MEMBER: ChannelMemberResponse[] = [ fromPartial({ banned: false, channel_role: 'channel_member', - created_at: '2021-01-27T11:54:34.173125Z', + created_at: convertDateToTimestamp('2021-01-27T11:54:34.173125Z'), role: 'member', shadow_banned: false, - updated_at: '2021-02-12T12:12:35.862282Z', + updated_at: convertDateToTimestamp('2021-02-12T12:12:35.862282Z'), user: { id: 'okechukwu nwagba martin', name: 'okechukwu nwagba martin', @@ -115,10 +117,10 @@ export const ONE_MEMBER_WITH_EMPTY_USER: ChannelMemberResponse[] = [ fromPartial({ banned: false, channel_role: 'channel_member', - created_at: '2021-01-27T11:54:34.173125Z', + created_at: convertDateToTimestamp('2021-01-27T11:54:34.173125Z'), role: 'member', shadow_banned: false, - updated_at: '2021-02-12T12:12:35.862282Z', + updated_at: convertDateToTimestamp('2021-02-12T12:12:35.862282Z'), user: {}, user_id: 'okechukwu nwagba martin', }), diff --git a/package/src/mock-builders/event/messageRead.ts b/package/src/mock-builders/event/messageRead.ts index 7de4293e86..72a57d0033 100644 --- a/package/src/mock-builders/event/messageRead.ts +++ b/package/src/mock-builders/event/messageRead.ts @@ -1,13 +1,15 @@ import { fromPartial } from '@total-typescript/shoehorn'; import type { ChannelResponse, Event, StreamChat, UserResponse } from 'stream-chat'; +import { convertDateToTimestamp } from '../generator/time'; + export default ( client: StreamChat, user: UserResponse, channel: Partial = {}, payload: Partial = {}, ): Event => { - const newDate = new Date() as unknown as string; + const newDate = convertDateToTimestamp(); const event = fromPartial({ channel, cid: channel.cid, diff --git a/package/src/mock-builders/event/notificationMarkUnread.ts b/package/src/mock-builders/event/notificationMarkUnread.ts index 8bf3dd9e17..5a91ee7830 100644 --- a/package/src/mock-builders/event/notificationMarkUnread.ts +++ b/package/src/mock-builders/event/notificationMarkUnread.ts @@ -1,13 +1,15 @@ import { fromPartial } from '@total-typescript/shoehorn'; import type { ChannelResponse, Event, StreamChat, UserResponse } from 'stream-chat'; +import { convertDateToTimestamp } from '../generator/time'; + export default ( client: StreamChat, channel: Partial = {}, payload: Partial = {}, user: Partial = {}, ) => { - const newDate = new Date() as unknown as string; + const newDate = convertDateToTimestamp(); client.dispatchEvent( fromPartial({ channel, diff --git a/package/src/mock-builders/event/notificationMutesUpdated.ts b/package/src/mock-builders/event/notificationMutesUpdated.ts index 23de346bb2..1835b35380 100644 --- a/package/src/mock-builders/event/notificationMutesUpdated.ts +++ b/package/src/mock-builders/event/notificationMutesUpdated.ts @@ -1,10 +1,12 @@ 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/package/src/mock-builders/generator/channel.ts b/package/src/mock-builders/generator/channel.ts index c0605e1747..f1b99547d4 100644 --- a/package/src/mock-builders/generator/channel.ts +++ b/package/src/mock-builders/generator/channel.ts @@ -8,6 +8,7 @@ import type { } from 'stream-chat'; import { v4 as uuidv4 } from 'uuid'; +import { convertDateToTimestamp } from './time'; import { generateUser, getUserDefaults } from './user'; const defaultCapabilities: ChannelOwnCapability[] = [ @@ -41,7 +42,7 @@ const defaultConfig = { }, ], 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, @@ -52,7 +53,7 @@ const defaultConfig = { replies: true, search: 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, }; @@ -85,13 +86,13 @@ const getChannelDefaults = (opts: GeneratedChannelIdType = {}): GeneratedChannel ...defaultConfig, name: type, } as GeneratedChannel['channel']['config'], - created_at: new Date('2020-04-28T11:20:48.578147Z'), + created_at: convertDateToTimestamp('2020-04-28T11:20:48.578147Z'), created_by: getUserDefaults(), frozen: false, id, own_capabilities: defaultCapabilities, type, - updated_at: new Date('2020-04-28T11:20:48.578147Z'), + updated_at: convertDateToTimestamp('2020-04-28T11:20:48.578147Z'), }, cid: `${type}:${id}`, id, diff --git a/package/src/mock-builders/generator/message.ts b/package/src/mock-builders/generator/message.ts index 66846decd1..9396b3665b 100644 --- a/package/src/mock-builders/generator/message.ts +++ b/package/src/mock-builders/generator/message.ts @@ -2,20 +2,25 @@ import { fromPartial } from '@total-typescript/shoehorn'; import type { LocalMessage } from 'stream-chat'; import { v4 as uuidv4, v5 as uuidv5 } from 'uuid'; +import { convertDateToTimestamp } from './time'; import { generateUser } from './user'; -type GenerateMessageOptions = Partial & { timestamp?: Date }; +type GenerateMessageOptions = Partial & { + timestamp?: Date | number | string; +}; -// Returns a `LocalMessage`-shaped mock. Components across this SDK consume -// `LocalMessage` (with `Date` objects for `created_at`/`updated_at`/`pinned_at`/ -// `deleted_at`), so the mock matches that shape. For tests that feed mock data -// into an API response where the server returns `MessageResponse` (strings for -// dates), cast at the call site — runtime values are the same either way. +// Returns a `LocalMessage`-shaped mock. Every timestamp is the unix-nanosecond number the API puts +// on the wire, because that is what the SDK now consumes — a mock carrying `Date` objects cannot +// catch the bugs that unit exists to prevent, and `Partial` makes the compiler say so. +// `timestamp` is the shorthand for seeding the message's own dates from one wall-clock value; for +// any other field, convert at the call site with `convertDateToTimestamp`. export const generateMessage = (options: GenerateMessageOptions = {}): LocalMessage => { - const timestamp = - options.timestamp || new Date(new Date().getTime() - Math.floor(Math.random() * 100000)); + const { timestamp: seed, ...overrides } = options; + const timestamp = convertDateToTimestamp( + seed ?? new Date(Date.now() - Math.floor(Math.random() * 100000)), + ); - return fromPartial({ + const message = fromPartial({ attachments: [], created_at: timestamp, deleted_at: undefined, @@ -28,8 +33,10 @@ export const generateMessage = (options: GenerateMessageOptions = {}): LocalMess type: 'regular', updated_at: timestamp, user: generateUser(), - ...options, + ...overrides, }); + + return message; }; const StreamReactNativeNamespace = '9b244ee4-7d69-4d7b-ae23-cf89e9f7b035'; @@ -38,7 +45,7 @@ export const generateStaticMessage = ( options?: GenerateMessageOptions, date?: string | Date, ): LocalMessage => { - const staticDate = date ? new Date(date) : new Date('2020-04-27T13:39:49.331742Z'); + const staticDate = convertDateToTimestamp(date ?? '2020-04-27T13:39:49.331742Z'); return generateMessage({ created_at: staticDate, id: uuidv5(seed, StreamReactNativeNamespace), diff --git a/package/src/mock-builders/generator/reaction.ts b/package/src/mock-builders/generator/reaction.ts index 3d4b692a4f..b2931d3af6 100644 --- a/package/src/mock-builders/generator/reaction.ts +++ b/package/src/mock-builders/generator/reaction.ts @@ -1,12 +1,13 @@ import { fromPartial } from '@total-typescript/shoehorn'; import type { ReactionResponse } from 'stream-chat'; +import { convertDateToTimestamp } from './time'; import { generateUser } from './user'; export const generateReaction = (options: Partial = {}): ReactionResponse => { const user = options.user || generateUser(); return fromPartial({ - created_at: new Date() as unknown as string, + created_at: convertDateToTimestamp(), type: 'love', user, user_id: user.id, diff --git a/package/src/mock-builders/generator/time.ts b/package/src/mock-builders/generator/time.ts new file mode 100644 index 0000000000..3a65147631 --- /dev/null +++ b/package/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/package/src/mock-builders/generator/user.ts b/package/src/mock-builders/generator/user.ts index e0aec0c55f..b02a466bd2 100644 --- a/package/src/mock-builders/generator/user.ts +++ b/package/src/mock-builders/generator/user.ts @@ -2,16 +2,18 @@ import { fromPartial } from '@total-typescript/shoehorn'; import type { UserResponse } from 'stream-chat'; import { v4 as uuidv4 } from 'uuid'; +import { convertDateToTimestamp } from './time'; + export const getUserDefaults = (): UserResponse => fromPartial({ banned: false, - created_at: '2020-04-27T13:39:49.331742Z', + created_at: convertDateToTimestamp('2020-04-27T13:39:49.331742Z'), id: uuidv4(), image: uuidv4(), name: uuidv4(), online: false, role: 'user', - updated_at: '2020-04-27T13:39:49.332087Z', + updated_at: convertDateToTimestamp('2020-04-27T13:39:49.332087Z'), }); export const generateUser = (options: Partial = {}): UserResponse => diff --git a/package/src/state-store/image-gallery-state-store.ts b/package/src/state-store/image-gallery-state-store.ts index e25146e2e0..d801a85a7e 100644 --- a/package/src/state-store/image-gallery-state-store.ts +++ b/package/src/state-store/image-gallery-state-store.ts @@ -1,6 +1,13 @@ import { makeMutable, SharedValue } from 'react-native-reanimated'; -import { Attachment, LocalMessage, StateStore, Unsubscribe, UserResponse } from 'stream-chat'; +import { + Attachment, + convertTimestampToDate, + LocalMessage, + StateStore, + Unsubscribe, + UserResponse, +} from 'stream-chat'; import { VideoPlayerPool } from './video-player-pool'; @@ -138,7 +145,7 @@ export class ImageGalleryStateStore { return { channelId: message?.cid, - created_at: message?.created_at, + created_at: convertTimestampToDate(message?.created_at), id: assetId, messageId: message?.id, mime_type: attachment.type === 'giphy' ? giphyMimeType : attachment.custom?.mime_type, diff --git a/package/src/store/SqliteClient.ts b/package/src/store/SqliteClient.ts index 69112c71e9..f191d45950 100644 --- a/package/src/store/SqliteClient.ts +++ b/package/src/store/SqliteClient.ts @@ -51,7 +51,7 @@ export class SqliteClientError extends Error { * This way usage @op-engineering/op-sqlite package is scoped to a single class/file. */ export class SqliteClient { - static dbVersion = 16; + static dbVersion = 17; static dbName = DB_NAME; static dbLocation = DB_LOCATION; diff --git a/package/src/store/__tests__/mapperRequiredTimestamps.test.ts b/package/src/store/__tests__/mapperRequiredTimestamps.test.ts new file mode 100644 index 0000000000..80137d8157 --- /dev/null +++ b/package/src/store/__tests__/mapperRequiredTimestamps.test.ts @@ -0,0 +1,64 @@ +import { mapStorableToChannel } from '../mappers/mapStorableToChannel'; +import { mapStorableToMessage } from '../mappers/mapStorableToMessage'; +import { mapStorableToReaction } from '../mappers/mapStorableToReaction'; +import { mapStorableToUser } from '../mappers/mapStorableToUser'; + +/** + * Every date column is nullable, but `created_at` / `updated_at` are required on the response + * models. These four mappers end their object literal with `...JSON.parse(extraData)`, and + * spreading `any` disables assignability checking for the whole literal — so `tsc` cannot see a + * required field being handed `undefined`. The six sibling mappers already guard with `?? 0`. + */ +describe('row -> model mappers keep required timestamps numeric', () => { + const nullDates = { createdAt: null, updatedAt: null }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const userRow = { ...nullDates, id: 'u1' } as any; + + it('mapStorableToMessage', () => { + const message = mapStorableToMessage({ + currentUserId: 'u1', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + messageRow: { ...nullDates, id: 'm1', type: 'regular', user: userRow } as any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + pollRow: undefined as any, + }); + + expect(typeof message.created_at).toBe('number'); + expect(typeof message.updated_at).toBe('number'); + }); + + it('mapStorableToUser', () => { + const user = mapStorableToUser(userRow); + + expect(typeof user.created_at).toBe('number'); + expect(typeof user.updated_at).toBe('number'); + // `role` is required on `UserResponse` but nullable in the column. + expect(typeof user.role).toBe('string'); + }); + + it('mapStorableToReaction', () => { + const reaction = mapStorableToReaction({ + ...nullDates, + messageId: 'm1', + type: 'like', + user: userRow, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any); + + expect(typeof reaction.created_at).toBe('number'); + expect(typeof reaction.updated_at).toBe('number'); + }); + + it('mapStorableToChannel', () => { + const result = mapStorableToChannel({ + ...nullDates, + cid: 'messaging:c1', + id: 'c1', + type: 'messaging', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any); + + expect(typeof result.channel?.created_at).toBe('number'); + expect(typeof result.channel?.updated_at).toBe('number'); + }); +}); diff --git a/package/src/store/__tests__/timestampStorage.test.ts b/package/src/store/__tests__/timestampStorage.test.ts new file mode 100644 index 0000000000..957a2f59b9 --- /dev/null +++ b/package/src/store/__tests__/timestampStorage.test.ts @@ -0,0 +1,146 @@ +import Database, { type Database as DatabaseType } from 'better-sqlite3'; + +import { generateMessage } from '../../mock-builders/generator/message'; +import { mapMessageToStorable } from '../mappers/mapMessageToStorable'; +import { mapStorableToTimestamp } from '../mappers/mapStorableToTimestamp'; +import { mapTimestampToStorable } from '../mappers/mapTimestampToStorable'; +import { tables } from '../schema'; +import { createCreateTableQuery } from '../sqlite-utils/createCreateTableQuery'; +import { createUpsertQuery } from '../sqlite-utils/createUpsertQuery'; +import type { Table } from '../types'; + +/** + * Timestamps are stored exactly as the API sends them: every date column is `INTEGER` holding unix + * nanoseconds, with no conversion at either boundary. Nothing here is about formatting — it is + * about the two things `tsc` cannot see. + * + * 1. **Precision.** A nanosecond timestamp (~1.79e18) sits far above `Number.MAX_SAFE_INTEGER`, so + * the obvious worry is that SQLite quantises it further. It does not: by the time `JSON.parse` + * has read the HTTP response the value is already an integral `double`, and an integral double + * below 2^63 round-trips through `INTEGER` storage exactly. The tables here are built from the + * real `schema.ts`, so a column reverted to `TEXT` fails these assertions rather than silently + * truncating every timestamp to the millisecond — which is what the ISO columns used to do. + * 2. **Absent means `NULL`, not `''`.** The ISO mapper this replaced wrote an empty string for an + * absent date, which is why `selectActiveLocationsForChannels`' `endAt IS NOT NULL` guard never + * actually filtered anything. `ORDER BY` placement and `IS NOT NULL` are invisible to the type + * system too. + */ +describe('timestamp storage', () => { + /** A real on-device value, from the report that prompted the nanosecond work. */ + const NANOS = 1786219962651957000; + const CID = 'messaging:general'; + + let db: DatabaseType; + + beforeEach(() => { + db = new Database(':memory:'); + // Every table, from the real schema: better-sqlite3 enables `PRAGMA foreign_keys` by default, + // and `messages.cid` references `channels`. + for (const table of Object.keys(tables) as Table[]) { + for (const [query] of createCreateTableQuery(table)) { + db.exec(query); + } + } + db.prepare('INSERT INTO channels (cid) VALUES (?)').run([CID]); + }); + + afterEach(() => { + db.close(); + }); + + const insertMessage = (message: ReturnType) => { + const [query, values] = createUpsertQuery('messages', mapMessageToStorable(message)); + db.prepare(query).run(values ?? []); + }; + + it('round-trips a nanosecond timestamp through the real schema without losing a digit', () => { + const message = generateMessage({ cid: CID, timestamp: NANOS }); + + insertMessage(message); + + const row = db + .prepare('SELECT createdAt, typeof(createdAt) AS storageClass FROM messages WHERE id = ?') + .get(message.id) as { createdAt: number; storageClass: string }; + + expect(row.storageClass).toBe('integer'); + expect(row.createdAt).toBe(NANOS); + + // `json_object` renders the integer SQLite actually holds; `toBe(NANOS)` alone cannot fail for + // a precision reason, since the literal is already the rounded double. + const { exact } = db + .prepare(`SELECT json_object('v', createdAt) AS exact FROM messages WHERE id = ?`) + .get(message.id) as { exact: string }; + expect(Number(JSON.parse(exact).v)).toBe(NANOS); + expect(Math.abs(Number(JSON.parse(exact).v) - NANOS)).toBeLessThan(1e6 / 2); + // The whole point of the change: no truncation to the millisecond. ISO carried three decimal + // places, so this assertion is what the previous storage format could not satisfy. + expect(row.createdAt % 1e6).not.toBe(0); + }); + + it('survives the json_object projection the select queries read through', () => { + // `selectMessagesForChannels` and friends do not select columns, they select a `json_object(…)` + // blob and `JSON.parse` it — a second place precision could be lost, via SQLite's number + // formatting rather than via storage. + const message = generateMessage({ cid: CID, timestamp: NANOS }); + + insertMessage(message); + + const { value } = db + .prepare(`SELECT json_object('createdAt', createdAt) AS value FROM messages WHERE id = ?`) + .get(message.id) as { value: string }; + + expect(JSON.parse(value).createdAt).toBe(NANOS); + }); + + it('writes an absent timestamp as NULL so IS NOT NULL means something', () => { + const message = generateMessage({ cid: CID, deleted_at: undefined, timestamp: NANOS }); + + insertMessage(message); + + const row = db + .prepare('SELECT typeof(deletedAt) AS storageClass FROM messages WHERE id = ?') + .get(message.id) as { storageClass: string }; + + expect(row.storageClass).toBe('null'); + expect( + db.prepare('SELECT count(*) AS c FROM messages WHERE deletedAt IS NOT NULL').get(), + ).toEqual({ c: 0 }); + }); + + it('orders numerically, with a missing timestamp last', () => { + const older = generateMessage({ cid: CID, timestamp: NANOS }); + const newer = generateMessage({ cid: CID, timestamp: NANOS + 1e9 }); + + insertMessage(older); + insertMessage(newer); + db.prepare('INSERT INTO messages (id, cid, createdAt) VALUES (?, ?, NULL)').run([ + 'no-timestamp', + CID, + ]); + + const ordered = db.prepare('SELECT id FROM messages ORDER BY createdAt DESC').all() as { + id: string; + }[]; + + expect(ordered.map((r) => r.id)).toStrictEqual([newer.id, older.id, 'no-timestamp']); + }); + + describe('the null boundary the mappers exist for', () => { + it('passes a present timestamp through untouched in both directions', () => { + expect(mapTimestampToStorable(NANOS)).toBe(NANOS); + expect(mapStorableToTimestamp(NANOS)).toBe(NANOS); + }); + + it('writes NULL for an absent timestamp, so an upsert clears rather than keeps it', () => { + // `upsertStatementParts` drops `undefined` from the column list, which on an + // upsert-**update** would leave the previous value in place. + expect(mapTimestampToStorable(undefined)).toBeNull(); + expect(mapTimestampToStorable(null)).toBeNull(); + }); + + it('reads NULL back as undefined, which is what the response types use', () => { + expect(mapStorableToTimestamp(null)).toBeUndefined(); + expect(mapStorableToTimestamp(undefined)).toBeUndefined(); + }); + }); +}); diff --git a/package/src/store/apis/deleteMessagesForChannel.ts b/package/src/store/apis/deleteMessagesForChannel.ts index ccd2a59432..5cd6483151 100644 --- a/package/src/store/apis/deleteMessagesForChannel.ts +++ b/package/src/store/apis/deleteMessagesForChannel.ts @@ -1,3 +1,5 @@ +import { nowNs } from 'stream-chat'; + import { SqliteClient } from '../SqliteClient'; export const deleteMessagesForChannel = async ({ @@ -6,10 +8,12 @@ export const deleteMessagesForChannel = async ({ execute = true, }: { cid: string; - truncated_at?: Date; + /** Unix nanoseconds, as the API sends it. */ + truncated_at?: number; execute?: boolean; }) => { - const timestamp = truncated_at ? new Date(truncated_at).toISOString() : new Date().toISOString(); + // `createdAt` holds unix nanoseconds, so the cutoff is one too and the comparison is numeric. + const timestamp = truncated_at ?? nowNs(); const query: [string, (string | number)[]] = [ `DELETE FROM messages WHERE cid = ? AND createdAt <= ?`, [cid, timestamp], diff --git a/package/src/store/apis/queries/selectActiveLocationsForChannels.ts b/package/src/store/apis/queries/selectActiveLocationsForChannels.ts index ac71ffa17e..a700f69931 100644 --- a/package/src/store/apis/queries/selectActiveLocationsForChannels.ts +++ b/package/src/store/apis/queries/selectActiveLocationsForChannels.ts @@ -1,3 +1,5 @@ +import { nowNs } from 'stream-chat'; + import { TableRow } from '../../../store/types'; import { SqliteClient } from '../../SqliteClient'; @@ -8,10 +10,11 @@ export const selectActiveLocationsForChannels = async ( SqliteClient.logger?.('info', 'selectActiveLocationsForChannels', { cids, }); - // Query to select active live locations for the given channel ids where the end_at is not empty and it is greater than the current date. + // Active means `endAt` is set and still in the future. `endAt` is unix nanoseconds, so the + // cutoff is `nowNs()` and the comparison is plain numeric. const locations = await SqliteClient.executeSql( `SELECT * FROM locations WHERE channelCid IN (${questionMarks}) AND endAt IS NOT NULL AND endAt > ?`, - [...cids, new Date().toISOString()], + [...cids, nowNs()], ); return locations as unknown as TableRow<'locations'>[]; diff --git a/package/src/store/apis/queries/selectDraftMessageFromDraftForChannels.ts b/package/src/store/apis/queries/selectDraftMessageFromDraftForChannels.ts index 146c834b3c..24a81edac1 100644 --- a/package/src/store/apis/queries/selectDraftMessageFromDraftForChannels.ts +++ b/package/src/store/apis/queries/selectDraftMessageFromDraftForChannels.ts @@ -33,7 +33,7 @@ export const selectDraftMessageFromDraftForChannels = async ( LEFT JOIN draftMessage b ON b.id = a.draftMessageId - WHERE cid in (${questionMarks}) ORDER BY datetime(a.createdAt) DESC`, + WHERE cid in (${questionMarks}) ORDER BY a.createdAt DESC`, cids, ); diff --git a/package/src/store/apis/queries/selectMembersForChannels.ts b/package/src/store/apis/queries/selectMembersForChannels.ts index 41779def6e..4f92600ab4 100644 --- a/package/src/store/apis/queries/selectMembersForChannels.ts +++ b/package/src/store/apis/queries/selectMembersForChannels.ts @@ -30,7 +30,7 @@ export const selectMembersForChannels = async ( LEFT JOIN users b ON b.id = a.userId - WHERE cid in (${questionMarks}) ORDER BY datetime(a.createdAt) DESC`, + WHERE cid in (${questionMarks}) ORDER BY a.createdAt DESC`, cids, ); diff --git a/package/src/store/apis/queries/selectReactionsForMessages.ts b/package/src/store/apis/queries/selectReactionsForMessages.ts index aae60585f2..2323d90df9 100644 --- a/package/src/store/apis/queries/selectReactionsForMessages.ts +++ b/package/src/store/apis/queries/selectReactionsForMessages.ts @@ -29,7 +29,7 @@ export const selectReactionsForMessages = async ( : []; const createdAtSort = sort?.find((s) => s.field === 'created_at')?.direction; const orderByClause = createdAtSort - ? `ORDER BY cast(strftime('%s', a.createdAt) AS INTEGER) ${createdAtSort === 1 ? 'ASC' : 'DESC'}` + ? `ORDER BY a.createdAt ${createdAtSort === 1 ? 'ASC' : 'DESC'}` : ''; SqliteClient.logger?.('info', 'selectReactionsForMessages', { diff --git a/package/src/store/apis/softDeleteMessage.ts b/package/src/store/apis/softDeleteMessage.ts index ce9c99a104..ce67b0d322 100644 --- a/package/src/store/apis/softDeleteMessage.ts +++ b/package/src/store/apis/softDeleteMessage.ts @@ -1,4 +1,4 @@ -import { DBDeleteMessageType, MessageLabel } from 'stream-chat'; +import { DBDeleteMessageType, MessageLabel, nowNs } from 'stream-chat'; import { createUpdateQuery } from '../sqlite-utils/createUpdateQuery'; import { SqliteClient } from '../SqliteClient'; @@ -11,7 +11,7 @@ export const softDeleteMessage = async ({ const query = createUpdateQuery( 'messages', { - deletedAt: deleteForMe ? undefined : new Date().toISOString(), + deletedAt: deleteForMe ? undefined : nowNs(), deletedForMe: deleteForMe, type: 'deleted' as MessageLabel, }, diff --git a/package/src/store/mappers/mapChannelDataToStorable.ts b/package/src/store/mappers/mapChannelDataToStorable.ts index cea46769df..4d1f0f9179 100644 --- a/package/src/store/mappers/mapChannelDataToStorable.ts +++ b/package/src/store/mappers/mapChannelDataToStorable.ts @@ -1,6 +1,6 @@ import type { ChannelResponse } from 'stream-chat'; -import { mapDateTimeToStorable } from './mapDateTimeToStorable'; +import { mapTimestampToStorable } from './mapTimestampToStorable'; import type { TableRow } from '../types'; @@ -37,22 +37,22 @@ export const mapChannelDataToStorable = (channel: ChannelResponse): TableRow<'ch cid, config: config && JSON.stringify(config), cooldown, - createdAt: mapDateTimeToStorable(created_at), - deletedAt: mapDateTimeToStorable(deleted_at), + createdAt: mapTimestampToStorable(created_at), + deletedAt: mapTimestampToStorable(deleted_at), disabled, extraData: JSON.stringify(extraData), frozen, hidden, id, - lastMessageAt: mapDateTimeToStorable(last_message_at), + lastMessageAt: mapTimestampToStorable(last_message_at), memberCount: member_count, muted, ownCapabilities: own_capabilities && JSON.stringify(own_capabilities), team, - truncatedAt: mapDateTimeToStorable(truncated_at), + truncatedAt: mapTimestampToStorable(truncated_at), truncatedBy: truncated_by && JSON.stringify(truncated_by), truncatedById: truncated_by?.id, type, - updatedAt: mapDateTimeToStorable(updated_at), + updatedAt: mapTimestampToStorable(updated_at), }; }; diff --git a/package/src/store/mappers/mapChannelToStorable.ts b/package/src/store/mappers/mapChannelToStorable.ts index 8d50be3257..95253082bf 100644 --- a/package/src/store/mappers/mapChannelToStorable.ts +++ b/package/src/store/mappers/mapChannelToStorable.ts @@ -1,6 +1,6 @@ import type { Channel, ChannelResponse } from 'stream-chat'; -import { mapDateTimeToStorable } from './mapDateTimeToStorable'; +import { mapTimestampToStorable } from './mapTimestampToStorable'; import type { TableRow } from '../types'; @@ -40,22 +40,22 @@ export const mapChannelToStorable = (channel: Channel): TableRow<'channels'> | u cid, config: config && JSON.stringify(config), cooldown, - createdAt: mapDateTimeToStorable(created_at), - deletedAt: mapDateTimeToStorable(deleted_at), + createdAt: mapTimestampToStorable(created_at), + deletedAt: mapTimestampToStorable(deleted_at), disabled, extraData: JSON.stringify(extraData), frozen, hidden, id, - lastMessageAt: mapDateTimeToStorable(last_message_at), + lastMessageAt: mapTimestampToStorable(last_message_at), memberCount: member_count, muted, ownCapabilities: own_capabilities && JSON.stringify(own_capabilities), team, - truncatedAt: mapDateTimeToStorable(truncated_at), + truncatedAt: mapTimestampToStorable(truncated_at), truncatedBy: truncated_by && JSON.stringify(truncated_by), truncatedById: truncated_by?.id, type, - updatedAt: mapDateTimeToStorable(updated_at), + updatedAt: mapTimestampToStorable(updated_at), }; }; diff --git a/package/src/store/mappers/mapDateTimeToStorable.ts b/package/src/store/mappers/mapDateTimeToStorable.ts deleted file mode 100644 index 7f9023b995..0000000000 --- a/package/src/store/mappers/mapDateTimeToStorable.ts +++ /dev/null @@ -1,7 +0,0 @@ -export const mapDateTimeToStorable = (datetime?: string | Date | null) => { - if (!datetime) { - return ''; - } - - return new Date(datetime).toISOString(); -}; diff --git a/package/src/store/mappers/mapDraftToStorable.ts b/package/src/store/mappers/mapDraftToStorable.ts index a72ba6c21e..0365c78abf 100644 --- a/package/src/store/mappers/mapDraftToStorable.ts +++ b/package/src/store/mappers/mapDraftToStorable.ts @@ -1,12 +1,12 @@ import type { DraftResponse } from 'stream-chat'; -import { mapDateTimeToStorable } from './mapDateTimeToStorable'; +import { mapTimestampToStorable } from './mapTimestampToStorable'; import type { TableRow } from '../types'; export const mapDraftToStorable = ({ draft }: { draft: DraftResponse }): TableRow<'draft'> => { const { channel_cid, created_at, parent_id, message, quoted_message } = draft; - const createdAt = mapDateTimeToStorable(created_at); + const createdAt = mapTimestampToStorable(created_at); return { cid: channel_cid, diff --git a/package/src/store/mappers/mapMemberToStorable.ts b/package/src/store/mappers/mapMemberToStorable.ts index bb81aa5e36..1fc9501043 100644 --- a/package/src/store/mappers/mapMemberToStorable.ts +++ b/package/src/store/mappers/mapMemberToStorable.ts @@ -1,6 +1,6 @@ import type { ChannelMemberResponse } from 'stream-chat'; -import { mapDateTimeToStorable } from './mapDateTimeToStorable'; +import { mapTimestampToStorable } from './mapTimestampToStorable'; import type { TableRow } from '../types'; @@ -28,19 +28,19 @@ export const mapMemberToStorable = ({ } = member; return { - archivedAt: mapDateTimeToStorable(archived_at), + archivedAt: mapTimestampToStorable(archived_at), banned, channelRole: channel_role, cid, - createdAt: mapDateTimeToStorable(created_at), - inviteAcceptedAt: mapDateTimeToStorable(invite_accepted_at), + createdAt: mapTimestampToStorable(created_at), + inviteAcceptedAt: mapTimestampToStorable(invite_accepted_at), invited, - inviteRejectedAt: mapDateTimeToStorable(invite_rejected_at), + inviteRejectedAt: mapTimestampToStorable(invite_rejected_at), isModerator: is_moderator, - pinnedAt: mapDateTimeToStorable(pinned_at), + pinnedAt: mapTimestampToStorable(pinned_at), role, shadowBanned: shadow_banned, - updatedAt: mapDateTimeToStorable(updated_at), + updatedAt: mapTimestampToStorable(updated_at), userId: user_id, }; }; diff --git a/package/src/store/mappers/mapMessageToStorable.ts b/package/src/store/mappers/mapMessageToStorable.ts index 76e416249b..347d28fe7a 100644 --- a/package/src/store/mappers/mapMessageToStorable.ts +++ b/package/src/store/mappers/mapMessageToStorable.ts @@ -1,6 +1,6 @@ import type { LocalMessage, MessageLabel, MessageResponse } from 'stream-chat'; -import { mapDateTimeToStorable } from './mapDateTimeToStorable'; +import { mapTimestampToStorable } from './mapTimestampToStorable'; import type { TableRow } from '../types'; @@ -36,18 +36,18 @@ export const mapMessageToStorable = ( return { attachments: JSON.stringify(attachments), cid: cid || '', - createdAt: mapDateTimeToStorable(created_at), - deletedAt: mapDateTimeToStorable(deleted_at), + createdAt: mapTimestampToStorable(created_at), + deletedAt: mapTimestampToStorable(deleted_at), deletedForMe: deleted_for_me, extraData: JSON.stringify(extraData), id, - messageTextUpdatedAt: mapDateTimeToStorable(message_text_updated_at), + messageTextUpdatedAt: mapTimestampToStorable(message_text_updated_at), poll_id: poll_id || '', reactionGroups: JSON.stringify(reaction_groups), shared_location: JSON.stringify(shared_location), text, type: type as MessageLabel, - updatedAt: mapDateTimeToStorable(updated_at), + updatedAt: mapTimestampToStorable(updated_at), userId: user?.id, }; }; diff --git a/package/src/store/mappers/mapPollToStorable.ts b/package/src/store/mappers/mapPollToStorable.ts index f822fff0c9..ec5bb33dbb 100644 --- a/package/src/store/mappers/mapPollToStorable.ts +++ b/package/src/store/mappers/mapPollToStorable.ts @@ -1,6 +1,6 @@ import type { PollResponseData } from 'stream-chat'; -import { mapDateTimeToStorable } from './mapDateTimeToStorable'; +import { mapTimestampToStorable } from './mapTimestampToStorable'; import type { TableRow } from '../types'; @@ -32,7 +32,7 @@ export const mapPollToStorable = (poll: PollResponseData): TableRow<'poll'> => { allow_answers, allow_user_suggested_options, answers_count, - created_at: mapDateTimeToStorable(created_at), + created_at: mapTimestampToStorable(created_at), created_by: JSON.stringify(created_by), // decouple the users from the actual poll created_by_id, description, @@ -45,7 +45,7 @@ export const mapPollToStorable = (poll: PollResponseData): TableRow<'poll'> => { name, options: JSON.stringify(options), own_votes: JSON.stringify(own_votes), - updated_at: mapDateTimeToStorable(updated_at), + updated_at: mapTimestampToStorable(updated_at), vote_count, vote_counts_by_option: JSON.stringify(vote_counts_by_option), voting_visibility, diff --git a/package/src/store/mappers/mapReactionToStorable.ts b/package/src/store/mappers/mapReactionToStorable.ts index 86281acbc4..f79b7a2ef4 100644 --- a/package/src/store/mappers/mapReactionToStorable.ts +++ b/package/src/store/mappers/mapReactionToStorable.ts @@ -1,6 +1,6 @@ import type { ReactionResponse } from 'stream-chat'; -import { mapDateTimeToStorable } from './mapDateTimeToStorable'; +import { mapTimestampToStorable } from './mapTimestampToStorable'; import type { TableRow } from '../types'; @@ -8,12 +8,12 @@ export const mapReactionToStorable = (reaction: ReactionResponse): TableRow<'rea const { created_at, message_id, score, type, updated_at, user, ...extraData } = reaction; return { - createdAt: mapDateTimeToStorable(created_at), + createdAt: mapTimestampToStorable(created_at), extraData: JSON.stringify(extraData), messageId: message_id, score, type: type || '', - updatedAt: mapDateTimeToStorable(updated_at), + updatedAt: mapTimestampToStorable(updated_at), userId: user?.id, }; }; diff --git a/package/src/store/mappers/mapReadToStorable.ts b/package/src/store/mappers/mapReadToStorable.ts index 8c20b1e109..5dec1bdab0 100644 --- a/package/src/store/mappers/mapReadToStorable.ts +++ b/package/src/store/mappers/mapReadToStorable.ts @@ -1,6 +1,6 @@ import type { ReadStateResponse } from 'stream-chat'; -import { mapDateTimeToStorable } from './mapDateTimeToStorable'; +import { mapTimestampToStorable } from './mapTimestampToStorable'; import type { TableRow } from '../types'; @@ -22,9 +22,10 @@ export const mapReadToStorable = ({ return { cid, - lastDeliveredAt: mapDateTimeToStorable(last_delivered_at), + lastDeliveredAt: mapTimestampToStorable(last_delivered_at), lastDeliveredMessageId: last_delivered_message_id, - lastRead: mapDateTimeToStorable(last_read), + // `0` is the LLC's epoch sentinel; the column is `INTEGER NOT NULL`. + lastRead: last_read ?? 0, lastReadMessageId: last_read_message_id, unreadMessages: unread_messages, userId: user?.id, diff --git a/package/src/store/mappers/mapReminderToStorable.ts b/package/src/store/mappers/mapReminderToStorable.ts index 720a63b6f2..d8515bba8c 100644 --- a/package/src/store/mappers/mapReminderToStorable.ts +++ b/package/src/store/mappers/mapReminderToStorable.ts @@ -1,6 +1,6 @@ import type { ReminderResponseData } from 'stream-chat'; -import { mapDateTimeToStorable } from './mapDateTimeToStorable'; +import { mapTimestampToStorable } from './mapTimestampToStorable'; import type { TableRow } from '../types'; @@ -9,10 +9,10 @@ export const mapReminderToStorable = (reminder: ReminderResponseData): TableRow< return { channelCid: channel_cid, - createdAt: mapDateTimeToStorable(created_at), + createdAt: mapTimestampToStorable(created_at), messageId: message_id, - remindAt: mapDateTimeToStorable(remind_at), - updatedAt: mapDateTimeToStorable(updated_at), + remindAt: mapTimestampToStorable(remind_at), + updatedAt: mapTimestampToStorable(updated_at), userId: user_id, }; }; diff --git a/package/src/store/mappers/mapSharedLocationToStorable.ts b/package/src/store/mappers/mapSharedLocationToStorable.ts index 3399302718..cea3cefea3 100644 --- a/package/src/store/mappers/mapSharedLocationToStorable.ts +++ b/package/src/store/mappers/mapSharedLocationToStorable.ts @@ -1,6 +1,6 @@ import type { SharedLocationResponseData } from 'stream-chat'; -import { mapDateTimeToStorable } from './mapDateTimeToStorable'; +import { mapTimestampToStorable } from './mapTimestampToStorable'; import type { TableRow } from '../types'; @@ -21,13 +21,13 @@ export const mapSharedLocationToStorable = ( return { channelCid: channel_cid, - createdAt: mapDateTimeToStorable(created_at), + createdAt: mapTimestampToStorable(created_at), createdByDeviceId: created_by_device_id, - endAt: mapDateTimeToStorable(end_at), + endAt: mapTimestampToStorable(end_at), latitude, longitude, messageId: message_id, - updatedAt: mapDateTimeToStorable(updated_at), + updatedAt: mapTimestampToStorable(updated_at), userId: user_id, }; }; diff --git a/package/src/store/mappers/mapStorableToChannel.ts b/package/src/store/mappers/mapStorableToChannel.ts index 85b62c3cc4..18410fd016 100644 --- a/package/src/store/mappers/mapStorableToChannel.ts +++ b/package/src/store/mappers/mapStorableToChannel.ts @@ -1,5 +1,7 @@ import type { ChannelStateResponseFields } from 'stream-chat'; +import { mapStorableToTimestamp } from './mapStorableToTimestamp'; + import type { TableRow } from '../types'; export const mapStorableToChannel = ( @@ -42,24 +44,24 @@ export const mapStorableToChannel = ( cid, config: config && JSON.parse(config), cooldown, - created_at: createdAt, + created_at: mapStorableToTimestamp(createdAt) ?? 0, created_by_id: createdById, - deleted_at: deletedAt, + deleted_at: mapStorableToTimestamp(deletedAt), disabled, frozen, hidden, id, invites: invites && JSON.parse(invites), - last_message_at: lastMessageAt, + last_message_at: mapStorableToTimestamp(lastMessageAt), member_count: memberCount, muted, own_capabilities: ownCapabilities && JSON.parse(ownCapabilities), team, - truncated_at: truncatedAt, + truncated_at: mapStorableToTimestamp(truncatedAt), truncated_by: truncatedBy, truncated_by_id: truncatedById, type, - updated_at: updatedAt, + updated_at: mapStorableToTimestamp(updatedAt) ?? 0, ...(extraData ? JSON.parse(extraData) : {}), }, }; diff --git a/package/src/store/mappers/mapStorableToDraft.ts b/package/src/store/mappers/mapStorableToDraft.ts index 4780b3df36..4305ae29f3 100644 --- a/package/src/store/mappers/mapStorableToDraft.ts +++ b/package/src/store/mappers/mapStorableToDraft.ts @@ -4,6 +4,7 @@ import { mapStorableToChannel } from './mapStorableToChannel'; import { mapStorableToDraftMessage } from './mapStorableToDraftMessage'; import { mapStorableToMessage } from './mapStorableToMessage'; +import { mapStorableToTimestamp } from './mapStorableToTimestamp'; import type { TableRow, TableRowJoinedDraftMessage, TableRowJoinedUser } from '../types'; @@ -33,7 +34,7 @@ export const mapStorableToDraft = ({ return { channel: channel.channel, channel_cid: cid, - created_at: new Date(createdAt), + created_at: mapStorableToTimestamp(createdAt) ?? 0, message, parent_id: parentId, quoted_message: quotedMessage, diff --git a/package/src/store/mappers/mapStorableToMember.ts b/package/src/store/mappers/mapStorableToMember.ts index 906a12d97d..063f8ca715 100644 --- a/package/src/store/mappers/mapStorableToMember.ts +++ b/package/src/store/mappers/mapStorableToMember.ts @@ -1,5 +1,6 @@ import type { ChannelMemberResponse } from 'stream-chat'; +import { mapStorableToTimestamp } from './mapStorableToTimestamp'; import { mapStorableToUser } from './mapStorableToUser'; import type { TableRowJoinedUser } from '../types'; @@ -25,20 +26,20 @@ export const mapStorableToMember = ( } = memberRow; return { - archived_at: archivedAt ? new Date(archivedAt) : undefined, + archived_at: mapStorableToTimestamp(archivedAt), banned: Boolean(banned), channel_role: channelRole ?? '', - created_at: new Date(createdAt ?? ''), + created_at: mapStorableToTimestamp(createdAt) ?? 0, custom: {}, - invite_accepted_at: inviteAcceptedAt ? new Date(inviteAcceptedAt) : undefined, - invite_rejected_at: inviteRejectedAt ? new Date(inviteRejectedAt) : undefined, + invite_accepted_at: mapStorableToTimestamp(inviteAcceptedAt), + invite_rejected_at: mapStorableToTimestamp(inviteRejectedAt), invited, is_moderator: isModerator, notifications_muted: false, - pinned_at: pinnedAt ? new Date(pinnedAt) : undefined, + pinned_at: mapStorableToTimestamp(pinnedAt), role, shadow_banned: Boolean(shadowBanned), - updated_at: new Date(updatedAt ?? ''), + updated_at: mapStorableToTimestamp(updatedAt) ?? 0, user: mapStorableToUser(user), user_id: userId, }; diff --git a/package/src/store/mappers/mapStorableToMessage.ts b/package/src/store/mappers/mapStorableToMessage.ts index e4b6459466..cfa4171ea8 100644 --- a/package/src/store/mappers/mapStorableToMessage.ts +++ b/package/src/store/mappers/mapStorableToMessage.ts @@ -4,6 +4,7 @@ import { mapStorableToPoll } from './mapStorableToPoll'; import { mapStorableToReaction } from './mapStorableToReaction'; import { mapStorableToReminder } from './mapStorableToReminder'; +import { mapStorableToTimestamp } from './mapStorableToTimestamp'; import { mapStorableToUser } from './mapStorableToUser'; import type { TableRow, TableRowJoinedUser } from '../types'; @@ -41,16 +42,16 @@ export const mapStorableToMessage = ({ return { ...rest, attachments: messageRow.attachments ? JSON.parse(messageRow.attachments) : [], - created_at: createdAt, - deleted_at: deletedAt, + created_at: mapStorableToTimestamp(createdAt) ?? 0, + deleted_at: mapStorableToTimestamp(deletedAt), deleted_for_me: deletedForMe, latest_reactions: latestReactions, - message_text_updated_at: messageTextUpdatedAt, + message_text_updated_at: mapStorableToTimestamp(messageTextUpdatedAt), own_reactions: ownReactions, poll_id, reaction_groups: reactionGroups ? JSON.parse(reactionGroups) : {}, shared_location: shared_location ? JSON.parse(shared_location) : null, - updated_at: updatedAt, + updated_at: mapStorableToTimestamp(updatedAt) ?? 0, user: mapStorableToUser(user), ...(pollRow ? { poll: mapStorableToPoll(pollRow) } : {}), ...(extraData ? JSON.parse(extraData) : {}), diff --git a/package/src/store/mappers/mapStorableToPoll.ts b/package/src/store/mappers/mapStorableToPoll.ts index a6e90b0108..572d4f14f4 100644 --- a/package/src/store/mappers/mapStorableToPoll.ts +++ b/package/src/store/mappers/mapStorableToPoll.ts @@ -1,5 +1,7 @@ import type { PollResponseData } from 'stream-chat'; +import { mapStorableToTimestamp } from './mapStorableToTimestamp'; + import type { TableRow } from '../types'; export const mapStorableToPoll = (pollRow: TableRow<'poll'>): PollResponseData => { @@ -30,7 +32,7 @@ export const mapStorableToPoll = (pollRow: TableRow<'poll'>): PollResponseData = allow_answers: Boolean(allow_answers), allow_user_suggested_options: Boolean(allow_user_suggested_options), answers_count, - created_at: new Date(created_at), + created_at: mapStorableToTimestamp(created_at) ?? 0, created_by: JSON.parse(created_by), created_by_id, custom: {}, @@ -44,9 +46,12 @@ export const mapStorableToPoll = (pollRow: TableRow<'poll'>): PollResponseData = name, options: JSON.parse(options), own_votes: own_votes ? JSON.parse(own_votes) : [], - updated_at: new Date(updated_at), + updated_at: mapStorableToTimestamp(updated_at) ?? 0, vote_count, vote_counts_by_option: JSON.parse(vote_counts_by_option), - voting_visibility: voting_visibility ?? '', + // `voting_visibility` is `'anonymous' | 'public'` on the response, and the column is TEXT. + // The `?? ''` fallback was never a valid value — it only compiled because the date fields on + // this same object literal were already failing and masking it. Default to the API's default. + voting_visibility: (voting_visibility as PollResponseData['voting_visibility']) ?? 'public', }; }; diff --git a/package/src/store/mappers/mapStorableToReaction.ts b/package/src/store/mappers/mapStorableToReaction.ts index 6e3166c41a..80bd7a8244 100644 --- a/package/src/store/mappers/mapStorableToReaction.ts +++ b/package/src/store/mappers/mapStorableToReaction.ts @@ -1,5 +1,6 @@ import type { ReactionResponse } from 'stream-chat'; +import { mapStorableToTimestamp } from './mapStorableToTimestamp'; import { mapStorableToUser } from './mapStorableToUser'; import type { TableRowJoinedUser } from '../types'; @@ -10,11 +11,11 @@ export const mapStorableToReaction = ( const { createdAt, extraData, messageId, score, type, updatedAt, user } = reactionRow; return { - created_at: createdAt, + created_at: mapStorableToTimestamp(createdAt) ?? 0, message_id: messageId, score, type, - updated_at: updatedAt, + updated_at: mapStorableToTimestamp(updatedAt) ?? 0, user: mapStorableToUser(user), ...(extraData ? JSON.parse(extraData) : {}), }; diff --git a/package/src/store/mappers/mapStorableToRead.ts b/package/src/store/mappers/mapStorableToRead.ts index 9b4b9674eb..a06797258a 100644 --- a/package/src/store/mappers/mapStorableToRead.ts +++ b/package/src/store/mappers/mapStorableToRead.ts @@ -1,5 +1,6 @@ import type { ReadStateResponse } from 'stream-chat'; +import { mapStorableToTimestamp } from './mapStorableToTimestamp'; import { mapStorableToUser } from './mapStorableToUser'; import type { TableRowJoinedUser } from '../types'; @@ -15,9 +16,9 @@ export const mapStorableToRead = (row: TableRowJoinedUser<'reads'>): ReadStateRe } = row; return { - last_delivered_at: lastDeliveredAt ? new Date(lastDeliveredAt) : undefined, + last_delivered_at: mapStorableToTimestamp(lastDeliveredAt), last_delivered_message_id: lastDeliveredMessageId, - last_read: new Date(lastRead), + last_read: mapStorableToTimestamp(lastRead) ?? 0, last_read_message_id: lastReadMessageId, unread_messages: unreadMessages ?? 0, user: mapStorableToUser(user), diff --git a/package/src/store/mappers/mapStorableToReminder.ts b/package/src/store/mappers/mapStorableToReminder.ts index e9dc8d07d5..ce2f6d7fc1 100644 --- a/package/src/store/mappers/mapStorableToReminder.ts +++ b/package/src/store/mappers/mapStorableToReminder.ts @@ -1,5 +1,7 @@ import type { ReminderResponseData } from 'stream-chat'; +import { mapStorableToTimestamp } from './mapStorableToTimestamp'; + import type { TableRow } from '../types'; export const mapStorableToReminder = (row: TableRow<'reminders'>): ReminderResponseData => { @@ -7,10 +9,10 @@ export const mapStorableToReminder = (row: TableRow<'reminders'>): ReminderRespo return { channel_cid: channelCid, - created_at: new Date(createdAt), + created_at: mapStorableToTimestamp(createdAt) ?? 0, message_id: messageId, - remind_at: remindAt ? new Date(remindAt) : undefined, - updated_at: new Date(updatedAt), + remind_at: mapStorableToTimestamp(remindAt), + updated_at: mapStorableToTimestamp(updatedAt) ?? 0, user_id: userId, }; }; diff --git a/package/src/store/mappers/mapStorableToSharedLocation.ts b/package/src/store/mappers/mapStorableToSharedLocation.ts index 03fa1c6de8..c89057a911 100644 --- a/package/src/store/mappers/mapStorableToSharedLocation.ts +++ b/package/src/store/mappers/mapStorableToSharedLocation.ts @@ -1,5 +1,7 @@ import { SharedLocationResponseData } from 'stream-chat'; +import { mapStorableToTimestamp } from './mapStorableToTimestamp'; + import type { TableRow } from '../types'; export const mapStorableToSharedLocation = ( @@ -19,13 +21,13 @@ export const mapStorableToSharedLocation = ( return { channel_cid: channelCid, - created_at: new Date(createdAt), + created_at: mapStorableToTimestamp(createdAt) ?? 0, created_by_device_id: createdByDeviceId, - end_at: endAt ? new Date(endAt) : undefined, + end_at: mapStorableToTimestamp(endAt), latitude, longitude, message_id: messageId, - updated_at: new Date(updatedAt), + updated_at: mapStorableToTimestamp(updatedAt) ?? 0, user_id: userId, }; }; diff --git a/package/src/store/mappers/mapStorableToTimestamp.ts b/package/src/store/mappers/mapStorableToTimestamp.ts new file mode 100644 index 0000000000..e754beef17 --- /dev/null +++ b/package/src/store/mappers/mapStorableToTimestamp.ts @@ -0,0 +1,14 @@ +/** + * A timestamp on its way *out of* SQLite. The inverse of `mapTimestampToStorable`, and equally + * conversion-free: the column already holds the unix-nanosecond number every response and event + * field carries. + * + * It exists for the one thing SQLite and the response types disagree about — SQLite says `null` + * for an absent timestamp, the generated types say `undefined`. + * + * Every date column is nullable, so append `?? 0` when assigning to a field the model declares + * required (`created_at` / `updated_at`). The compiler cannot catch a miss in a mapper whose + * literal ends with `...JSON.parse(extraData)` — spreading `any` disables the check. + */ +export const mapStorableToTimestamp = (timestamp?: number | null): number | undefined => + timestamp ?? undefined; diff --git a/package/src/store/mappers/mapStorableToUser.ts b/package/src/store/mappers/mapStorableToUser.ts index 1d09732606..1bca0066b0 100644 --- a/package/src/store/mappers/mapStorableToUser.ts +++ b/package/src/store/mappers/mapStorableToUser.ts @@ -1,5 +1,7 @@ import type { UserResponse } from 'stream-chat'; +import { mapStorableToTimestamp } from './mapStorableToTimestamp'; + import type { TableRow } from '../types'; export const mapStorableToUser = (userRow: TableRow<'users'>): UserResponse => { @@ -7,12 +9,12 @@ export const mapStorableToUser = (userRow: TableRow<'users'>): UserResponse => { return { banned: Boolean(banned), - created_at: createdAt, + created_at: mapStorableToTimestamp(createdAt) ?? 0, id, - last_active: lastActive, + last_active: mapStorableToTimestamp(lastActive), online: Boolean(online), - role, - updated_at: updatedAt, + role: role ?? 'user', + updated_at: mapStorableToTimestamp(updatedAt) ?? 0, ...(extraData ? JSON.parse(extraData) : {}), }; }; diff --git a/package/src/store/mappers/mapTimestampToStorable.ts b/package/src/store/mappers/mapTimestampToStorable.ts new file mode 100644 index 0000000000..18838416d5 --- /dev/null +++ b/package/src/store/mappers/mapTimestampToStorable.ts @@ -0,0 +1,14 @@ +/** + * A timestamp on its way *into* SQLite. Unix nanoseconds in, unix nanoseconds out — every date + * column is `INTEGER` holding exactly what the API put on the wire, so there is no conversion here. + * Lossless relative to what JavaScript holds: `JSON.parse` already quantised the value to the + * nearest double (~256 ns) before SQLite saw it. + * + * The only thing this function does is pick `null` over `undefined`, and that choice is + * load-bearing rather than stylistic: `upsertStatementParts` omits `undefined` values from the + * column list, so an absent date on an upsert-**update** would silently keep whatever was + * already stored. Writing an explicit `null` clears it, which is what the previous ISO mapper + * did by returning `''`. + */ +export const mapTimestampToStorable = (timestamp?: number | null): number | null => + timestamp ?? null; diff --git a/package/src/store/mappers/mapUserToStorable.ts b/package/src/store/mappers/mapUserToStorable.ts index ed1c455b4a..e7a9af7b03 100644 --- a/package/src/store/mappers/mapUserToStorable.ts +++ b/package/src/store/mappers/mapUserToStorable.ts @@ -1,6 +1,6 @@ import type { UserResponse } from 'stream-chat'; -import { mapDateTimeToStorable } from './mapDateTimeToStorable'; +import { mapTimestampToStorable } from './mapTimestampToStorable'; import type { TableRow } from '../types'; @@ -9,12 +9,12 @@ export const mapUserToStorable = (user: UserResponse): TableRow<'users'> => { return { banned, - createdAt: mapDateTimeToStorable(created_at), + createdAt: mapTimestampToStorable(created_at), extraData: JSON.stringify(extraData || {}), id, - lastActive: mapDateTimeToStorable(last_active), + lastActive: mapTimestampToStorable(last_active), online, role, - updatedAt: mapDateTimeToStorable(updated_at), + updatedAt: mapTimestampToStorable(updated_at), }; }; diff --git a/package/src/store/schema.ts b/package/src/store/schema.ts index 4bf355a8f3..a14da96dfe 100644 --- a/package/src/store/schema.ts +++ b/package/src/store/schema.ts @@ -38,32 +38,32 @@ export const tables: Tables = { cid: 'TEXT', config: 'TEXT', cooldown: 'BOOLEAN', - createdAt: 'TEXT', + createdAt: 'INTEGER', createdById: 'TEXT', - deletedAt: 'TEXT', + deletedAt: 'INTEGER', disabled: 'BOOLEAN DEFAULT FALSE', extraData: 'TEXT', frozen: 'BOOLEAN', hidden: 'BOOLEAN', id: 'TEXT', invites: 'TEXT', - lastMessageAt: 'TEXT', + lastMessageAt: 'INTEGER', memberCount: 'INTEGER', muted: 'BOOLEAN DEFAULT FALSE', ownCapabilities: 'TEXT', team: 'TEXT', - truncatedAt: 'TEXT', + truncatedAt: 'INTEGER', truncatedBy: 'TEXT', truncatedById: 'TEXT', type: 'TEXT', - updatedAt: 'TEXT', + updatedAt: 'INTEGER', }, primaryKey: ['cid'], }, draft: { columns: { cid: 'TEXT NOT NULL', - createdAt: 'TEXT', + createdAt: 'INTEGER', draftMessageId: 'TEXT NOT NULL', parentId: 'TEXT', quotedMessageId: 'TEXT', @@ -108,13 +108,13 @@ export const tables: Tables = { locations: { columns: { channelCid: 'TEXT NOT NULL', - createdAt: 'TEXT', + createdAt: 'INTEGER', createdByDeviceId: 'TEXT', - endAt: 'TEXT', + endAt: 'INTEGER', latitude: 'REAL NOT NULL', longitude: 'REAL NOT NULL', messageId: 'TEXT NOT NULL', - updatedAt: 'TEXT', + updatedAt: 'INTEGER', userId: 'TEXT NOT NULL', }, foreignKeys: [ @@ -136,19 +136,19 @@ export const tables: Tables = { }, members: { columns: { - archivedAt: 'TEXT', + archivedAt: 'INTEGER', banned: 'BOOLEAN DEFAULT FALSE', channelRole: 'TEXT', cid: 'TEXT NOT NULL', - createdAt: 'TEXT', - inviteAcceptedAt: 'TEXT', + createdAt: 'INTEGER', + inviteAcceptedAt: 'INTEGER', invited: 'BOOLEAN', - inviteRejectedAt: 'TEXT', + inviteRejectedAt: 'INTEGER', isModerator: 'BOOLEAN', - pinnedAt: 'TEXT', + pinnedAt: 'INTEGER', role: 'TEXT', shadowBanned: 'BOOLEAN DEFAULT FALSE', - updatedAt: 'TEXT', + updatedAt: 'INTEGER', userId: 'TEXT', }, foreignKeys: [ @@ -172,18 +172,18 @@ export const tables: Tables = { columns: { attachments: 'TEXT', cid: 'TEXT NOT NULL', - createdAt: 'TEXT', - deletedAt: 'TEXT', + createdAt: 'INTEGER', + deletedAt: 'INTEGER', deletedForMe: 'BOOLEAN DEFAULT FALSE', extraData: 'TEXT', id: 'TEXT', - messageTextUpdatedAt: 'TEXT', + messageTextUpdatedAt: 'INTEGER', poll_id: 'TEXT', reactionGroups: 'TEXT', shared_location: 'TEXT', text: "TEXT DEFAULT ''", type: 'TEXT', - updatedAt: 'TEXT', + updatedAt: 'INTEGER', userId: 'TEXT', }, foreignKeys: [ @@ -220,7 +220,7 @@ export const tables: Tables = { allow_answers: 'BOOLEAN DEFAULT FALSE', allow_user_suggested_options: 'BOOLEAN DEFAULT FALSE', answers_count: 'INTEGER DEFAULT 0', - created_at: 'TEXT', + created_at: 'INTEGER', created_by: 'TEXT', created_by_id: 'TEXT', description: 'TEXT', @@ -233,7 +233,7 @@ export const tables: Tables = { name: 'TEXT', options: 'TEXT', own_votes: 'TEXT', - updated_at: 'TEXT', + updated_at: 'INTEGER', vote_count: 'INTEGER DEFAULT 0', vote_counts_by_option: 'TEXT', voting_visibility: 'TEXT', @@ -242,12 +242,12 @@ export const tables: Tables = { }, reactions: { columns: { - createdAt: 'TEXT', + createdAt: 'INTEGER', extraData: 'TEXT', messageId: 'TEXT', score: 'INTEGER DEFAULT 0', type: 'TEXT', - updatedAt: 'TEXT', + updatedAt: 'INTEGER', userId: 'TEXT', }, foreignKeys: [ @@ -270,9 +270,9 @@ export const tables: Tables = { reads: { columns: { cid: 'TEXT NOT NULL', - lastDeliveredAt: 'TEXT', + lastDeliveredAt: 'INTEGER', lastDeliveredMessageId: 'TEXT', - lastRead: 'TEXT NOT NULL', + lastRead: 'INTEGER NOT NULL DEFAULT 0', lastReadMessageId: 'TEXT', unreadMessages: 'INTEGER DEFAULT 0', userId: 'TEXT', @@ -289,10 +289,10 @@ export const tables: Tables = { reminders: { columns: { channelCid: 'TEXT NOT NULL', - createdAt: 'TEXT', + createdAt: 'INTEGER', messageId: 'TEXT NOT NULL', - remindAt: 'TEXT', - updatedAt: 'TEXT', + remindAt: 'INTEGER', + updatedAt: 'INTEGER', userId: 'TEXT NOT NULL', }, foreignKeys: [ @@ -315,13 +315,13 @@ export const tables: Tables = { users: { columns: { banned: 'BOOLEAN DEFAULT FALSE', - createdAt: 'TEXT', + createdAt: 'INTEGER', extraData: 'TEXT', id: 'TEXT', - lastActive: 'TEXT', + lastActive: 'INTEGER', online: 'INTEGER', role: 'TEXT', - updatedAt: 'TEXT', + updatedAt: 'INTEGER', }, indexes: [ { @@ -357,27 +357,27 @@ export type Schema = { autoTranslationLanguage?: string; config?: string; cooldown?: number; - createdAt?: string; + createdAt?: number | null; createdById?: string; - deletedAt?: string; + deletedAt?: number | null; disabled?: boolean; frozen?: boolean; hidden?: boolean; invites?: string; - lastMessageAt?: string; + lastMessageAt?: number | null; memberCount?: number; muted?: boolean; ownCapabilities?: string; team?: string; - truncatedAt?: string; + truncatedAt?: number | null; truncatedBy?: string; truncatedById?: string; - updatedAt?: string; + updatedAt?: number | null; }; draft: { draftMessageId: string; cid: string; - createdAt: string; + createdAt: number | null; parentId?: string; quotedMessageId?: string; }; @@ -399,35 +399,35 @@ export type Schema = { type?: MessageLabel; }; members: { - archivedAt?: string; + archivedAt?: number | null; cid: string; banned?: boolean; channelRole?: string; - createdAt?: string; - inviteAcceptedAt?: string; + createdAt?: number | null; + inviteAcceptedAt?: number | null; invited?: boolean; - inviteRejectedAt?: string; + inviteRejectedAt?: number | null; isModerator?: boolean; role?: string; shadowBanned?: boolean; - updatedAt?: string; + updatedAt?: number | null; userId?: string; - pinnedAt?: string; + pinnedAt?: number | null; }; messages: { attachments: string; cid: string; - createdAt: string; - deletedAt: string; + createdAt: number | null; + deletedAt: number | null; deletedForMe?: boolean; extraData: string; id: string; - messageTextUpdatedAt: string; + messageTextUpdatedAt: number | null; poll_id: string; reactionGroups: string; shared_location: string; type: MessageLabel; - updatedAt: string; + updatedAt: number | null; text?: string; userId?: string; }; @@ -443,7 +443,7 @@ export type Schema = { }; poll: { answers_count: number; - created_at: string; + created_at: number | null; created_by: string; created_by_id: string; enforce_unique_vote: boolean; @@ -453,7 +453,7 @@ export type Schema = { max_votes_allowed: number; name: string; options: string; - updated_at: string; + updated_at: number | null; vote_count: number; vote_counts_by_option: string; allow_answers?: boolean; @@ -464,51 +464,51 @@ export type Schema = { voting_visibility?: string; }; reactions: { - createdAt: string; + createdAt: number | null; messageId: string; type: string; - updatedAt: string; + updatedAt: number | null; extraData?: string; score?: number; userId?: string; }; reads: { cid: string; - lastRead: string; + lastRead: number; lastReadMessageId?: string; unreadMessages?: number; userId?: string; - lastDeliveredAt?: string; + lastDeliveredAt?: number | null; lastDeliveredMessageId?: string; }; reminders: { channelCid: string; - createdAt: string; + createdAt: number | null; messageId: string; - updatedAt: string; + updatedAt: number | null; userId: string; - remindAt?: string; + remindAt?: number | null; }; locations: { channelCid: string; - createdAt: string; + createdAt: number | null; createdByDeviceId: string; - endAt?: string; + endAt?: number | null; latitude: number; longitude: number; messageId: string; - updatedAt: string; + updatedAt: number | null; userId: string; }; users: { id: string; banned?: boolean; - createdAt?: string; + createdAt?: number | null; extraData?: string; - lastActive?: string; + lastActive?: number | null; online?: boolean; role?: string; - updatedAt?: string; + updatedAt?: number | null; }; userSyncStatus: { appSettings: string; diff --git a/package/src/store/types.ts b/package/src/store/types.ts index bc42c5c68d..a3d676c374 100644 --- a/package/src/store/types.ts +++ b/package/src/store/types.ts @@ -13,7 +13,7 @@ export type TableRowJoinedDraftMessage = Schema[T] & { }; export type TableColumnNames = keyof Schema[T]; -export type TableColumnValue = string | boolean | number | undefined; +export type TableColumnValue = string | boolean | number | null | undefined; export type Scalar = string | number | boolean | null | ArrayBuffer | ArrayBufferView; // eslint-disable-next-line @typescript-eslint/no-explicit-any export type PreparedQueries = [string] | [string, Array]; diff --git a/package/src/utils/getChannelUnreadState.ts b/package/src/utils/getChannelUnreadState.ts index de98a96100..133b3e49ac 100644 --- a/package/src/utils/getChannelUnreadState.ts +++ b/package/src/utils/getChannelUnreadState.ts @@ -20,7 +20,7 @@ export const getChannelUnreadState = (channel: Channel): ChannelUnreadState | un return { first_unread_message_id: snapshot.firstUnreadMessageId ?? undefined, - last_read: snapshot.lastReadAt ?? new Date(0), + last_read: snapshot.lastReadAt ?? 0, last_read_message_id: snapshot.lastReadMessageId ?? undefined, unread_messages: snapshot.unreadCount, }; diff --git a/package/src/utils/utils.ts b/package/src/utils/utils.ts index c107c6ad6b..81e71c7e5e 100644 --- a/package/src/utils/utils.ts +++ b/package/src/utils/utils.ts @@ -91,7 +91,7 @@ export const isBouncedMessage = (message: LocalMessage) => * @param message * @returns boolean */ -export const isEditedMessage = (message: LocalMessage) => !!message.message_text_updated_at; +export const isEditedMessage = (message: LocalMessage) => message.message_text_updated_at != null; export const makeImageCompatibleUrl = (url: string) => (url.indexOf('//') === 0 ? `https:${url}` : url).trim(); @@ -273,26 +273,22 @@ export const findInMessagesById = (messages: LocalMessage[], targetId: string) = */ export const findInMessagesByDate = ( messages: MessageResponse[] | LocalMessage[], - targetDate: Date, + /** Unix nanoseconds, the same unit the messages' `created_at` carries. */ + targetTimestamp: number, ) => { // Binary search - const targetTimestamp = targetDate.getTime(); let left = 0; let right = messages.length - 1; let middle = 0; while (left <= right) { middle = Math.floor(left + (right - left) / 2); - const middleTimestamp = new Date(messages[middle].created_at as string | Date).getTime(); - const middleLeftTimestamp = - messages[middle - 1]?.created_at && - new Date(messages[middle - 1].created_at as string | Date).getTime(); - const middleRightTimestamp = - messages[middle + 1]?.created_at && - new Date(messages[middle + 1].created_at as string | Date).getTime(); + const middleTimestamp = messages[middle].created_at; + const middleLeftTimestamp = messages[middle - 1]?.created_at; + const middleRightTimestamp = messages[middle + 1]?.created_at; if ( middleTimestamp === targetTimestamp || - (middleLeftTimestamp && - middleRightTimestamp && + (middleLeftTimestamp != null && + middleRightTimestamp != null && middleLeftTimestamp < targetTimestamp && middleRightTimestamp > targetTimestamp) ) { @@ -337,8 +333,8 @@ export const checkMessageEquality = ( prevMessage?.pinned === nextMessage?.pinned && prevMessage?.i18n === nextMessage?.i18n && prevMessage?.reply_count === nextMessage?.reply_count && - prevMessage?.updated_at?.getTime?.() === nextMessage?.updated_at?.getTime?.() && - prevMessage?.deleted_at?.getTime?.() === nextMessage?.deleted_at?.getTime?.(); + prevMessage?.updated_at === nextMessage?.updated_at && + prevMessage?.deleted_at === nextMessage?.deleted_at; return messageEqual; }; @@ -365,8 +361,8 @@ export const checkQuotedMessageEquality = ( const quotedMessageEqual = prevQuotedMessage?.type === nextQuotedMessage?.type && prevQuotedMessage?.text === nextQuotedMessage?.text && - prevQuotedMessage?.updated_at?.getTime?.() === nextQuotedMessage?.updated_at?.getTime?.() && - prevQuotedMessage?.deleted_at?.getTime?.() === nextQuotedMessage?.deleted_at?.getTime?.(); + prevQuotedMessage?.updated_at === nextQuotedMessage?.updated_at && + prevQuotedMessage?.deleted_at === nextQuotedMessage?.deleted_at; return quotedMessageEqual; }; diff --git a/yarn.lock b/yarn.lock index a8d38f62e2..1304d3862d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6578,7 +6578,7 @@ __metadata: react-native-teleport: "npm:^1.1.12" react-native-web: "npm:^0.21.2" react-native-worklets: "npm:0.11.1" - stream-chat: "npm:^10.0.0-rc.8" + stream-chat: "npm:^10.0.0-rc.9" stream-chat-expo: "workspace:^" stream-chat-react-native-core: "workspace:^" typescript: "npm:6.0.3" @@ -17575,7 +17575,7 @@ __metadata: react-native-teleport: "npm:^1.1.12" react-native-video: "npm:^6.19.2" react-native-worklets: "npm:^0.12.1" - stream-chat: "npm:^10.0.0-rc.8" + stream-chat: "npm:^10.0.0-rc.9" stream-chat-react-native: "workspace:^" stream-chat-react-native-core: "workspace:^" typescript: "npm:6.0.3" @@ -18317,7 +18317,7 @@ __metadata: react-native-worklets: "npm:^0.12.1" react-test-renderer: "npm:19.2.3" rimraf: "npm:^6.0.1" - stream-chat: "npm:^10.0.0-rc.8" + stream-chat: "npm:^10.0.0-rc.9" typescript: "npm:6.0.3" use-sync-external-store: "npm:^1.5.0" uuid: "npm:^11.1.0" @@ -18391,9 +18391,9 @@ __metadata: languageName: unknown linkType: soft -"stream-chat@npm:^10.0.0-rc.8": - version: 10.0.0-rc.8 - resolution: "stream-chat@npm:10.0.0-rc.8" +"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" @@ -18405,7 +18405,7 @@ __metadata: built: true husky: built: true - checksum: 10c0/0cbd96aae6cae42804090c96d7b3461274b77ded18d42c7b529820fba1c6206c6608de8436778ab140ed6ab1e88fccfe361bc27d58e6172c97f0548302f81527 + checksum: 10c0/4409003abe465e7d75d5b55daf6a197aeec346a9dcbbb84ad729dc2c31155899b6cf464d3a3be3249cf132b1cb3b67e4e7d0e3356379f22160779581b7b38dd1 languageName: node linkType: hard