diff --git a/ai-docs/ai-migration-v14-v15.md b/ai-docs/ai-migration-v14-v15.md index 98e32e6ae..b0d65fedb 100644 --- a/ai-docs/ai-migration-v14-v15.md +++ b/ai-docs/ai-migration-v14-v15.md @@ -110,28 +110,40 @@ To ingest an ad-hoc channel (e.g. navigating to a DM or search result) into the ## Dates on response types are unix-nanosecond numbers -`stream-chat` now types every **server-sent** date as the unix-nanosecond `number` the API puts on the +`stream-chat` now types every **server-sent** date as the unix-nanosecond number the API puts on the wire — `created_at`, `updated_at`, `last_read`, and every sibling on a response or event. It is not a `Date` and not an ISO string, and the React types that carry those values through changed with it. -Two failure modes, neither of which is a type error: +The type is **`TimestampNS`**, a branded `number`. Reading, comparing, sorting and subtracting work as +with any number. Two things change at compile time: -- **Every `Date`-based path is out of range.** `Date` tops out near 8.64e15 ms while a current - timestamp is ~1.79e18, and a date library reads a bare number as **milliseconds** — so both land on - an invalid instance rather than on a plausible wrong date. `.toISOString()` throws - `RangeError: Invalid time value`, usually mid-render; `dayjs(created_at).format()` instead returns - the literal string `Invalid Date` and renders it on screen. -- **A unit mix-up between two `number`s is the silent one.** Comparing a wire timestamp against - `Date.now()`, or adding a millisecond duration to one, produces a plausible-looking number and no - complaint at all — see `headerPosition` below for a case with no type change to warn you. +- **`new Date(timestamp)` is a type error.** `stream-chat`'s published types augment the global + `DateConstructor`, because a nanosecond value is out of `Date`'s range (`Date` tops out near 8.64e15 + ms; a current timestamp is ~1.79e18) and yields an Invalid Date whose `.toISOString()` throws. +- **Minting one needs a helper.** A plain `number` is not assignable to a `TimestampNS` field or prop: + use `nowNs()`, `msToNs(ms)`, `dateToNs(date)`, or `asTimestampNS(n)` for a value that is already in + nanoseconds (a fixture, a stored value, the epoch `asTimestampNS(0)`). Arithmetic drops the brand — + wrap the result in `asTimestampNS` when it goes back into a timestamp. + +What the compiler still does **not** catch: + +- **Date libraries.** A date library reads a bare number as **milliseconds**, so + `dayjs(created_at).format()` returns the literal string `Invalid Date` and renders it on screen. +- **Fallbacks and derived values.** `new Date(ts ?? Date.now())` and `new Date(Math.max(a, b))` + compile, because the argument is no longer purely `TimestampNS`. Convert first, then fall back. +- **A unit mix-up between two numbers.** Comparing a wire timestamp against `Date.now()`, or adding a + millisecond duration to one, produces a plausible-looking number and no complaint at all. ### The public React types that changed -| Type | v14 | v15 | -| ----------------------------------------------------- | ----------------------------- | ------------------------------- | -| `ChatContextValue.latestMessageDatesByChannels` | `Record` | `Record` | -| `ProcessMessagesParams.lastRead` (`processMessages`) | `Date \| null` | `number \| null` | -| `VirtualizedMessageList` render props: `lastReadDate` | `Date \| null` | `number \| null` | +| Type | v14 | v15 | +| ----------------------------------------------------- | ------------------- | ----------------------- | +| `ProcessMessagesParams.lastRead` (`processMessages`) | `Date \| null` | `TimestampNS \| null` | +| `VirtualizedMessageList` render props: `lastReadDate` | `Date \| null` | `TimestampNS \| null` | +| `MessageList` `headerPosition` / `insertIntro` | `number` (epoch ms) | `TimestampNS` (unix ns) | + +`ChatContextValue.latestMessageDatesByChannels` is not in this table because it is **removed**, not +retyped — see [below](#chatcontextlatestmessagedatesbychannels--removed). `DateSeparatorMessage` (a member of the exported `RenderedMessage` union) changed shape rather than type: it **lost its `type: MessageLabel` field**, and `unread` is now optional. The `type` field was @@ -144,10 +156,10 @@ Comparisons get simpler, not harder — compare and sort the raw numbers and dro ```ts // v14 -if (latestMessageDatesByChannels[cid].getTime() < new Date(message.created_at).getTime()) { … } +if (new Date(a.created_at).getTime() < new Date(b.created_at).getTime()) { … } // v15 -if (latestMessageDatesByChannels[cid] < message.created_at) { … } +if (a.created_at < b.created_at) { … } ``` ### Presentational props still take `Date` @@ -178,16 +190,17 @@ const createdAt = convertTimestampToDate(message.created_at); ``` -`nsToDate` / `dateToNs` / `nsToMs` / `msToNs` / `nowNs` are exported alongside it for values known to be -present. Note that **outgoing request** date fields are still `Date` (filter bounds like +`nsToDate` / `dateToNs` / `nsToMs` / `msToNs` / `nowNs` / `asTimestampNS` are exported alongside it +for values known to be present. Note that **outgoing request** date fields are still `Date` (filter bounds like `created_at_before`, plus `remind_at` and `message_timestamp`) — `JSON.stringify` emits RFC3339 for a `Date`, which is what the request spec declares. Use `nsToDate` when handing a server-sent timestamp back to the API. -### `MessageList`'s `headerPosition` prop changed unit, not type +### `MessageList`'s `headerPosition` prop changed unit `headerPosition` is compared against `message.created_at`, so it is now **unix nanoseconds** — it was -epoch milliseconds while `created_at` was a `Date`. The type is still `number`, so nothing warns. +epoch milliseconds while `created_at` was a `Date`. It is typed `TimestampNS`, so a millisecond +`number` no longer compiles: pass `message.created_at` or `msToNs(ms)`. ### Peer-dependency gate before release @@ -202,7 +215,9 @@ range to the version that exports them and verify from a clean install with no ` A fixture that hands the SDK a `Date` cannot catch either failure mode above, and will diverge from runtime behavior. The SDK's own suite normalizes through `mock-builders/generator/time.ts` (`convertDateToTimestamp`), which accepts a `Date`, an ISO string or a -raw wire number so tests stay readable while the value on the wire stays a number. +raw wire number so tests stay readable while the value on the wire stays a number. It returns +`TimestampNS`, so a generated fixture is assignable to the response types; a hand-written literal +(`created_at: 0`, `now - msToNs(1000)`) needs `asTimestampNS(...)`. ## i18n: English-only bundle, namespaced translation keys diff --git a/ai-docs/i18n-v15-migration.md b/ai-docs/i18n-v15-migration.md index f2cb8fa6b..c8a10017d 100644 --- a/ai-docs/i18n-v15-migration.md +++ b/ai-docs/i18n-v15-migration.md @@ -336,7 +336,7 @@ hand them straight to a provider. const { translators } = useChat({ client, defaultLanguage, i18nInstance }); // v15 -const { getAppSettings, latestMessageDatesByChannels, mutes } = useChat({ client }); +const { getAppSettings, mutes } = useChat({ client }); const translators = useStreami18n({ client, i18nInstance }); ``` diff --git a/examples/tutorial/package.json b/examples/tutorial/package.json index 0bdc58709..07f0bfa79 100644 --- a/examples/tutorial/package.json +++ b/examples/tutorial/package.json @@ -16,7 +16,7 @@ "emoji-mart": "^5.6.0", "react": "^19.2.6", "react-dom": "^19.2.6", - "stream-chat": "10.0.0-rc.13", + "stream-chat": "10.0.0-rc.14", "stream-chat-react": "workspace:^" }, "devDependencies": { diff --git a/examples/vite/package.json b/examples/vite/package.json index 141b947c7..769e9ca52 100644 --- a/examples/vite/package.json +++ b/examples/vite/package.json @@ -18,7 +18,7 @@ "modern-normalize": "^3.0.1", "react": "^19.2.6", "react-dom": "^19.2.6", - "stream-chat": "10.0.0-rc.13", + "stream-chat": "10.0.0-rc.14", "stream-chat-react": "workspace:^" }, "devDependencies": { diff --git a/examples/vite/src/AppSettings/ActionsMenu/WebSocketEventPromptDialog/websocketEventAutomation.ts b/examples/vite/src/AppSettings/ActionsMenu/WebSocketEventPromptDialog/websocketEventAutomation.ts index 95c18482d..d1871e9c5 100644 --- a/examples/vite/src/AppSettings/ActionsMenu/WebSocketEventPromptDialog/websocketEventAutomation.ts +++ b/examples/vite/src/AppSettings/ActionsMenu/WebSocketEventPromptDialog/websocketEventAutomation.ts @@ -1,6 +1,7 @@ import { nowNs } from 'stream-chat'; import type { Channel, + ChannelMemberPartialResponse, ChannelMemberResponse, MessageResponse, ReactionResponse, @@ -25,7 +26,8 @@ type UnknownRecord = Record; */ type EventPayload = UnknownRecord & { channel?: Partial; - member?: ChannelMemberResponse; + // Typing events carry the partial member shape (`TypingStartEvent.member`), not a full response. + member?: ChannelMemberResponse | ChannelMemberPartialResponse; message?: Partial; reaction?: ReactionResponse; user?: UserResponse; diff --git a/examples/vite/src/AppSettings/ActionsMenu/WebSocketEventPromptDialog/websocketEventTemplates.ts b/examples/vite/src/AppSettings/ActionsMenu/WebSocketEventPromptDialog/websocketEventTemplates.ts index 1a86e7124..141dacd5b 100644 --- a/examples/vite/src/AppSettings/ActionsMenu/WebSocketEventPromptDialog/websocketEventTemplates.ts +++ b/examples/vite/src/AppSettings/ActionsMenu/WebSocketEventPromptDialog/websocketEventTemplates.ts @@ -4,6 +4,7 @@ import type { ChannelMemberResponse, ChannelResponse, StreamChat, + TimestampNS, UserResponse, } from 'stream-chat'; @@ -103,9 +104,9 @@ export type WebSocketEventTemplateContext = { channelType: string; cid: string; /** Unix nanoseconds, the unit every server-sent date uses on the wire. */ - createdAt: number; + createdAt: TimestampNS; /** Unix nanoseconds, the unit every server-sent date uses on the wire. */ - lastReadAt: number; + lastReadAt: TimestampNS; memberCount: number; messageId: string; otherMember: ChannelMemberResponse; @@ -123,7 +124,7 @@ type BuildChannelSeedContext = Omit & channel: Partial; }; -const createFallbackUser = (id: string, createdAt: number): DebugUserResponse => ({ +const createFallbackUser = (id: string, createdAt: TimestampNS): DebugUserResponse => ({ banned: false, blocked_user_ids: [], created_at: createdAt, diff --git a/package.json b/package.json index 2ace4a357..fa9670e76 100644 --- a/package.json +++ b/package.json @@ -131,7 +131,7 @@ "modern-normalize": "^3.0.1", "react": "^19.0.0 || ^18.0.0 || ^17.0.0", "react-dom": "^19.0.0 || ^18.0.0 || ^17.0.0", - "stream-chat": "^10.0.0-rc.13" + "stream-chat": "^10.0.0-rc.14" }, "peerDependenciesMeta": { "@breezystack/lamejs": { @@ -201,7 +201,7 @@ "react-dom": "^19.2.6", "sass": "^1.100.0", "semantic-release": "^25.0.3", - "stream-chat": "10.0.0-rc.13", + "stream-chat": "10.0.0-rc.14", "typescript": "^6.0.3", "typescript-eslint": "^8.59.4", "vite": "^8.1.3", diff --git a/src/components/Accessibility/__tests__/NotificationAnnouncer.test.tsx b/src/components/Accessibility/__tests__/NotificationAnnouncer.test.tsx index 257fa7bd2..1f02b65ba 100644 --- a/src/components/Accessibility/__tests__/NotificationAnnouncer.test.tsx +++ b/src/components/Accessibility/__tests__/NotificationAnnouncer.test.tsx @@ -12,7 +12,7 @@ import { useNotifications } from '../../Notifications/hooks/useNotifications'; import { TranslationProvider } from '../../../context'; import { mockTranslationContextValue } from 'mock-builders'; -import type { Notification } from '../../../../../stream-chat-js/src'; +import type { Notification } from 'stream-chat'; import { mockT } from '../../../mock-builders/translator'; vi.mock('../../Notifications/hooks/useNotifications', () => ({ diff --git a/src/components/Attachment/__tests__/Geolocation.test.tsx b/src/components/Attachment/__tests__/Geolocation.test.tsx index ab69d7561..af71d815f 100644 --- a/src/components/Attachment/__tests__/Geolocation.test.tsx +++ b/src/components/Attachment/__tests__/Geolocation.test.tsx @@ -10,7 +10,7 @@ import { initClientWithChannels, } from '../../../mock-builders'; import type { Channel as ChannelType, StreamChat } from 'stream-chat'; -import { msToNs, nowNs } from 'stream-chat'; +import { asTimestampNS, msToNs, nowNs } from 'stream-chat'; import { convertDateToTimestamp } from '../../../mock-builders/generator/time'; const GeolocationMapComponent = (props) => ( @@ -116,7 +116,7 @@ describe.each([ it('renders own live location', async () => { const location = generateLiveLocationResponse({ - end_at: nowNs() + msToNs(10000), + end_at: asTimestampNS(nowNs() + msToNs(10000)), user_id: ownUser.id, }); await renderComponent({ @@ -142,7 +142,7 @@ describe.each([ }); it("other user's live location", async () => { const location = generateLiveLocationResponse({ - end_at: nowNs() + msToNs(10000), + end_at: asTimestampNS(nowNs() + msToNs(10000)), user_id: otherUser.id, }); await renderComponent({ diff --git a/src/components/Message/__tests__/MessageTimestamp.test.tsx b/src/components/Message/__tests__/MessageTimestamp.test.tsx index a65f089d9..11e562cb6 100644 --- a/src/components/Message/__tests__/MessageTimestamp.test.tsx +++ b/src/components/Message/__tests__/MessageTimestamp.test.tsx @@ -169,9 +169,7 @@ describe('', () => { props: { format: 'YYYY' }, }); expect(container).toHaveTextContent( - nsToDate(messageMock.created_at as unknown as number) - .getFullYear() - .toString(), + nsToDate(messageMock.created_at).getFullYear().toString(), ); }); @@ -188,9 +186,7 @@ describe('', () => { props: { format: 'YYYY' }, }); expect(container).toHaveTextContent( - nsToDate(messageMock.created_at as unknown as number) - .getFullYear() - .toString(), + nsToDate(messageMock.created_at).getFullYear().toString(), ); }); diff --git a/src/components/Message/__tests__/ReminderNotification.test.tsx b/src/components/Message/__tests__/ReminderNotification.test.tsx index d87ba551d..6c8abfe01 100644 --- a/src/components/Message/__tests__/ReminderNotification.test.tsx +++ b/src/components/Message/__tests__/ReminderNotification.test.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Reminder } from 'stream-chat'; +import { asTimestampNS, Reminder } from 'stream-chat'; import { act, render, type RenderResult } from '@testing-library/react'; import { Chat } from '../../Chat'; import { ReminderNotification } from '../ReminderNotification'; @@ -41,7 +41,7 @@ describe('ReminderNotification', () => { // truthiness guard renders "Saved for later" for what is really a long-overdue reminder. const reminder = new Reminder({ data: generateReminderResponse({ - data: { remind_at: 0 }, + data: { remind_at: asTimestampNS(0) }, }), }); const { container } = await renderComponent({ reminder }); diff --git a/src/components/MessageList/MessageList.tsx b/src/components/MessageList/MessageList.tsx index 6e7c0621a..c0e49b051 100644 --- a/src/components/MessageList/MessageList.tsx +++ b/src/components/MessageList/MessageList.tsx @@ -39,6 +39,7 @@ import type { LocalMessage, MessageFocusSignalState, MessagePaginatorState, + TimestampNS, UnreadSnapshotState, } from 'stream-chat'; import type { GroupStyle, ProcessMessagesParams, RenderedMessage } from './utils'; @@ -469,7 +470,7 @@ export type MessageListProps = Partial * Position to render HeaderComponent, as a timestamp in the same unit as `message.created_at` — * i.e. unix nanoseconds. Was milliseconds while `created_at` was a `Date`. */ - headerPosition?: number; + headerPosition?: TimestampNS; // todo: data manipulation - should live in MessagePaginator /** Hides the MessageDeleted components from the list, defaults to `false` */ hideDeletedMessages?: boolean; diff --git a/src/components/MessageList/VirtualizedMessageList.tsx b/src/components/MessageList/VirtualizedMessageList.tsx index d06711fc4..2f8ab2490 100644 --- a/src/components/MessageList/VirtualizedMessageList.tsx +++ b/src/components/MessageList/VirtualizedMessageList.tsx @@ -71,6 +71,7 @@ import type { MessageFocusSignalState, MessagePaginatorState, ChannelState as StreamChannelState, + TimestampNS, UnreadSnapshotState, UserResponse, } from 'stream-chat'; @@ -130,7 +131,7 @@ export type VirtuosoContext = Required< /** Message id which was marked as unread. ALl the messages following this message are considered unrea. */ firstUnreadMessageId: string | null; /** Unix nanoseconds, as `messagePaginator.unreadStateSnapshot.lastReadAt` carries it. */ - lastReadDate: number | null; + lastReadDate: TimestampNS | null; /** * The ID of the last message considered read by the current user in the current channel. * All the messages following this message are considered unread. diff --git a/src/components/MessageList/__tests__/utils.test.ts b/src/components/MessageList/__tests__/utils.test.ts index c4ae8a345..ed01165da 100644 --- a/src/components/MessageList/__tests__/utils.test.ts +++ b/src/components/MessageList/__tests__/utils.test.ts @@ -14,8 +14,9 @@ import { makeDateMessageId, processMessages, } from '../utils'; +import type { ProcessMessagesParams } from '../utils'; import { CUSTOM_MESSAGE_TYPE } from '../../../constants/messageTypes'; -import { convertTimestampToDate, msToNs } from 'stream-chat'; +import { asTimestampNS, convertTimestampToDate, msToNs, nowNs } from 'stream-chat'; import { convertDateToTimestamp } from '../../../mock-builders'; const mockedNanoId = 'V1StGXR8_Z5jdHi6B-myT'; @@ -68,7 +69,10 @@ const msgCreationDatesSecondInvalid = [ }, ]; -const runMessageProcessing = (msgData, processMsgParams = {}) => { +const runMessageProcessing = ( + msgData: Parameters[0][], + processMsgParams: Partial = {}, +) => { const messages = msgData.map((msg) => generateMessage(msg)); return { messages, @@ -385,7 +389,7 @@ describe('processMessages', () => { describe('for unread messages', () => { const expectedWhere = ['start']; const shouldExpectUnreadSeparator = true; - const lastRead = new Date(); + const lastRead = nowNs(); const oldMsg = { created_at: convertDateToTimestamp(new Date('1970-01-01')), updated_at: convertDateToTimestamp(new Date('1970-01-01')), @@ -547,7 +551,7 @@ describe('processMessages', () => { const [separator] = processMessages({ ...withDateSeparatorParams, // The epoch as "nothing read yet", so the message counts as unread. - lastRead: 0, + lastRead: asTimestampNS(0), messages: [message], userId: myUserId, }); @@ -837,7 +841,7 @@ describe('getGroupStyles', () => { describe('with a message created at the epoch', () => { it('applies the cutoff when the previous message is at the epoch', () => { const maxTimeBetweenGroupedMessages = 10; - previousMessage = { ...previousMessage, created_at: 0 }; + previousMessage = { ...previousMessage, created_at: asTimestampNS(0) }; message = { ...message, created_at: msToNs(12) }; // 12ms apart, so the previous message must not be grouped with this one. A truthiness guard @@ -855,7 +859,7 @@ describe('getGroupStyles', () => { it('applies the cutoff when the message itself is at the epoch', () => { const maxTimeBetweenGroupedMessages = 10; - message = { ...message, created_at: 0 }; + message = { ...message, created_at: asTimestampNS(0) }; nextMessage = { ...nextMessage, created_at: msToNs(12) }; // The symmetric branch: a truthiness guard reports 'middle' and glues the next message on. @@ -875,7 +879,7 @@ describe('getGroupStyles', () => { describe('insertIntro', () => { // `headerPosition` is a public prop compared against `message.created_at`, so unix nanoseconds. const NS_PER_MS = 1e6; - const at = (iso: string) => Date.parse(iso) * NS_PER_MS; + const at = (iso: string) => asTimestampNS(Date.parse(iso) * NS_PER_MS); const msg = (iso: string, id: string) => fromPartial({ created_at: at(iso), id, status: 'received' }); const isIntro = (entry: unknown) => @@ -894,7 +898,7 @@ describe('insertIntro', () => { it('puts the intro at the top when the position precedes every message', () => { // Asserts the whole list, not just `[0]`: a dropped intro and a moved one both satisfy // `isIntro(result[0]) === false`. - const result = insertIntro([msg('2026-01-02T00:00:00Z', 'a')], 0); + const result = insertIntro([msg('2026-01-02T00:00:00Z', 'a')], asTimestampNS(0)); expect(result.map((m) => (isIntro(m) ? 'intro' : m.id))).toEqual(['intro', 'a']); }); @@ -919,18 +923,19 @@ describe('insertIntro', () => { msg('2026-01-01T00:00:00Z', 'older'), msg('2026-01-03T00:00:00Z', 'newer'), ]; - // The epoch-millisecond value an integrator would have passed before the migration. + // The epoch-millisecond value an integrator would have passed before the migration. The prop is + // typed `TimestampNS`, so this only compiles when mislabelled on purpose — which is the point. const asMilliseconds = Date.parse('2026-01-02T00:00:00Z'); // A millisecond value precedes every message, so the intro lands at the top — wrong placement, // but visible rather than dropped. Nanoseconds split the list where they should. expect( - insertIntro([...messages], asMilliseconds).map((m) => + insertIntro([...messages], asTimestampNS(asMilliseconds)).map((m) => isIntro(m) ? 'intro' : m.id, ), ).toEqual(['intro', 'older', 'newer']); expect( - insertIntro([...messages], asMilliseconds * NS_PER_MS).map((m) => + insertIntro([...messages], asTimestampNS(asMilliseconds * NS_PER_MS)).map((m) => isIntro(m) ? 'intro' : m.id, ), ).toEqual(['older', 'intro', 'newer']); diff --git a/src/components/MessageList/hooks/MessageList/useEnrichedMessages.ts b/src/components/MessageList/hooks/MessageList/useEnrichedMessages.ts index e568ef24c..08c1f1d3a 100644 --- a/src/components/MessageList/hooks/MessageList/useEnrichedMessages.ts +++ b/src/components/MessageList/hooks/MessageList/useEnrichedMessages.ts @@ -6,7 +6,7 @@ import { getGroupStyles, insertIntro, processMessages } from '../../utils'; import { useChatContext } from '../../../../context/ChatContext'; import { useComponentContext } from '../../../../context/ComponentContext'; -import type { Channel, LocalMessage } from 'stream-chat'; +import type { Channel, LocalMessage, TimestampNS } from 'stream-chat'; export const useEnrichedMessages = (args: { channel: Channel; @@ -22,7 +22,7 @@ export const useEnrichedMessages = (args: { noGroupByUser: boolean, maxTimeBetweenGroupedMessages?: number, ) => GroupStyle; - headerPosition?: number; + headerPosition?: TimestampNS; maxTimeBetweenGroupedMessages?: number; reviewProcessedMessage?: ProcessMessagesParams['reviewProcessedMessage']; }) => { diff --git a/src/components/MessageList/hooks/__tests__/useUnreadMessagesNotificationVirtualized.test.tsx b/src/components/MessageList/hooks/__tests__/useUnreadMessagesNotificationVirtualized.test.tsx index 51136ea84..3ce48125c 100644 --- a/src/components/MessageList/hooks/__tests__/useUnreadMessagesNotificationVirtualized.test.tsx +++ b/src/components/MessageList/hooks/__tests__/useUnreadMessagesNotificationVirtualized.test.tsx @@ -6,7 +6,7 @@ import { generateMessage, initClientWithChannels } from '../../../../mock-builde import type { RenderedMessage } from '../../utils'; import { Chat } from '../../../Chat'; import { Channel } from '../../../Channel'; -import { msToNs, nowNs } from 'stream-chat'; +import { asTimestampNS, msToNs, nowNs } from 'stream-chat'; import { convertDateToTimestamp } from '../../../../mock-builders/generator/time'; // MERGE-RECONCILE (test migration): useUnreadMessagesNotificationVirtualized was rewritten to @@ -49,7 +49,10 @@ const render = async ({ await Promise.resolve(); }); await act(() => { - channel.messagePaginator.setUnreadSnapshot({ lastReadAt: lastRead, unreadCount }); + channel.messagePaginator.setUnreadSnapshot({ + lastReadAt: lastRead == null ? null : asTimestampNS(lastRead), + unreadCount, + }); }); return { channel, ...utils }; }; diff --git a/src/components/MessageList/utils.ts b/src/components/MessageList/utils.ts index a012935ad..5529fa46a 100644 --- a/src/components/MessageList/utils.ts +++ b/src/components/MessageList/utils.ts @@ -4,7 +4,12 @@ import { CUSTOM_MESSAGE_TYPE } from '../../constants/messageTypes'; import { isMessageEdited } from '../Message/utils'; import { isDate } from '../../i18n'; -import type { Channel, LocalMessage, UnreadSnapshotState } from 'stream-chat'; +import type { + Channel, + LocalMessage, + TimestampNS, + UnreadSnapshotState, +} from 'stream-chat'; import { convertTimestampToDate, nsToMs } from 'stream-chat'; type IntroMessage = { @@ -39,7 +44,7 @@ type ProcessMessagesContext = { /** Disable date separator display for unread incoming messages */ hideNewMessageSeparator?: boolean; /** Sets the threshold after everything is considered unread. Unix nanoseconds, as `channel.lastRead()` returns. */ - lastRead?: number | null; + lastRead?: TimestampNS | null; /** Inject date separators between messages posted on different days */ withDateSeparator?: boolean; }; @@ -213,7 +218,10 @@ export const getLastReceived = (messages: RenderedMessage[]) => { return null; }; -export const insertIntro = (messages: RenderedMessage[], headerPosition?: number) => { +export const insertIntro = ( + messages: RenderedMessage[], + headerPosition?: TimestampNS, +) => { const newMessages = messages; const intro = makeIntroMessage(); diff --git a/src/context/ChatContext.tsx b/src/context/ChatContext.tsx index a403a8350..0a997f177 100644 --- a/src/context/ChatContext.tsx +++ b/src/context/ChatContext.tsx @@ -33,7 +33,7 @@ export type ChatContextValue = { */ channelManager: ChannelManager; getAppSettings: () => ReturnType | null; - /** Newest own-message timestamp per channel, in unix nanoseconds as the API sends it. */ + /** Users muted by the current user. */ mutes: Array; /** Instance of SearchController class that allows to control all the search operations. */ searchController: SearchController; diff --git a/src/mock-builders/event/messageDelivered.ts b/src/mock-builders/event/messageDelivered.ts index dea542d1e..6ca39f2c2 100644 --- a/src/mock-builders/event/messageDelivered.ts +++ b/src/mock-builders/event/messageDelivered.ts @@ -4,6 +4,7 @@ import type { CustomEventData, Event, StreamChat, + TimestampNS, UserResponse, } from 'stream-chat'; import { convertDateToTimestamp } from '../generator/time'; @@ -16,7 +17,7 @@ type MessageDeliveredEvent = { cid: string; // `created_at` is unix nanoseconds like every other wire timestamp, but `last_delivered_at` is // the one field the spec still declares as a bare string, so it really does arrive as RFC3339. - created_at: number; + created_at: TimestampNS; custom: CustomEventData; last_delivered_at: string; last_delivered_message_id: string; diff --git a/src/mock-builders/generator/reminder.ts b/src/mock-builders/generator/reminder.ts index 164940069..c9d643e2c 100644 --- a/src/mock-builders/generator/reminder.ts +++ b/src/mock-builders/generator/reminder.ts @@ -2,7 +2,7 @@ import { fromPartial } from '@total-typescript/shoehorn'; import type { MessageResponse, ReminderResponseData, UserResponse } from 'stream-chat'; import { generateChannel } from './channel'; import { convertDateToTimestamp } from './time'; -import { msToNs } from 'stream-chat'; +import { asTimestampNS, msToNs } from 'stream-chat'; const baseData = { channel_cid: 'messaging:id', @@ -27,7 +27,7 @@ export const generateReminderResponse = ({ user: fromPartial({ id: baseData.user_id }), }; if (typeof scheduleOffsetMs === 'number') { - basePayload.remind_at = created_at + msToNs(scheduleOffsetMs); + basePayload.remind_at = asTimestampNS(created_at + msToNs(scheduleOffsetMs)); } return { ...basePayload, diff --git a/src/mock-builders/generator/time.ts b/src/mock-builders/generator/time.ts index 3a6514763..db574dd25 100644 --- a/src/mock-builders/generator/time.ts +++ b/src/mock-builders/generator/time.ts @@ -1,4 +1,5 @@ -import { dateToNs, msToNs, nowNs } from 'stream-chat'; +import type { TimestampNS } from 'stream-chat'; +import { asTimestampNS, dateToNs, msToNs, nowNs } from 'stream-chat'; /** * Normalizes whatever a test hands a generator into the unix-**nanosecond** number the API puts on @@ -10,9 +11,9 @@ import { dateToNs, msToNs, nowNs } from 'stream-chat'; * * A bare `number` is taken to be nanoseconds already, matching the SDK's unit everywhere else. */ -export const convertDateToTimestamp = (value?: Date | number | string): number => { +export const convertDateToTimestamp = (value?: Date | number | string): TimestampNS => { if (value === undefined) return nowNs(); if (value instanceof Date) return dateToNs(value); - if (typeof value === 'number') return value; + if (typeof value === 'number') return asTimestampNS(value); return msToNs(Date.parse(value)); }; diff --git a/src/plugins/ChannelDetail/Views/ChannelFilesView/ChannelFilesView.utils.ts b/src/plugins/ChannelDetail/Views/ChannelFilesView/ChannelFilesView.utils.ts index be721cac3..4a3d8ea58 100644 --- a/src/plugins/ChannelDetail/Views/ChannelFilesView/ChannelFilesView.utils.ts +++ b/src/plugins/ChannelDetail/Views/ChannelFilesView/ChannelFilesView.utils.ts @@ -3,6 +3,7 @@ import { isScrapedContent, type LocalMessage, type MessageResponse, + type TimestampNS, } from 'stream-chat'; import { convertTimestampToDate } from 'stream-chat'; @@ -47,7 +48,7 @@ export type ChannelFileSections = { * what `byCreatedAtDesc` compares. `convertTimestampToDate` rather than `new Date`: a nanosecond value is out of * Date's range, so constructing one directly yields an Invalid Date. */ -const normalizeTimestamp = (timestamp?: number) => +const normalizeTimestamp = (timestamp?: TimestampNS) => timestamp == null ? undefined : convertTimestampToDate(timestamp)?.toISOString(); const isChannelFileAttachment = (attachment: Attachment) => diff --git a/yarn.lock b/yarn.lock index 7eeb8abbf..75dfbdb39 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1861,7 +1861,7 @@ __metadata: emoji-mart: "npm:^5.6.0" react: "npm:^19.2.6" react-dom: "npm:^19.2.6" - stream-chat: "npm:10.0.0-rc.13" + stream-chat: "npm:10.0.0-rc.14" stream-chat-react: "workspace:^" typescript: "npm:^6.0.3" vite: "npm:^8.1.3" @@ -1889,7 +1889,7 @@ __metadata: react: "npm:^19.2.6" react-dom: "npm:^19.2.6" sass: "npm:^1.100.0" - stream-chat: "npm:10.0.0-rc.13" + stream-chat: "npm:10.0.0-rc.14" stream-chat-react: "workspace:^" typescript: "npm:^6.0.3" vite: "npm:^8.1.3" @@ -9551,7 +9551,7 @@ __metadata: remark-parse: "npm:^11.0.0" sass: "npm:^1.100.0" semantic-release: "npm:^25.0.3" - stream-chat: "npm:10.0.0-rc.13" + stream-chat: "npm:10.0.0-rc.14" typescript: "npm:^6.0.3" typescript-eslint: "npm:^8.59.4" unified: "npm:^11.0.5" @@ -9569,7 +9569,7 @@ __metadata: modern-normalize: ^3.0.1 react: ^19.0.0 || ^18.0.0 || ^17.0.0 react-dom: ^19.0.0 || ^18.0.0 || ^17.0.0 - stream-chat: ^10.0.0-rc.13 + stream-chat: ^10.0.0-rc.14 dependenciesMeta: "@parcel/watcher": built: true @@ -9595,9 +9595,9 @@ __metadata: languageName: unknown linkType: soft -"stream-chat@npm:10.0.0-rc.13": - version: 10.0.0-rc.13 - resolution: "stream-chat@npm:10.0.0-rc.13" +"stream-chat@npm:10.0.0-rc.14": + version: 10.0.0-rc.14 + resolution: "stream-chat@npm:10.0.0-rc.14" dependencies: "@stream-io/logger": "npm:^2.0.0" "@stream-io/state-store": "npm:^1.1.6" @@ -9608,7 +9608,7 @@ __metadata: built: true husky: built: true - checksum: 10c0/fbb9736adbe0e621063185d0fc32fe92f88111c90d171ecdd6e70370899cf6a3d240a2e944d05380266dc50284e9d533e38841706b2d0adfd19c5f7c784153f7 + checksum: 10c0/2db57b3297da1d35ef208189b7be12940f418854fd765e300f83895038ff092fa157e37f64c2ce2e82ff1dcc6bf1f7b995b5d0e59092ac3da4a44fe248ad96d5 languageName: node linkType: hard