diff --git a/CLAUDE.md b/CLAUDE.md index fa5920ccfd..b6760f2010 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -70,7 +70,7 @@ This is a single-package SDK with **no monorepo**. The public surface is everyth - **`signing.ts` — one function, `UserFromToken`.** Decodes a JWT payload with the global `atob` and returns `user_id`. Everything else this module used to hold was server-side (JWT minting via `jsonwebtoken`, webhook/SQS/SNS verification via `crypto` + `zlib`) and was removed along with those deps — see `v9-to-v10-migration-guide-server-side.md`. Do not reintroduce secret-holding or HMAC code here; that surface lives in `@stream-io/node-sdk`. - **`middleware.ts`** — `MiddlewareExecutor` (see "Middleware pipelines" below). Used by composer pipelines, not by client request lifecycle. - **`token_manager.ts`** — handles static tokens and async token providers. Tracks a `loadTokenPromise` so concurrent calls await the same fetch. The constructor takes no arguments: there is no `secret` and no local JWT signing — every token comes from the caller (a string or a `TokenProvider`). Anonymous users may have no token at all; anyone else without one now fails at `getToken()` rather than at `setTokenOrProvider()`. -- **Event types.** There is no `events.ts` / `EVENT_MAP` any more (removed in v10). Wire events come from the generated `WSEvent` union (`src/gen/models`) and are decoded by `src/gen/model-decoders/event-decoder-mapping.ts`, so adding one means regenerating rather than hand-editing. `src/types.ts` overlays two non-generated members onto the public `Event` union: `LocalEvent` — `channels.queried`, `connection.changed`, `connection.recovered`, `capabilities.changed`, `message.read_locally`, `offline_reactions.queried`, `live_location_sharing.*`, all dispatched client-side only and never received over the wire — and `ConnectedEvent` (`connection.ok`), which _is_ a wire event but is not published in the OpenAPI spec yet. +- **Event types.** There is no `events.ts` / `EVENT_MAP` any more (removed in v10). Wire events come from the generated `WSEvent` union (`src/gen/models`) so adding one means regenerating rather than hand-editing. There is no runtime decoder layer any more: `--opt response_dates_as_number` types every server-sent date as the unix-nanosecond number the wire already carries, so `src/gen/model-decoders/` (including `event-decoder-mapping.ts`) is no longer emitted at all, and `src/connection.ts` decodes frames with a plain cast. The unit invariant and its traps live in `src/utils/time.ts`; the consumer-facing delta is `v9-to-v10-migration-guide-dates.md`. `src/types.ts` overlays two non-generated members onto the public `Event` union: `LocalEvent` — `channels.queried`, `connection.changed`, `connection.recovered`, `capabilities.changed`, `message.read_locally`, `offline_reactions.queried`, `live_location_sharing.*`, all dispatched client-side only and never received over the wire — and `ConnectedEvent` (`connection.ok`), which _is_ a wire event but is not published in the OpenAPI spec yet. - **`insights.ts` — `InsightMetrics` + `postInsights`.** WS-health telemetry sent to `https://chat-insights.getstream.io`. This is internal; do not call from end-user code paths. The fields captured by `buildWsBaseInsight` include token and connection metadata — treat changes here as security-sensitive. - **`uploadManager.ts` / `LiveLocationManager.ts` / `CooldownTimer.ts`** — feature controllers, each owns its own `StateStore` slice. - **Domain subsystems** (each a folder with its own `index.ts` barrel): diff --git a/scripts/apply-custom-data-types.mts b/scripts/apply-custom-data-types.mts index 5967b1a3ab..16d00de345 100644 --- a/scripts/apply-custom-data-types.mts +++ b/scripts/apply-custom-data-types.mts @@ -59,6 +59,7 @@ const CUSTOM_DATA_MAPPING: Record = { ConnectUserDetailsRequest: 'CustomUserData', EntityCreatorResponse: 'CustomUserData', FullUserResponse: 'CustomUserData', + MemberUserRequest: 'CustomUserData', OwnUserResponse: 'CustomUserData', UserRequest: 'CustomUserData', UserResponse: 'CustomUserData', diff --git a/scripts/generate-client.sh b/scripts/generate-client.sh index 4d42aa9c5b..df399c7b92 100755 --- a/scripts/generate-client.sh +++ b/scripts/generate-client.sh @@ -6,7 +6,7 @@ CHAT_DIR="../chat" rm -rf $OUTPUT_DIR -( cd $CHAT_DIR ; make openapi ; make -C projects/chat-manager build; build/chat-manager openapi generate-client --language ts --spec releases/v2/chat-clientside-api.yaml --output $OUTPUT_DIR --opt typed_filters=true --opt with_request_options=true) +( cd $CHAT_DIR ; make openapi ; make -C projects/chat-manager build; build/chat-manager openapi generate-client --language ts --spec releases/v2/chat-clientside-api.yaml --output $OUTPUT_DIR --opt typed_filters=true --opt with_request_options=true --opt response_dates_as_number=true) # apply-custom-data-types matches `export interface` / `}` anchored to column 0, # but the generator emits them indented — format first so it can track which diff --git a/src/CooldownTimer.ts b/src/CooldownTimer.ts index c57dee068c..617a3d760f 100644 --- a/src/CooldownTimer.ts +++ b/src/CooldownTimer.ts @@ -1,4 +1,6 @@ import { StateStore } from './store'; +import { getMessageCreatedAtTimestamp } from './pagination/paginators/MessageIntervalPaginator'; +import { nowNs, nsToMs } from './utils/time'; import type { ChannelResponse, LocalMessage } from './types'; import { WithSubscriptions } from './utils/WithSubscriptions'; import type { Channel } from './channel'; @@ -13,24 +15,16 @@ export type CooldownTimerState = { */ canSkipCooldown: boolean; /** - * Latest message creation date authored by the current user in this channel. Change reported via message.new WS event. + * Creation timestamp of the latest message authored by the current user in this channel, in unix + * nanoseconds as the API sends it. Change reported via message.new WS event. */ - ownLatestMessageDate?: Date; + ownLatestMessageTimestamp?: number; /** * Remaining cooldown in whole seconds (rounded). */ cooldownRemaining: number; }; -const toDateOrUndefined = (value: unknown): Date | undefined => { - if (value instanceof Date) return value; - if (typeof value === 'string' || typeof value === 'number') { - const parsed = new Date(value); - if (!Number.isNaN(parsed.getTime())) return parsed; - } - return undefined; -}; - export class CooldownTimer extends WithSubscriptions { public readonly state: StateStore; private timeout: ReturnType | null = null; @@ -42,7 +36,7 @@ export class CooldownTimer extends WithSubscriptions { this.state = new StateStore({ cooldownConfigSeconds: 0, cooldownRemaining: 0, - ownLatestMessageDate: undefined, + ownLatestMessageTimestamp: undefined, canSkipCooldown: false, }); this.refresh(); @@ -60,8 +54,8 @@ export class CooldownTimer extends WithSubscriptions { return this.state.getLatestValue().canSkipCooldown; } - get ownLatestMessageDate() { - return this.state.getLatestValue().ownLatestMessageDate; + get ownLatestMessageTimestamp() { + return this.state.getLatestValue().ownLatestMessageTimestamp; } /** @@ -85,7 +79,7 @@ export class CooldownTimer extends WithSubscriptions { ), ); - // `ownLatestMessageDate` comes from the paginator's head interval. Selected on `items` rather than on + // `ownLatestMessageTimestamp` comes from the paginator's head interval. Selected on `items` rather than on // the derived date: any ingest can change which message is the own-latest, and `refresh` already // declines to publish unless one of its inputs actually moved. this.addUnsubscribeFunction( @@ -114,18 +108,18 @@ export class CooldownTimer extends WithSubscriptions { .data ?? {}) as Partial; const canSkipCooldown = (own_capabilities ?? []).includes('skip-slow-mode'); - const ownLatestMessageDate = this.findOwnLatestMessageDate({ + const ownLatestMessageTimestamp = this.findOwnLatestMessageTimestamp({ messages: this.channel.messagePaginator.headItems, }); if ( cooldownConfigSeconds !== this.cooldownConfigSeconds || - ownLatestMessageDate?.getTime() !== this.ownLatestMessageDate?.getTime() || + ownLatestMessageTimestamp !== this.ownLatestMessageTimestamp || canSkipCooldown !== this.canSkipCooldown ) { this.state.partialNext({ cooldownConfigSeconds, - ownLatestMessageDate, + ownLatestMessageTimestamp, canSkipCooldown, }); } @@ -142,11 +136,13 @@ export class CooldownTimer extends WithSubscriptions { }; /** - * Updates the known latest own message date and recomputes remaining time. - * Prefer calling this when you already know the message date (e.g. from an event). + * Updates the known latest own message timestamp and recomputes remaining time. + * Prefer calling this when you already know it (e.g. from an event). + * + * @param timestamp - Unix nanoseconds, as the API sends it. */ - public setOwnLatestMessageDate = (date: Date | undefined) => { - this.state.partialNext({ ownLatestMessageDate: date }); + public setOwnLatestMessageTimestamp = (timestamp: number | undefined) => { + this.state.partialNext({ ownLatestMessageTimestamp: timestamp }); this.recalculate(); }; @@ -155,24 +151,24 @@ export class CooldownTimer extends WithSubscriptions { return client.userId ?? client.user?.id; } - private findOwnLatestMessageDate({ + private findOwnLatestMessageTimestamp({ messages, }: { messages: LocalMessage[]; - }): Date | undefined { + }): number | undefined { const ownUserId = this.getOwnUserId(); if (!ownUserId) return undefined; - let latest: Date | undefined; + let latest: number | undefined; for (let i = messages.length - 1; i >= 0; i -= 1) { const message = messages[i]; if (message.user?.id !== ownUserId) continue; - const createdAt = toDateOrUndefined(message.created_at); - if (!createdAt) continue; - if (!latest || createdAt.getTime() > latest.getTime()) { + const createdAt = getMessageCreatedAtTimestamp(message); + if (createdAt === null) continue; + if (latest === undefined || createdAt > latest) { latest = createdAt; } - if (latest.getTime() > createdAt.getTime()) break; + if (latest > createdAt) break; } return latest; } @@ -180,13 +176,14 @@ export class CooldownTimer extends WithSubscriptions { private recalculate = () => { this.clearTimeout(); - const { cooldownConfigSeconds, ownLatestMessageDate, canSkipCooldown } = + const { cooldownConfigSeconds, ownLatestMessageTimestamp, canSkipCooldown } = this.state.getLatestValue(); const timeSinceOwnLastMessage = - ownLatestMessageDate != null - ? // prevent negative values - Math.max(0, (Date.now() - ownLatestMessageDate.getTime()) / 1000) + ownLatestMessageTimestamp != null + ? // prevent negative values. Both operands are wire timestamps, so the difference is in + // nanoseconds — convert once, here, to the seconds the cooldown config speaks. + Math.max(0, nsToMs(nowNs() - ownLatestMessageTimestamp) / 1000) : undefined; const remaining = diff --git a/src/LiveLocationManager.ts b/src/LiveLocationManager.ts index e67f9770ac..8cbb5fa3fb 100644 --- a/src/LiveLocationManager.ts +++ b/src/LiveLocationManager.ts @@ -10,6 +10,7 @@ */ import { withCancellation } from './utils/concurrency'; +import { nowNs, nsToMs } from './utils/time'; import { deepFreezeConfig } from './configuration/utils/deepFreezeConfig'; import { StateStore } from './store'; import { ConfigController } from './configuration/ConfigController'; @@ -39,19 +40,38 @@ export type LiveLocationManagerState = { messages: Map; }; -const isExpiredLocation = (location: SharedLiveLocationResponse) => { - const endTimeTimestamp = new Date(location.end_at).getTime(); +/** `setTimeout` silently clamps a longer delay to 1 ms, so longer waits are armed in steps. */ +const MAX_TIMEOUT_MS = 2 ** 31 - 1; - return endTimeTimestamp < Date.now(); -}; +/** + * Whether the manager can track this location: a finite `end_at` that has not passed. `end_at` is + * optional on the wire, and `NaN < nowNs()` is `false`, so both would otherwise read as live. + */ +const canTrackLocation = (location: SharedLocationResponseData): boolean => + typeof location.end_at === 'number' && + Number.isFinite(location.end_at) && + location.end_at >= nowNs(); + +/** + * The narrowing form, which replaces the `as` cast at the response boundary. Negating a predicate + * narrows `state.messages` values to `never`, so use `canTrackLocation` for the boolean. + */ +const isTrackableLocation = ( + location: SharedLocationResponseData, +): location is SharedLiveLocationResponse => canTrackLocation(location); + +/** + * Milliseconds from now until a live location stops sharing. Never negative. + * `end_at` is a wire timestamp, so the subtraction happens in nanoseconds and is converted once. + */ +const msUntilExpiry = (endAt: number) => Math.max(0, nsToMs(endAt - nowNs())); function isValidLiveLocationMessage( message?: MessageResponse, ): message is MessageResponse & { shared_location: SharedLiveLocationResponse } { - if (!message || message.type === 'deleted' || !message.shared_location?.end_at) - return false; + if (!message || message.type === 'deleted' || !message.shared_location) return false; - return !isExpiredLocation(message.shared_location as SharedLiveLocationResponse); + return isTrackableLocation(message.shared_location); } export type LiveLocationManagerConstructorParameters = { @@ -234,20 +254,16 @@ export class LiveLocationManager extends WithSubscriptions { const { active_live_locations } = await this.client.getUserLiveLocations(); this.state.next({ messages: new Map( - (active_live_locations as SharedLiveLocationResponse[]) - .filter((location) => !isExpiredLocation(location)) - .map((location) => [ - location.message_id, - { - ...location, - stopSharingTimeout: setTimeout( - () => { - this.unregisterMessages([location.message_id]); - }, - new Date(location.end_at).getTime() - Date.now(), - ), - }, - ]), + active_live_locations.filter(isTrackableLocation).map((location) => [ + location.message_id, + { + ...location, + stopSharingTimeout: this.scheduleStopSharing( + location.message_id, + location.end_at, + ), + }, + ]), ), ready: true, }); @@ -292,7 +308,8 @@ export class LiveLocationManager extends WithSubscriptions { const expiredLocations: string[] = []; for (const [messageId, location] of this.messages) { - if (isExpiredLocation(location)) { + // Drops an absent or non-finite `end_at` too, not just an expired one. + if (!canTrackLocation(location)) { expiredLocations.push(location.message_id); continue; } @@ -357,6 +374,38 @@ export class LiveLocationManager extends WithSubscriptions { return () => subscriptions.forEach((subscription) => subscription.unsubscribe()); } + /** + * Arms the stop-sharing timer, stepping when the expiry is beyond `MAX_TIMEOUT_MS` (~24.9 days). + * A share of any length is valid — only a minimum duration is enforced — and scheduling one in a + * single call was clamped to 1 ms, unregistering it immediately. + */ + private scheduleStopSharing( + messageId: MessageId, + endAt: number, + ): ReturnType | null { + if (!Number.isFinite(endAt)) return null; + + const remaining = msUntilExpiry(endAt); + if (remaining <= MAX_TIMEOUT_MS) { + return setTimeout(() => { + this.unregisterMessages([messageId]); + }, remaining); + } + + return setTimeout(() => { + this.state.next((currentValue) => { + const current = currentValue.messages.get(messageId); + if (!current) return currentValue; // unregistered while waiting + const messages = new Map(currentValue.messages); + messages.set(messageId, { + ...current, + stopSharingTimeout: this.scheduleStopSharing(messageId, endAt), + }); + return { ...currentValue, messages }; + }); + }, MAX_TIMEOUT_MS); + } + private registerMessage(message: MessageResponse) { if ( !this.client.userId || @@ -369,11 +418,9 @@ export class LiveLocationManager extends WithSubscriptions { const messages = new Map(currentValue.messages); messages.set(message.id, { ...message.shared_location, - stopSharingTimeout: setTimeout( - () => { - this.unregisterMessages([message.id]); - }, - new Date(message.shared_location.end_at).getTime() - Date.now(), + stopSharingTimeout: this.scheduleStopSharing( + message.id, + message.shared_location.end_at, ), }); return { diff --git a/src/channel.ts b/src/channel.ts index ddba9d2fe4..c01ff1b049 100644 --- a/src/channel.ts +++ b/src/channel.ts @@ -18,6 +18,7 @@ import { localMessageToNewMessagePayload, logChatPromiseExecution, } from './utils'; +import { msToNs, nowNs } from './utils/time'; import { normalizeUploadFile } from './upload-utils'; import type { StreamChat } from './client'; import { chatLoggerSystem } from './logger'; @@ -1178,28 +1179,6 @@ export class Channel extends ChannelApi { ); } - /** - * Adds moderators to the channel. - * - * @param members - An array of member identifiers. - * @param message - Message object for channel members notification (optional). - * @param options - Configuration to control the behavior while updating (optional, defaults to `{}`). - * @param requestOptions - Per-request options such as an abort `signal`. Never serialized - * into the request (optional). - * @returns The server response. - */ - async addModerators( - members: string[], - message?: MessageRequest, - options: ChannelUpdateOptions = {}, - requestOptions?: StreamRequestOptions, - ) { - return await this.update( - { add_moderators: members, message, ...options }, - requestOptions, - ); - } - /** * Invite members to the channel. * @@ -1250,28 +1229,6 @@ export class Channel extends ChannelApi { ); } - /** - * Removes the moderator role from channel members. - * - * @param members - An array of member identifiers. - * @param message - Message object for channel members notification (optional). - * @param options - Configuration to control the behavior while updating (optional, defaults to `{}`). - * @param requestOptions - Per-request options such as an abort `signal`. Never serialized - * into the request (optional). - * @returns The server response. - */ - async demoteModerators( - members: string[], - message?: MessageRequest, - options: ChannelUpdateOptions = {}, - requestOptions?: StreamRequestOptions, - ) { - return await this.update( - { demote_moderators: members, message, ...options }, - requestOptions, - ); - } - /** * Mutes the current channel. * @@ -1384,7 +1341,8 @@ export class Channel extends ChannelApi { /** * Returns the mute status for the current channel. * - * @returns An object of the form `{ muted: true | false, createdAt: Date | null, expiresAt: Date | null }`. + * @returns An object of the form `{ muted: true | false, createdAt: number | null, expiresAt: number | null }`, + * where the timestamps are unix nanoseconds as the API sends them. */ muteStatus() { this._checkInitialized(); @@ -1404,8 +1362,8 @@ export class Channel extends ChannelApi { const previous = this.state.getLatestValue().muteStatus; const unchanged = previous.muted === next.muted && - (previous.createdAt?.getTime() ?? null) === (next.createdAt?.getTime() ?? null) && - (previous.expiresAt?.getTime() ?? null) === (next.expiresAt?.getTime() ?? null); + (previous.createdAt ?? null) === (next.createdAt ?? null) && + (previous.expiresAt ?? null) === (next.expiresAt ?? null); if (unchanged) return; @@ -1459,7 +1417,7 @@ export class Channel extends ChannelApi { type: 'typing.start', parent_id: parentId, ...(options || {}), - created_at: new Date(), + created_at: nowNs(), custom: {}, }, }, @@ -1492,7 +1450,7 @@ export class Channel extends ChannelApi { type: 'ai_indicator.update', message_id: messageId, ai_state: state, - created_at: new Date(), + created_at: nowNs(), custom: {}, }, }, @@ -1512,7 +1470,7 @@ export class Channel extends ChannelApi { { event: { type: 'ai_indicator.clear', - created_at: new Date(), + created_at: nowNs(), custom: {}, }, }, @@ -1532,7 +1490,7 @@ export class Channel extends ChannelApi { { event: { type: 'ai_indicator.stop', - created_at: new Date(), + created_at: nowNs(), custom: {}, }, }, @@ -1566,7 +1524,7 @@ export class Channel extends ChannelApi { type: 'typing.stop', parent_id: parentId, ...(options || {}), - created_at: new Date(), + created_at: nowNs(), custom: {}, }, }, @@ -1752,7 +1710,7 @@ export class Channel extends ChannelApi { channel_id: this.id, channel_type: this.type, cid: this.cid, - created_at: new Date(), + created_at: nowNs(), last_read_message_id: this.messagePaginator.headmostItem?.id, team: this.data?.team, type: 'message.read_locally', @@ -1920,7 +1878,8 @@ export class Channel extends ChannelApi { /** * Returns the last time the user marked the channel as read. If the user never marked the channel as read, this will return `null`. * - * @returns The last-read `Date`, `null` if never read, or `undefined` if the user is unset. + * @returns The last-read timestamp in unix nanoseconds, `null` if never read, or `undefined` if + * the user is unset. */ lastRead() { const { userId } = this.getClient(); @@ -1957,11 +1916,13 @@ export class Channel extends ChannelApi { /** * Count of unread messages. * - * @param lastRead - The time that the user read a message (optional, defaults to the current user's read state). + * @param lastRead - The time that the user read a message, in unix nanoseconds (optional, + * defaults to the current user's read state). * @returns Unread count. */ - countUnread(lastRead?: Date | null) { - if (!lastRead) return this.state.unreadCount; + countUnread(lastRead?: number | null) { + // Nullish, not truthy: `0` is a legitimate wire timestamp (the epoch). + if (lastRead == null) return this.state.unreadCount; let count = 0; const latestMessages = this.messagePaginator.headItems; for (let i = 0; i < latestMessages.length; i += 1) { @@ -1988,7 +1949,7 @@ export class Channel extends ChannelApi { const message = latestMessages[i]; if ( this._countMessageAsUnread(message) && - (!lastRead || message.created_at > lastRead) && + (lastRead == null || message.created_at > lastRead) && message.mentioned_users?.some((user) => user.id === userId) ) { count++; @@ -2522,36 +2483,28 @@ export class Channel extends ChannelApi { case 'message.read': if ( event.user?.id && - event.created_at && + event.created_at != null && // the same event announces a thread read, which says nothing about the channel !(event.type === 'notification.mark_read' && event.thread_id) ) { const eventUser = event.user; - const readAtDate = new Date(event.created_at); - const toDate = (value?: string | Date) => - value ? (value instanceof Date ? value : new Date(value)) : undefined; + const readAt = event.created_at; const userReadState = this._upsertReadState( eventUser.id, (currentUserReadState) => { - const currentDeliveredAt = toDate(currentUserReadState?.last_delivered_at); + const currentDeliveredAt = currentUserReadState?.last_delivered_at; return { // preserve delivery information already known for user ...currentUserReadState, - ...(currentUserReadState?.last_read - ? { last_read: toDate(currentUserReadState.last_read) } - : null), - ...(currentDeliveredAt - ? { last_delivered_at: currentDeliveredAt } - : null), - last_read: readAtDate, + last_read: readAt, last_read_message_id: event.last_read_message_id, last_delivered_at: - !currentDeliveredAt || currentDeliveredAt < readAtDate - ? readAtDate + !currentDeliveredAt || currentDeliveredAt < readAt + ? readAt : currentDeliveredAt, last_delivered_message_id: - !currentDeliveredAt || currentDeliveredAt < readAtDate + !currentDeliveredAt || currentDeliveredAt < readAt ? (event.last_read_message_id ?? currentUserReadState?.last_delivered_message_id) : currentUserReadState?.last_delivered_message_id, @@ -2578,24 +2531,29 @@ export class Channel extends ChannelApi { break; case 'message.delivered': // todo: update also on thread - if (event.user?.id && event.created_at) { + if (event.user?.id && event.created_at != null) { const eventUser = event.user; const createdAt = event.created_at; - const toDate = (value?: string | Date) => - value ? (value instanceof Date ? value : new Date(value)) : undefined; - const resolvedDeliveredAt = new Date(event.last_delivered_at ?? createdAt); + // `last_delivered_at` is the one timestamp the spec still declares as a bare `type: + // string` with no `format: date-time`, so it arrives as RFC3339 while `created_at` on the + // very same event arrives as unix nanoseconds. Normalize it into the wire unit so the + // comparisons below are unit-consistent; an absent or unparseable value falls back to + // `created_at`, which is already in that unit. + // TODO: report upstream — this field should be a `date-time` like every other timestamp. + const parsedDeliveredAt = event.last_delivered_at + ? Date.parse(event.last_delivered_at) + : NaN; + const resolvedDeliveredAt = Number.isFinite(parsedDeliveredAt) + ? msToNs(parsedDeliveredAt) + : createdAt; const userReadState = this._upsertReadState( eventUser.id, (currentUserReadState) => { - const currentDeliveredAt = toDate(currentUserReadState?.last_delivered_at); - const currentReadAt = toDate(currentUserReadState?.last_read); + const currentDeliveredAt = currentUserReadState?.last_delivered_at; + const currentReadAt = currentUserReadState?.last_read; return { ...currentUserReadState, - ...(currentReadAt ? { last_read: currentReadAt } : null), - ...(currentDeliveredAt - ? { last_delivered_at: currentDeliveredAt } - : null), last_delivered_at: currentDeliveredAt && currentDeliveredAt > resolvedDeliveredAt ? currentDeliveredAt @@ -2606,7 +2564,7 @@ export class Channel extends ChannelApi { : event.last_delivered_message_id, user: eventUser, // delivery events can be received before read events - last_read: currentReadAt ?? new Date(createdAt), + last_read: currentReadAt ?? createdAt, unread_messages: currentUserReadState?.unread_messages ?? 0, }; }, @@ -2657,7 +2615,7 @@ export class Channel extends ChannelApi { break; case 'user.messages.deleted': if (event.user) { - const deletedAt = new Date(event.created_at ?? Date.now()); + const deletedAt = event.created_at ?? nowNs(); const hardDelete = !!event.hard_delete; this.messagePaginator.applyMessageDeletionForUser({ userId: event.user.id, @@ -2707,7 +2665,7 @@ export class Channel extends ChannelApi { if (event.user?.id) { const eventUser = event.user; const eventUserId = eventUser.id; - const createdAt = new Date(event.created_at ?? Date.now()); + const createdAt = event.created_at ?? nowNs(); const eventMessageId = event.message.id; const ownUserId = client.userId; this._patchReadState( @@ -2745,7 +2703,7 @@ export class Channel extends ChannelApi { // every "no last read" consumer already treats a missing value. if (ownUserId && countsAsOwnUnread && !currentReadState[ownUserId]) { nextReadState[ownUserId] = { - last_read: new Date(0), + last_read: 0, unread_messages: 1, user: (client.user ?? { id: ownUserId }) as UserResponse, }; @@ -2780,18 +2738,17 @@ export class Channel extends ChannelApi { } } break; - case 'channel.truncated': - if (event.channel?.truncated_at) { - const truncatedAtDate = new Date(event.channel.truncated_at); - - this._setOwnUnreadCount(this.countUnread(truncatedAtDate)); + case 'channel.truncated': { + const truncatedAt = event.channel?.truncated_at; + if (truncatedAt != null) { + this._setOwnUnreadCount(this.countUnread(truncatedAt)); // Partial truncation: keep messages newer than the cutoff. clearStateAndCache would wipe // the whole paginator (readers now source from it), so use the partial truncate. The // channel-wide read/unread context is reset by the truncation, so drop the unread snapshot // too (clearStateAndCache did this for the full-truncate branch). - this.messagePaginator.truncate({ truncatedAt: truncatedAtDate }); + this.messagePaginator.truncate({ truncatedAt }); this.messagePaginator.clearUnreadSnapshot(); - this.pinnedMessagesPaginator.truncate({ truncatedAt: truncatedAtDate }); + this.pinnedMessagesPaginator.truncate({ truncatedAt }); } else { this._setOwnUnreadCount(0); this.messagePaginator.clearStateAndCache(); @@ -2805,6 +2762,7 @@ export class Channel extends ChannelApi { } break; + } case 'member.added': case 'member.updated': { const memberCopy: ChannelMemberResponse = { @@ -2851,7 +2809,7 @@ export class Channel extends ChannelApi { break; case 'notification.mark_unread': { const ownMessage = event.user?.id === this.getClient().user?.id; - if (!ownMessage || !event.user || !event.last_read_at) break; + if (!ownMessage || !event.user || event.last_read_at == null) break; const eventUser = event.user; const lastReadAt = event.last_read_at; const unreadCount = event.unread_messages ?? 0; @@ -2861,7 +2819,7 @@ export class Channel extends ChannelApi { // keep the message delivery info ...currentUserReadState, first_unread_message_id: event.first_unread_message_id, - last_read: new Date(lastReadAt), + last_read: lastReadAt, last_read_message_id: event.last_read_message_id, user: eventUser, unread_messages: unreadCount, @@ -3073,7 +3031,7 @@ export class Channel extends ChannelApi { // that everything up to this point is not marked as unread const readUpdates: ChannelState['read'] = {}; if (userID != null) { - const last_read = this.messagePaginator.lastMessageAt || new Date(); + const last_read = this.messagePaginator.lastMessageAt ?? nowNs(); if (user) { readUpdates[user.id] = { user: user as UserResponse, @@ -3087,11 +3045,9 @@ export class Channel extends ChannelApi { if (state.read) { for (const read of state.read) { readUpdates[read.user.id] = { - last_delivered_at: read.last_delivered_at - ? new Date(read.last_delivered_at) - : undefined, + last_delivered_at: read.last_delivered_at ?? undefined, last_delivered_message_id: read.last_delivered_message_id, - last_read: new Date(read.last_read), + last_read: read.last_read, last_read_message_id: read.last_read_message_id, unread_messages: read.unread_messages ?? 0, user: read.user, diff --git a/src/channel_state.ts b/src/channel_state.ts index 6da7c5caf7..6148a38229 100644 --- a/src/channel_state.ts +++ b/src/channel_state.ts @@ -10,17 +10,20 @@ import type { } from './types'; import { AIStates } from './types'; import { formatMessage } from './utils'; +import { nowNs, nsToMs } from './utils/time'; import { StateStore } from './store'; type ChannelReadStatus = Record< string, { - last_read: Date; + /** Unix nanoseconds, as the API sends it. */ + last_read: number; unread_messages: number; user: UserResponse; first_unread_message_id?: string; last_read_message_id?: string; - last_delivered_at?: Date; + /** Unix nanoseconds, as the API sends it. */ + last_delivered_at?: number; last_delivered_message_id?: string; } >; @@ -105,8 +108,10 @@ export type ChannelDataState = { /** Whether THIS channel is muted for the current user, mirrored from `client.mutedChannels`. */ export type ChannelMuteStatus = { muted: boolean; - createdAt: Date | null; - expiresAt: Date | null; + /** Unix nanoseconds, as the API sends it. */ + createdAt: number | null; + /** Unix nanoseconds, as the API sends it. */ + expiresAt: number | null; }; /** @@ -389,14 +394,11 @@ export class ChannelState extends StateStore { * clean - Remove stale data such as users that stayed in typing state for more than 5 seconds */ clean() { - const now = new Date(); + const now = nowNs(); // prevent old users from showing up as typing for (const [userID, lastEvent] of Object.entries(this.typing)) { - const receivedAt = - typeof lastEvent.received_at === 'string' - ? new Date(lastEvent.received_at) - : lastEvent.received_at || new Date(); - if (now.getTime() - receivedAt.getTime() > 7000) { + const receivedAt = lastEvent.received_at ?? now; + if (nsToMs(now - receivedAt) > 7000) { this.removeTypingEvent(userID); this._channel.getClient().dispatchEvent({ cid: this._channel.cid, diff --git a/src/client.ts b/src/client.ts index d15a2a2b10..b18548d541 100644 --- a/src/client.ts +++ b/src/client.ts @@ -5,6 +5,7 @@ import type { AxiosInstance } from 'axios'; import axios from 'axios'; import { Channel } from './channel'; +import type { ChannelMuteStatus } from './channel_state'; import { ChannelWatchStatus } from './channel_state'; import { ClientState } from './client_state'; import { StableWSConnection } from './connection'; @@ -19,6 +20,7 @@ import { isOwnUserBaseProperty, randomId, } from './utils'; +import { nowNs } from './utils/time'; import { normalizeUploadFile } from './upload-utils'; import type { @@ -902,7 +904,7 @@ export class StreamChat extends ChatApi { } dispatchEvent = (event: Event) => { - if (!event.received_at) event.received_at = new Date(); + if (event.received_at == null) event.received_at = nowNs(); // client event handlers const postListenerCallbacks = this._handleClientEvent(event as WSEvent); @@ -1003,12 +1005,12 @@ export class StreamChat extends ChatApi { channel.messagePaginator.applyMessageDeletionForUser({ userId: user.id, hardDelete, - deletedAt: deletedAt ?? new Date(), + deletedAt: deletedAt ?? nowNs(), }); channel.pinnedMessagesPaginator.applyMessageDeletionForUser({ userId: user.id, hardDelete, - deletedAt: deletedAt ?? new Date(), + deletedAt: deletedAt ?? nowNs(), }); } } @@ -1076,7 +1078,7 @@ export class StreamChat extends ChatApi { if ( event.type === 'user.deleted' && - event.user.deleted_at && + event.user.deleted_at != null && (event.mark_messages_deleted || event.hard_delete) ) { this._deleteUserMessageReference( @@ -1216,17 +1218,17 @@ export class StreamChat extends ChatApi { } } - _muteStatus(cid: string) { - let muteStatus; + _muteStatus(cid: string): ChannelMuteStatus { + let muteStatus: ChannelMuteStatus | undefined; for (let i = 0; i < this.mutedChannels.length; i++) { const mute = this.mutedChannels[i]; if (mute.channel?.cid === cid) { muteStatus = { - muted: mute.expires - ? new Date(mute.expires).getTime() > new Date().getTime() - : true, - createdAt: mute.created_at ? new Date(mute.created_at) : new Date(), - expiresAt: mute.expires ? new Date(mute.expires) : null, + // `expires` is a wire timestamp, so it compares directly against the local clock in the + // same unit. Comparing it against `Date.now()` would make every expiry look far future. + muted: mute.expires != null ? mute.expires > nowNs() : true, + createdAt: mute.created_at ?? nowNs(), + expiresAt: mute.expires ?? null, }; break; } @@ -1721,8 +1723,12 @@ export class StreamChat extends ChatApi { getChannelByMembers = (channelType: string, custom: ChannelInput) => { // Check if the channel already exists. // Only allow 1 channel object per cid + // Mirrors the same expression in `channel.ts` (`_initializeState`), which recomputes this + // temp cid to evict the stale `activeChannels` entry once the server assigns a real id — + // the two must agree exactly. `user_id` became optional in the v2 spec while + // `MemberUserRequest.id` is required, so `{ user: { id } }` is now a valid member spec. const memberIds = (custom.members ?? []).map((member) => - typeof member === 'string' ? member : member.user_id, + typeof member === 'string' ? member : member.user_id || member.user?.id || '', ); const membersStr = memberIds.sort().join(','); const tempCid = generateChannelTempCid(channelType, memberIds); @@ -1891,16 +1897,25 @@ export class StreamChat extends ChatApi { /** * Transforms an expiration value into an ISO string. * + * A `number` is an offset in SECONDS, not a timestamp — passing a wire `pin_expires` overflows + * `Date`, which used to surface as an opaque `RangeError` from `toISOString()`. + * * @param timeoutOrExpirationDate - Expiration date or timeout. Use `number` to set the timeout * in seconds, `string` or `Date` to set the exact expiration date (optional). * @returns The expiration as an ISO string, or `null`. + * @throws If a numeric offset does not resolve to a representable date. */ _normalizeExpiration(timeoutOrExpirationDate?: null | number | string | Date) { let pinExpires: null | string = null; if (typeof timeoutOrExpirationDate === 'number') { - const now = new Date(); - now.setSeconds(now.getSeconds() + timeoutOrExpirationDate); - pinExpires = now.toISOString(); + const expiresAt = new Date(); + expiresAt.setSeconds(expiresAt.getSeconds() + timeoutOrExpirationDate); + if (Number.isNaN(expiresAt.getTime())) { + throw new Error( + `Expiration offset ${timeoutOrExpirationDate} does not resolve to a valid date`, + ); + } + pinExpires = expiresAt.toISOString(); } else if (isString(timeoutOrExpirationDate)) { pinExpires = timeoutOrExpirationDate; } else if (timeoutOrExpirationDate instanceof Date) { diff --git a/src/connection.ts b/src/connection.ts index 19a1cd4440..6752ee02f4 100644 --- a/src/connection.ts +++ b/src/connection.ts @@ -16,25 +16,26 @@ import { chatLoggerSystem } from './logger'; import type { ConnectAPIResponse, ConnectedEvent, ConnectionOpen } from './types'; import type { StreamChat } from './client'; import type { APIError } from './errors'; -import { decoders } from './gen/model-decoders/decoders'; -import { decodeWSEvent } from './gen/model-decoders/event-decoder-mapping'; import type { WSEvent } from './gen/models'; const logger = chatLoggerSystem.getLogger('connection'); /** - * `connection.ok` is not published in the OpenAPI spec yet, so `decodeWSEvent` passes - * it through raw and its `created_at` / `me` fields would stay strings. Its payload is - * field-for-field what the `HealthCheckEvent` decoder already handles. + * Wire frames are handed through as they arrive. Every server-sent date is the unix-nanosecond + * number the API puts on the wire (the generator runs with `response_dates_as_number=true`), so + * there is nothing left to decode — the per-model decoders that used to turn those numbers into + * `Date` objects no longer exist. * - * Remove this once the backend adds the event to the spec and `src/gen` is regenerated. + * `connection.ok` is still not published in the OpenAPI spec, so it is typed by the hand-written + * `ConnectedEvent` overlay in `types.ts` rather than by `src/gen`. Remove that overlay, and this + * branch, once the backend adds the event to the spec and `src/gen` is regenerated. */ const decodeConnectionEvent = ( data: { type: string } & Record, ): WSEvent | ConnectedEvent => data.type === 'connection.ok' - ? (decoders.HealthCheckEvent(data) as ConnectedEvent) - : (decodeWSEvent(data) as WSEvent); + ? (data as unknown as ConnectedEvent) + : (data as unknown as WSEvent); // Type guards to check WebSocket error type const isCloseEvent = ( diff --git a/src/constants.ts b/src/constants.ts index 15515100cc..f7b5350944 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -4,10 +4,11 @@ export const DEFAULT_UPLOAD_SIZE_LIMIT_BYTES = 100 * 1024 * 1024; // 100 MB export const API_MAX_FILES_ALLOWED_PER_MESSAGE = 10; export const MAX_CHANNEL_MEMBER_COUNT_IN_CHANNEL_QUERY = 100; export const RESERVED_UPDATED_MESSAGE_FIELDS = Object.freeze({ - // Dates should not be converted back to ISO strings as JS looses precision on them (milliseconds) + // Not `MessageRequest` fields at all. Date fields that *are* (pinned_at, pin_expires, + // shared_location) must not be added here — stripping them clears them server-side. created_at: true, deleted_at: true, - pinned_at: true, + message_text_updated_at: true, updated_at: true, command: true, // Back-end enriches these fields diff --git a/src/entityStore/applyReactionLocally.ts b/src/entityStore/applyReactionLocally.ts index bdfca2c728..c6c104fca4 100644 --- a/src/entityStore/applyReactionLocally.ts +++ b/src/entityStore/applyReactionLocally.ts @@ -1,5 +1,6 @@ import type { StreamChat } from '../client'; -import type { ReactionRequest, ReactionResponse, UserResponse } from '../types'; +import type { ReactionResponse, UserResponse } from '../types'; +import { nowNs } from '../utils/time'; import { computeOwnReactions, messageWithReactionAdded, @@ -33,7 +34,13 @@ export const applyReactionLocally = ( removed = false, }: { messageId: string; - reaction: ReactionRequest; + /** + * A response-shaped partial the caller already holds — a captured reaction being re-applied by + * `undo()`, or a freshly composed one. Response-shaped rather than `ReactionRequest` because its + * timestamps are the wire's numbers and it flows straight into the message store and the offline + * DB, both of which speak `ReactionResponse`. + */ + reaction: Partial & Pick; enforceUnique?: boolean; removed?: boolean; }, @@ -43,7 +50,7 @@ export const applyReactionLocally = ( const existing = store.get(messageId); if (!user || !existing) return; - const now = new Date(); + const now = nowNs(); // Spread `reaction` first so the authoritative fields below win, while still preserving any values // the caller already carried (e.g. the original `created_at` when undo re-applies a captured // reaction) via `?? now`. `message_id`/`user`/`user_id` are always derived from this message and diff --git a/src/gen/chat/ChatApi.ts b/src/gen/chat/ChatApi.ts index 95bd3da9f2..2a27d60884 100644 --- a/src/gen/chat/ChatApi.ts +++ b/src/gen/chat/ChatApi.ts @@ -18,6 +18,7 @@ import type { CreatePollOptionRequest, CreatePollRequest, CreateReminderRequest, + CreateReminderResponse, CreateUserGroupRequest, CreateUserGroupResponse, DeleteChannelResponse, @@ -88,7 +89,6 @@ import type { QueryThreadsResponse, QueryUsersPayload, QueryUsersResponse, - ReminderResponseData, RemoveUserGroupMembersRequest, RemoveUserGroupMembersResponse, Response, @@ -149,7 +149,6 @@ import type { WrappedUnreadCountsResponse, WSAuthMessage, } from '../models'; -import { decoders } from '../model-decoders/decoders'; export class ChatApi { constructor(public readonly apiClient: ApiClient) {} @@ -161,8 +160,6 @@ export class ChatApi { StreamResponse >('GET', '/api/v2/app', undefined, undefined, undefined, undefined, requestOptions); - decoders['GetApplicationResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -188,8 +185,6 @@ export class ChatApi { requestOptions, ); - decoders['ListBlockListResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -221,8 +216,6 @@ export class ChatApi { requestOptions, ); - decoders['CreateBlockListResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -250,8 +243,6 @@ export class ChatApi { requestOptions, ); - decoders['ImportBlockListResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -276,8 +267,6 @@ export class ChatApi { requestOptions, ); - decoders['Response']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -309,8 +298,6 @@ export class ChatApi { requestOptions, ); - decoders['UpdateBlockListResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -349,8 +336,6 @@ export class ChatApi { requestOptions, ); - decoders['QueryChannelsResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -376,8 +361,6 @@ export class ChatApi { requestOptions, ); - decoders['DeleteChannelsResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -402,8 +385,6 @@ export class ChatApi { requestOptions, ); - decoders['MarkDeliveredResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -433,8 +414,6 @@ export class ChatApi { requestOptions, ); - decoders['GroupedQueryChannelsResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -457,8 +436,6 @@ export class ChatApi { requestOptions, ); - decoders['MarkReadResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -497,8 +474,6 @@ export class ChatApi { requestOptions, ); - decoders['ChannelStateResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -526,8 +501,6 @@ export class ChatApi { requestOptions, ); - decoders['DeleteChannelResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -575,8 +548,6 @@ export class ChatApi { requestOptions, ); - decoders['ChannelStateResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -605,8 +576,6 @@ export class ChatApi { requestOptions, ); - decoders['UpdateChannelPartialResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -627,9 +596,6 @@ export class ChatApi { skip_push: request?.skip_push, add_filter_tags: request?.add_filter_tags, add_members: request?.add_members, - add_moderators: request?.add_moderators, - assign_roles: request?.assign_roles, - demote_moderators: request?.demote_moderators, invites: request?.invites, remove_filter_tags: request?.remove_filter_tags, remove_members: request?.remove_members, @@ -649,8 +615,6 @@ export class ChatApi { requestOptions, ); - decoders['UpdateChannelResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -676,8 +640,6 @@ export class ChatApi { requestOptions, ); - decoders['Response']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -703,8 +665,6 @@ export class ChatApi { requestOptions, ); - decoders['GetDraftResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -732,8 +692,6 @@ export class ChatApi { requestOptions, ); - decoders['CreateDraftResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -759,8 +717,6 @@ export class ChatApi { requestOptions, ); - decoders['EventResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -786,8 +742,6 @@ export class ChatApi { requestOptions, ); - decoders['Response']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -816,8 +770,6 @@ export class ChatApi { requestOptions, ); - decoders['UploadChannelFileResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -845,8 +797,6 @@ export class ChatApi { requestOptions, ); - decoders['HideChannelResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -872,8 +822,6 @@ export class ChatApi { requestOptions, ); - decoders['Response']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -903,8 +851,6 @@ export class ChatApi { requestOptions, ); - decoders['UploadChannelResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -933,8 +879,6 @@ export class ChatApi { requestOptions, ); - decoders['UpdateMemberPartialResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -967,8 +911,6 @@ export class ChatApi { requestOptions, ); - decoders['SendMessageResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -1002,8 +944,6 @@ export class ChatApi { requestOptions, ); - decoders['GetManyMessagesResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -1061,8 +1001,6 @@ export class ChatApi { requestOptions, ); - decoders['GetPinnedMessagesResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -1106,8 +1044,6 @@ export class ChatApi { requestOptions, ); - decoders['ChannelStateResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -1134,8 +1070,6 @@ export class ChatApi { requestOptions, ); - decoders['MarkReadResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -1161,8 +1095,6 @@ export class ChatApi { requestOptions, ); - decoders['ShowChannelResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -1193,8 +1125,6 @@ export class ChatApi { requestOptions, ); - decoders['Response']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -1226,8 +1156,6 @@ export class ChatApi { requestOptions, ); - decoders['TruncateChannelResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -1255,8 +1183,6 @@ export class ChatApi { requestOptions, ); - decoders['Response']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -1285,8 +1211,6 @@ export class ChatApi { requestOptions, ); - decoders['QueryDraftsResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -1308,8 +1232,6 @@ export class ChatApi { requestOptions, ); - decoders['MembersResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -1337,8 +1259,6 @@ export class ChatApi { requestOptions, ); - decoders['DeleteMessageResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -1360,8 +1280,6 @@ export class ChatApi { requestOptions, ); - decoders['GetMessageResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -1390,8 +1308,6 @@ export class ChatApi { requestOptions, ); - decoders['UpdateMessageResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -1421,8 +1337,6 @@ export class ChatApi { requestOptions, ); - decoders['UpdateMessagePartialResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -1449,8 +1363,6 @@ export class ChatApi { requestOptions, ); - decoders['MessageActionResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -1479,8 +1391,6 @@ export class ChatApi { requestOptions, ); - decoders['SendReactionResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -1505,8 +1415,6 @@ export class ChatApi { requestOptions, ); - decoders['DeleteReactionResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -1534,8 +1442,6 @@ export class ChatApi { requestOptions, ); - decoders['GetReactionsResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -1566,8 +1472,6 @@ export class ChatApi { requestOptions, ); - decoders['QueryReactionsResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -1594,8 +1498,6 @@ export class ChatApi { requestOptions, ); - decoders['MessageActionResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -1621,8 +1523,6 @@ export class ChatApi { requestOptions, ); - decoders['PollVoteResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -1646,8 +1546,6 @@ export class ChatApi { requestOptions, ); - decoders['PollVoteResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -1671,8 +1569,6 @@ export class ChatApi { requestOptions, ); - decoders['DeleteReminderResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -1699,15 +1595,13 @@ export class ChatApi { requestOptions, ); - decoders['UpdateReminderResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } async createReminder( request: CreateReminderRequest & { message_id: string }, requestOptions?: StreamRequestOptions, - ): Promise> { + ): Promise> { const pathParams = { message_id: request?.message_id, }; @@ -1716,7 +1610,7 @@ export class ChatApi { }; const response = await this.apiClient.sendRequest< - StreamResponse + StreamResponse >( 'POST', '/api/v2/chat/messages/{message_id}/reminders', @@ -1727,8 +1621,6 @@ export class ChatApi { requestOptions, ); - decoders['ReminderResponseData']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -1770,8 +1662,6 @@ export class ChatApi { requestOptions, ); - decoders['GetRepliesResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -1795,8 +1685,6 @@ export class ChatApi { requestOptions, ); - decoders['QueryMessageFlagsResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -1822,8 +1710,6 @@ export class ChatApi { requestOptions, ); - decoders['MuteChannelResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -1847,8 +1733,6 @@ export class ChatApi { requestOptions, ); - decoders['UnmuteResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -1872,8 +1756,6 @@ export class ChatApi { requestOptions, ); - decoders['QueryBannedUsersResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -1897,8 +1779,6 @@ export class ChatApi { requestOptions, ); - decoders['QueryFutureChannelBansResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -1927,8 +1807,6 @@ export class ChatApi { requestOptions, ); - decoders['QueryRemindersResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -1950,8 +1828,6 @@ export class ChatApi { requestOptions, ); - decoders['SearchResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -1983,8 +1859,6 @@ export class ChatApi { requestOptions, ); - decoders['SyncResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -2019,8 +1893,6 @@ export class ChatApi { requestOptions, ); - decoders['QueryThreadsResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -2056,8 +1928,6 @@ export class ChatApi { requestOptions, ); - decoders['GetThreadResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -2085,8 +1955,6 @@ export class ChatApi { requestOptions, ); - decoders['UpdateThreadPartialResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -2105,8 +1973,6 @@ export class ChatApi { requestOptions, ); - decoders['WrappedUnreadCountsResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -2128,8 +1994,6 @@ export class ChatApi { requestOptions, ); - decoders['Response']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -2148,8 +2012,6 @@ export class ChatApi { requestOptions, ); - decoders['ListDevicesResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -2176,8 +2038,6 @@ export class ChatApi { requestOptions, ); - decoders['Response']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -2202,8 +2062,6 @@ export class ChatApi { requestOptions, ); - decoders['CreateGuestResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -2226,8 +2084,6 @@ export class ChatApi { requestOptions, ); - decoders['{}']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -2249,8 +2105,6 @@ export class ChatApi { requestOptions, ); - decoders['GetOGResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -2283,8 +2137,6 @@ export class ChatApi { requestOptions, ); - decoders['PollResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -2317,8 +2169,6 @@ export class ChatApi { requestOptions, ); - decoders['PollResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -2345,8 +2195,6 @@ export class ChatApi { requestOptions, ); - decoders['QueryPollsResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -2368,8 +2216,6 @@ export class ChatApi { requestOptions, ); - decoders['Response']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -2391,8 +2237,6 @@ export class ChatApi { requestOptions, ); - decoders['PollResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -2418,8 +2262,6 @@ export class ChatApi { requestOptions, ); - decoders['PollResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -2445,8 +2287,6 @@ export class ChatApi { requestOptions, ); - decoders['PollOptionResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -2473,8 +2313,6 @@ export class ChatApi { requestOptions, ); - decoders['PollOptionResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -2497,8 +2335,6 @@ export class ChatApi { requestOptions, ); - decoders['Response']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -2521,8 +2357,6 @@ export class ChatApi { requestOptions, ); - decoders['PollOptionResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -2551,8 +2385,6 @@ export class ChatApi { requestOptions, ); - decoders['PollVotesResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -2577,8 +2409,6 @@ export class ChatApi { requestOptions, ); - decoders['UpsertPushPreferencesResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -2612,8 +2442,6 @@ export class ChatApi { requestOptions, ); - decoders['SearchRolesResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -2635,8 +2463,6 @@ export class ChatApi { requestOptions, ); - decoders['Response']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -2660,8 +2486,6 @@ export class ChatApi { requestOptions, ); - decoders['FileUploadResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -2683,8 +2507,6 @@ export class ChatApi { requestOptions, ); - decoders['Response']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -2711,8 +2533,6 @@ export class ChatApi { requestOptions, ); - decoders['ImageUploadResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -2744,8 +2564,6 @@ export class ChatApi { requestOptions, ); - decoders['ListUserGroupsResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -2774,8 +2592,6 @@ export class ChatApi { requestOptions, ); - decoders['CreateUserGroupResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -2809,8 +2625,6 @@ export class ChatApi { requestOptions, ); - decoders['SearchUserGroupsResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -2835,8 +2649,6 @@ export class ChatApi { requestOptions, ); - decoders['Response']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -2863,8 +2675,6 @@ export class ChatApi { requestOptions, ); - decoders['GetUserGroupResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -2893,8 +2703,6 @@ export class ChatApi { requestOptions, ); - decoders['UpdateUserGroupResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -2923,8 +2731,6 @@ export class ChatApi { requestOptions, ); - decoders['AddUserGroupMembersResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -2952,8 +2758,6 @@ export class ChatApi { requestOptions, ); - decoders['RemoveUserGroupMembersResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -2975,8 +2779,6 @@ export class ChatApi { requestOptions, ); - decoders['QueryUsersResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -3001,8 +2803,6 @@ export class ChatApi { requestOptions, ); - decoders['UpdateUsersResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -3027,8 +2827,6 @@ export class ChatApi { requestOptions, ); - decoders['UpdateUsersResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -3047,8 +2845,6 @@ export class ChatApi { requestOptions, ); - decoders['GetBlockedUsersResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -3071,8 +2867,6 @@ export class ChatApi { requestOptions, ); - decoders['BlockUsersResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -3091,8 +2885,6 @@ export class ChatApi { requestOptions, ); - decoders['SharedLocationsResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -3120,8 +2912,6 @@ export class ChatApi { requestOptions, ); - decoders['SharedLocationResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -3146,8 +2936,6 @@ export class ChatApi { requestOptions, ); - decoders['UnblockUsersResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } } diff --git a/src/gen/model-decoders/decoders.ts b/src/gen/model-decoders/decoders.ts deleted file mode 100644 index 5d943bf236..0000000000 --- a/src/gen/model-decoders/decoders.ts +++ /dev/null @@ -1,2710 +0,0 @@ -type Decoder = (i: any) => any; - -type TypeMapping = Record; - -export const decoders: Record = {}; - -const decodeDatetimeType = (input: number | string) => - typeof input === 'number' ? new Date(Math.floor(input / 1000000)) : new Date(input); - -decoders.DatetimeType = decodeDatetimeType; - -const decode = (typeMappings: TypeMapping, input?: Record) => { - if (!input || Object.keys(typeMappings).length === 0) return input; - - Object.keys(typeMappings).forEach((key) => { - if (input[key] != null) { - if (typeMappings[key]) { - const decoder = decoders[typeMappings[key].type]; - if (decoder) { - if (typeMappings[key].isSingle) { - input[key] = decoder(input[key]); - } else { - Object.keys(input[key]).forEach((k) => { - input[key][k] = decoder(input[key][k]); - }); - } - } - } - } - }); - - return input; -}; - -decoders['AIIndicatorClearEvent'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - received_at: { type: 'DatetimeType', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['AIIndicatorStopEvent'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - received_at: { type: 'DatetimeType', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['AIIndicatorUpdateEvent'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - received_at: { type: 'DatetimeType', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['ActionLogResponse'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - review_queue_item: { type: 'ReviewQueueItemResponse', isSingle: true }, - - target_user: { type: 'UserResponse', isSingle: true }, - - user: { type: 'UserResponse', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['AddUserGroupMembersResponse'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - user_group: { type: 'UserGroupResponse', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['AppUpdatedEvent'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - received_at: { type: 'DatetimeType', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['AppealItemResponse'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - updated_at: { type: 'DatetimeType', isSingle: true }, - - actions: { type: 'ActionLogResponse', isSingle: false }, - - flags: { type: 'ModerationFlagResponse', isSingle: false }, - - moderation_action: { type: 'ActionLogResponse', isSingle: true }, - - original_moderation_action: { type: 'ActionLogResponse', isSingle: true }, - - user: { type: 'UserResponse', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['AutomodDetailsResponse'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - result: { type: 'MessageModerationResult', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['BanInfoResponse'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - expires: { type: 'DatetimeType', isSingle: true }, - - channel: { type: 'ChannelMetadata', isSingle: true }, - - created_by: { type: 'UserResponse', isSingle: true }, - - user: { type: 'UserResponse', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['BanResponse'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - expires: { type: 'DatetimeType', isSingle: true }, - - banned_by: { type: 'UserResponse', isSingle: true }, - - channel: { type: 'ChannelResponse', isSingle: true }, - - user: { type: 'UserResponse', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['BlockListResponse'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - updated_at: { type: 'DatetimeType', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['BlockUsersResponse'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['BlockedUserResponse'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - blocked_user: { type: 'UserResponse', isSingle: true }, - - user: { type: 'UserResponse', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['BulkActionAppealsResponse'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - results: { type: 'BulkAppealResult', isSingle: false }, - }; - return decode(typeMappings, input); -}; - -decoders['BulkAppealResult'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - appeal_item: { type: 'AppealItemResponse', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['ChannelConfigWithInfo'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - updated_at: { type: 'DatetimeType', isSingle: true }, - - commands: { type: 'Command', isSingle: false }, - }; - return decode(typeMappings, input); -}; - -decoders['ChannelContextResponse'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_by: { type: 'UserResponse', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['ChannelCreatedEvent'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - channel: { type: 'ChannelResponse', isSingle: true }, - - received_at: { type: 'DatetimeType', isSingle: true }, - - user: { type: 'UserResponseCommonFields', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['ChannelDeletedEvent'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - channel: { type: 'ChannelResponse', isSingle: true }, - - received_at: { type: 'DatetimeType', isSingle: true }, - - user: { type: 'UserResponseCommonFields', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['ChannelFrozenEvent'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - received_at: { type: 'DatetimeType', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['ChannelHiddenEvent'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - channel: { type: 'ChannelResponse', isSingle: true }, - - received_at: { type: 'DatetimeType', isSingle: true }, - - user: { type: 'UserResponseCommonFields', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['ChannelKickedEvent'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - received_at: { type: 'DatetimeType', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['ChannelMemberResponse'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - updated_at: { type: 'DatetimeType', isSingle: true }, - - archived_at: { type: 'DatetimeType', isSingle: true }, - - ban_expires: { type: 'DatetimeType', isSingle: true }, - - deleted_at: { type: 'DatetimeType', isSingle: true }, - - future_channel_ban_expires: { type: 'DatetimeType', isSingle: true }, - - invite_accepted_at: { type: 'DatetimeType', isSingle: true }, - - invite_rejected_at: { type: 'DatetimeType', isSingle: true }, - - pinned_at: { type: 'DatetimeType', isSingle: true }, - - user: { type: 'UserResponse', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['ChannelMetadata'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - last_message_at: { type: 'DatetimeType', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['ChannelMute'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - updated_at: { type: 'DatetimeType', isSingle: true }, - - expires: { type: 'DatetimeType', isSingle: true }, - - channel: { type: 'ChannelResponse', isSingle: true }, - - user: { type: 'UserResponse', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['ChannelPushPreferencesResponse'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - disabled_until: { type: 'DatetimeType', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['ChannelResponse'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - updated_at: { type: 'DatetimeType', isSingle: true }, - - deleted_at: { type: 'DatetimeType', isSingle: true }, - - hide_messages_before: { type: 'DatetimeType', isSingle: true }, - - last_message_at: { type: 'DatetimeType', isSingle: true }, - - mute_expires_at: { type: 'DatetimeType', isSingle: true }, - - truncated_at: { type: 'DatetimeType', isSingle: true }, - - members: { type: 'ChannelMemberResponse', isSingle: false }, - - config: { type: 'ChannelConfigWithInfo', isSingle: true }, - - created_by: { type: 'UserResponse', isSingle: true }, - - truncated_by: { type: 'UserResponse', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['ChannelStateResponse'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - members: { type: 'ChannelMemberResponse', isSingle: false }, - - messages: { type: 'MessageResponse', isSingle: false }, - - pinned_messages: { type: 'MessageResponse', isSingle: false }, - - threads: { type: 'ThreadStateResponse', isSingle: false }, - - hide_messages_before: { type: 'DatetimeType', isSingle: true }, - - active_live_locations: { type: 'SharedLocationResponseData', isSingle: false }, - - pending_messages: { type: 'PendingMessageResponse', isSingle: false }, - - read: { type: 'ReadStateResponse', isSingle: false }, - - watchers: { type: 'UserResponse', isSingle: false }, - - channel: { type: 'ChannelResponse', isSingle: true }, - - draft: { type: 'DraftResponse', isSingle: true }, - - membership: { type: 'ChannelMemberResponse', isSingle: true }, - - push_preferences: { type: 'ChannelPushPreferencesResponse', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['ChannelStateResponseFields'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - members: { type: 'ChannelMemberResponse', isSingle: false }, - - messages: { type: 'MessageResponse', isSingle: false }, - - pinned_messages: { type: 'MessageResponse', isSingle: false }, - - threads: { type: 'ThreadStateResponse', isSingle: false }, - - hide_messages_before: { type: 'DatetimeType', isSingle: true }, - - active_live_locations: { type: 'SharedLocationResponseData', isSingle: false }, - - pending_messages: { type: 'PendingMessageResponse', isSingle: false }, - - read: { type: 'ReadStateResponse', isSingle: false }, - - watchers: { type: 'UserResponse', isSingle: false }, - - channel: { type: 'ChannelResponse', isSingle: true }, - - draft: { type: 'DraftResponse', isSingle: true }, - - membership: { type: 'ChannelMemberResponse', isSingle: true }, - - push_preferences: { type: 'ChannelPushPreferencesResponse', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['ChannelTruncatedEvent'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - channel: { type: 'ChannelResponse', isSingle: true }, - - received_at: { type: 'DatetimeType', isSingle: true }, - - message: { type: 'MessageResponse', isSingle: true }, - - user: { type: 'UserResponseCommonFields', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['ChannelUnFrozenEvent'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - received_at: { type: 'DatetimeType', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['ChannelUpdatedEvent'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - channel: { type: 'ChannelResponse', isSingle: true }, - - received_at: { type: 'DatetimeType', isSingle: true }, - - message: { type: 'MessageResponse', isSingle: true }, - - user: { type: 'UserResponseCommonFields', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['ChannelVisibleEvent'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - channel: { type: 'ChannelResponse', isSingle: true }, - - received_at: { type: 'DatetimeType', isSingle: true }, - - user: { type: 'UserResponseCommonFields', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['ChatDraftPayloadResponse'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - mentioned_users: { type: 'UserResponse', isSingle: false }, - }; - return decode(typeMappings, input); -}; - -decoders['ChatDraftResponse'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - message: { type: 'ChatDraftPayloadResponse', isSingle: true }, - - parent_message: { type: 'ChatMessageResponse', isSingle: true }, - - quoted_message: { type: 'ChatMessageResponse', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['ChatMessageResponse'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - updated_at: { type: 'DatetimeType', isSingle: true }, - - latest_reactions: { type: 'ChatReactionResponse', isSingle: false }, - - mentioned_users: { type: 'UserResponse', isSingle: false }, - - own_reactions: { type: 'ChatReactionResponse', isSingle: false }, - - user: { type: 'UserResponse', isSingle: true }, - - deleted_at: { type: 'DatetimeType', isSingle: true }, - - message_text_updated_at: { type: 'DatetimeType', isSingle: true }, - - pin_expires: { type: 'DatetimeType', isSingle: true }, - - pinned_at: { type: 'DatetimeType', isSingle: true }, - - mentioned_groups: { type: 'UserGroupResponse', isSingle: false }, - - thread_participants: { type: 'UserResponse', isSingle: false }, - - draft: { type: 'ChatDraftResponse', isSingle: true }, - - pinned_by: { type: 'UserResponse', isSingle: true }, - - poll: { type: 'PollResponseData', isSingle: true }, - - quoted_message: { type: 'ChatMessageResponse', isSingle: true }, - - reaction_groups: { type: 'ChatReactionGroupResponse', isSingle: false }, - - reminder: { type: 'ChatReminderResponseData', isSingle: true }, - - shared_location: { type: 'ChatSharedLocationResponseData', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['ChatReactionGroupResponse'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - first_reaction_at: { type: 'DatetimeType', isSingle: true }, - - last_reaction_at: { type: 'DatetimeType', isSingle: true }, - - latest_reactions_by: { type: 'ChatReactionGroupUserResponse', isSingle: false }, - }; - return decode(typeMappings, input); -}; - -decoders['ChatReactionGroupUserResponse'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - user: { type: 'UserResponse', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['ChatReactionResponse'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - updated_at: { type: 'DatetimeType', isSingle: true }, - - user: { type: 'UserResponse', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['ChatReminderResponseData'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - updated_at: { type: 'DatetimeType', isSingle: true }, - - remind_at: { type: 'DatetimeType', isSingle: true }, - - message: { type: 'ChatMessageResponse', isSingle: true }, - - user: { type: 'UserResponse', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['ChatSharedLocationResponseData'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - updated_at: { type: 'DatetimeType', isSingle: true }, - - end_at: { type: 'DatetimeType', isSingle: true }, - - message: { type: 'ChatMessageResponse', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['Command'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - updated_at: { type: 'DatetimeType', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['ConfigResponse'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - updated_at: { type: 'DatetimeType', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['CreateBlockListResponse'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - blocklist: { type: 'BlockListResponse', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['CreateDraftResponse'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - draft: { type: 'DraftResponse', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['CreateGuestResponse'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - user: { type: 'UserResponse', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['CreateUserGroupResponse'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - user_group: { type: 'UserGroupResponse', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['CustomEvent'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - received_at: { type: 'DatetimeType', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['DeleteChannelResponse'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - channel: { type: 'ChannelResponse', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['DeleteMessageResponse'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - message: { type: 'MessageResponse', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['DeleteReactionResponse'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - message: { type: 'MessageResponse', isSingle: true }, - - reaction: { type: 'ReactionResponse', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['DeviceResponse'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['DraftDeletedEvent'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - received_at: { type: 'DatetimeType', isSingle: true }, - - draft: { type: 'DraftResponse', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['DraftPayloadResponse'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - mentioned_users: { type: 'UserResponse', isSingle: false }, - }; - return decode(typeMappings, input); -}; - -decoders['DraftResponse'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - message: { type: 'DraftPayloadResponse', isSingle: true }, - - channel: { type: 'ChannelResponse', isSingle: true }, - - parent_message: { type: 'MessageResponse', isSingle: true }, - - quoted_message: { type: 'MessageResponse', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['DraftUpdatedEvent'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - received_at: { type: 'DatetimeType', isSingle: true }, - - draft: { type: 'DraftResponse', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['EntityCreatorResponse'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - updated_at: { type: 'DatetimeType', isSingle: true }, - - deactivated_at: { type: 'DatetimeType', isSingle: true }, - - deleted_at: { type: 'DatetimeType', isSingle: true }, - - last_active: { type: 'DatetimeType', isSingle: true }, - - revoke_tokens_issued_before: { type: 'DatetimeType', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['FeedsBookmarkResponse'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - updated_at: { type: 'DatetimeType', isSingle: true }, - - user: { type: 'UserResponse', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['FeedsEnrichedCollectionResponse'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - updated_at: { type: 'DatetimeType', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['FeedsFeedResponse'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - updated_at: { type: 'DatetimeType', isSingle: true }, - - created_by: { type: 'UserResponse', isSingle: true }, - - deleted_at: { type: 'DatetimeType', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['FeedsReactionGroupResponse'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - first_reaction_at: { type: 'DatetimeType', isSingle: true }, - - last_reaction_at: { type: 'DatetimeType', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['FeedsReactionResponse'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - updated_at: { type: 'DatetimeType', isSingle: true }, - - user: { type: 'UserResponse', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['FeedsShareResponse'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - user: { type: 'UserResponse', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['FeedsV3ActivityResponse'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - updated_at: { type: 'DatetimeType', isSingle: true }, - - comments: { type: 'FeedsV3CommentResponse', isSingle: false }, - - latest_reactions: { type: 'FeedsReactionResponse', isSingle: false }, - - mentioned_users: { type: 'UserResponse', isSingle: false }, - - own_bookmarks: { type: 'FeedsBookmarkResponse', isSingle: false }, - - own_reactions: { type: 'FeedsReactionResponse', isSingle: false }, - - collections: { type: 'FeedsEnrichedCollectionResponse', isSingle: false }, - - reaction_groups: { type: 'FeedsReactionGroupResponse', isSingle: false }, - - user: { type: 'UserResponse', isSingle: true }, - - deleted_at: { type: 'DatetimeType', isSingle: true }, - - edited_at: { type: 'DatetimeType', isSingle: true }, - - expires_at: { type: 'DatetimeType', isSingle: true }, - - friend_reactions: { type: 'FeedsReactionResponse', isSingle: false }, - - latest_shares: { type: 'FeedsShareResponse', isSingle: false }, - - current_feed: { type: 'FeedsFeedResponse', isSingle: true }, - - parent: { type: 'FeedsV3ActivityResponse', isSingle: true }, - - poll: { type: 'PollResponseData', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['FeedsV3CommentResponse'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - updated_at: { type: 'DatetimeType', isSingle: true }, - - mentioned_users: { type: 'UserResponse', isSingle: false }, - - own_reactions: { type: 'FeedsReactionResponse', isSingle: false }, - - user: { type: 'UserResponse', isSingle: true }, - - deleted_at: { type: 'DatetimeType', isSingle: true }, - - edited_at: { type: 'DatetimeType', isSingle: true }, - - latest_reactions: { type: 'FeedsReactionResponse', isSingle: false }, - - reaction_groups: { type: 'FeedsReactionGroupResponse', isSingle: false }, - }; - return decode(typeMappings, input); -}; - -decoders['FlagDetailsResponse'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - automod: { type: 'AutomodDetailsResponse', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['FlagFeedbackResponse'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['FullUserResponse'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - updated_at: { type: 'DatetimeType', isSingle: true }, - - channel_mutes: { type: 'ChannelMute', isSingle: false }, - - devices: { type: 'DeviceResponse', isSingle: false }, - - mutes: { type: 'UserMuteResponse', isSingle: false }, - - ban_expires: { type: 'DatetimeType', isSingle: true }, - - deactivated_at: { type: 'DatetimeType', isSingle: true }, - - deleted_at: { type: 'DatetimeType', isSingle: true }, - - last_active: { type: 'DatetimeType', isSingle: true }, - - revoke_tokens_issued_before: { type: 'DatetimeType', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['FutureChannelBanResponse'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - expires: { type: 'DatetimeType', isSingle: true }, - - banned_by: { type: 'UserResponse', isSingle: true }, - - user: { type: 'UserResponse', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['GetAppealResponse'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - item: { type: 'AppealItemResponse', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['GetBlockedUsersResponse'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - blocks: { type: 'BlockedUserResponse', isSingle: false }, - }; - return decode(typeMappings, input); -}; - -decoders['GetConfigResponse'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - config: { type: 'ConfigResponse', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['GetDraftResponse'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - draft: { type: 'DraftResponse', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['GetManyMessagesResponse'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - messages: { type: 'MessageResponse', isSingle: false }, - }; - return decode(typeMappings, input); -}; - -decoders['GetMessageResponse'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - message: { type: 'MessageWithChannelResponse', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['GetPinnedMessagesResponse'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - messages: { type: 'MessageResponse', isSingle: false }, - }; - return decode(typeMappings, input); -}; - -decoders['GetReactionsResponse'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - reactions: { type: 'ReactionResponse', isSingle: false }, - }; - return decode(typeMappings, input); -}; - -decoders['GetRepliesResponse'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - messages: { type: 'MessageResponse', isSingle: false }, - }; - return decode(typeMappings, input); -}; - -decoders['GetThreadResponse'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - thread: { type: 'ThreadStateResponse', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['GetUserGroupResponse'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - user_group: { type: 'UserGroupResponse', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['GroupedChannelsBucket'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - channels: { type: 'ChannelStateResponseFields', isSingle: false }, - }; - return decode(typeMappings, input); -}; - -decoders['GroupedQueryChannelsResponse'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - groups: { type: 'GroupedChannelsBucket', isSingle: false }, - }; - return decode(typeMappings, input); -}; - -decoders['HealthCheckEvent'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - received_at: { type: 'DatetimeType', isSingle: true }, - - me: { type: 'OwnUserResponse', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['ListBlockListResponse'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - blocklists: { type: 'BlockListResponse', isSingle: false }, - }; - return decode(typeMappings, input); -}; - -decoders['ListDevicesResponse'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - devices: { type: 'DeviceResponse', isSingle: false }, - }; - return decode(typeMappings, input); -}; - -decoders['ListQueuesResponse'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - queues: { type: 'ModerationQueueResponse', isSingle: false }, - }; - return decode(typeMappings, input); -}; - -decoders['ListUserGroupsResponse'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - user_groups: { type: 'UserGroupResponse', isSingle: false }, - }; - return decode(typeMappings, input); -}; - -decoders['MarkReadResponse'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - event: { type: 'MarkReadResponseEvent', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['MarkReadResponseEvent'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - channel_last_message_at: { type: 'DatetimeType', isSingle: true }, - - channel: { type: 'ChannelResponse', isSingle: true }, - - thread: { type: 'ThreadResponse', isSingle: true }, - - user: { type: 'UserResponseCommonFields', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['MaxStreakChangedEvent'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - received_at: { type: 'DatetimeType', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['MemberAddedEvent'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - channel: { type: 'ChannelResponse', isSingle: true }, - - member: { type: 'ChannelMemberResponse', isSingle: true }, - - received_at: { type: 'DatetimeType', isSingle: true }, - - user: { type: 'UserResponseCommonFields', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['MemberRemovedEvent'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - channel: { type: 'ChannelResponse', isSingle: true }, - - member: { type: 'ChannelMemberResponse', isSingle: true }, - - received_at: { type: 'DatetimeType', isSingle: true }, - - user: { type: 'UserResponseCommonFields', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['MemberUpdatedEvent'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - channel: { type: 'ChannelResponse', isSingle: true }, - - member: { type: 'ChannelMemberResponse', isSingle: true }, - - received_at: { type: 'DatetimeType', isSingle: true }, - - user: { type: 'UserResponseCommonFields', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['MembersResponse'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - members: { type: 'ChannelMemberResponse', isSingle: false }, - }; - return decode(typeMappings, input); -}; - -decoders['MessageActionResponse'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - message: { type: 'MessageResponse', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['MessageDeletedEvent'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - message: { type: 'MessageResponse', isSingle: true }, - - received_at: { type: 'DatetimeType', isSingle: true }, - - user: { type: 'UserResponseCommonFields', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['MessageDeliveredEvent'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - received_at: { type: 'DatetimeType', isSingle: true }, - - channel: { type: 'ChannelResponse', isSingle: true }, - - user: { type: 'UserResponseCommonFields', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['MessageFlagResponse'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - updated_at: { type: 'DatetimeType', isSingle: true }, - - approved_at: { type: 'DatetimeType', isSingle: true }, - - rejected_at: { type: 'DatetimeType', isSingle: true }, - - reviewed_at: { type: 'DatetimeType', isSingle: true }, - - details: { type: 'FlagDetailsResponse', isSingle: true }, - - message: { type: 'MessageResponse', isSingle: true }, - - moderation_feedback: { type: 'FlagFeedbackResponse', isSingle: true }, - - moderation_result: { type: 'MessageModerationResult', isSingle: true }, - - reviewed_by: { type: 'UserResponse', isSingle: true }, - - user: { type: 'UserResponse', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['MessageModerationResult'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - updated_at: { type: 'DatetimeType', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['MessageNewEvent'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - message: { type: 'MessageResponse', isSingle: true }, - - received_at: { type: 'DatetimeType', isSingle: true }, - - thread_participants: { type: 'UserResponseCommonFields', isSingle: false }, - - channel: { type: 'ChannelResponse', isSingle: true }, - - user: { type: 'UserResponseCommonFields', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['MessageReadEvent'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - received_at: { type: 'DatetimeType', isSingle: true }, - - channel: { type: 'ChannelResponse', isSingle: true }, - - thread: { type: 'ThreadResponse', isSingle: true }, - - user: { type: 'UserResponseCommonFields', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['MessageResponse'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - updated_at: { type: 'DatetimeType', isSingle: true }, - - latest_reactions: { type: 'ReactionResponse', isSingle: false }, - - mentioned_users: { type: 'UserResponse', isSingle: false }, - - own_reactions: { type: 'ReactionResponse', isSingle: false }, - - user: { type: 'UserResponse', isSingle: true }, - - deleted_at: { type: 'DatetimeType', isSingle: true }, - - message_text_updated_at: { type: 'DatetimeType', isSingle: true }, - - pin_expires: { type: 'DatetimeType', isSingle: true }, - - pinned_at: { type: 'DatetimeType', isSingle: true }, - - mentioned_groups: { type: 'UserGroupResponse', isSingle: false }, - - thread_participants: { type: 'UserResponse', isSingle: false }, - - draft: { type: 'DraftResponse', isSingle: true }, - - pinned_by: { type: 'UserResponse', isSingle: true }, - - poll: { type: 'PollResponseData', isSingle: true }, - - quoted_message: { type: 'MessageResponse', isSingle: true }, - - reaction_groups: { type: 'ReactionGroupResponse', isSingle: false }, - - reminder: { type: 'ReminderResponseData', isSingle: true }, - - shared_location: { type: 'SharedLocationResponseData', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['MessageUndeletedEvent'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - message: { type: 'MessageResponse', isSingle: true }, - - received_at: { type: 'DatetimeType', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['MessageUpdatedEvent'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - message: { type: 'MessageResponse', isSingle: true }, - - received_at: { type: 'DatetimeType', isSingle: true }, - - user: { type: 'UserResponseCommonFields', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['MessageWithChannelResponse'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - updated_at: { type: 'DatetimeType', isSingle: true }, - - latest_reactions: { type: 'ReactionResponse', isSingle: false }, - - mentioned_users: { type: 'UserResponse', isSingle: false }, - - own_reactions: { type: 'ReactionResponse', isSingle: false }, - - channel: { type: 'ChannelResponse', isSingle: true }, - - user: { type: 'UserResponse', isSingle: true }, - - deleted_at: { type: 'DatetimeType', isSingle: true }, - - message_text_updated_at: { type: 'DatetimeType', isSingle: true }, - - pin_expires: { type: 'DatetimeType', isSingle: true }, - - pinned_at: { type: 'DatetimeType', isSingle: true }, - - mentioned_groups: { type: 'UserGroupResponse', isSingle: false }, - - thread_participants: { type: 'UserResponse', isSingle: false }, - - draft: { type: 'DraftResponse', isSingle: true }, - - pinned_by: { type: 'UserResponse', isSingle: true }, - - poll: { type: 'PollResponseData', isSingle: true }, - - quoted_message: { type: 'MessageResponse', isSingle: true }, - - reaction_groups: { type: 'ReactionGroupResponse', isSingle: false }, - - reminder: { type: 'ReminderResponseData', isSingle: true }, - - shared_location: { type: 'SharedLocationResponseData', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['ModerationCallResponse'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - updated_at: { type: 'DatetimeType', isSingle: true }, - - ended_at: { type: 'DatetimeType', isSingle: true }, - - starts_at: { type: 'DatetimeType', isSingle: true }, - - created_by: { type: 'UserResponse', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['ModerationCustomActionEvent'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - review_queue_item: { type: 'ReviewQueueItemResponse', isSingle: true }, - - received_at: { type: 'DatetimeType', isSingle: true }, - - message: { type: 'MessageResponse', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['ModerationFlagResponse'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - updated_at: { type: 'DatetimeType', isSingle: true }, - - review_queue_item: { type: 'ReviewQueueItemResponse', isSingle: true }, - - user: { type: 'UserResponse', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['ModerationFlaggedEvent'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - received_at: { type: 'DatetimeType', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['ModerationMarkReviewedEvent'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - item: { type: 'ReviewQueueItemResponse', isSingle: true }, - - received_at: { type: 'DatetimeType', isSingle: true }, - - message: { type: 'MessageResponse', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['ModerationQueueResponse'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - updated_at: { type: 'DatetimeType', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['MuteChannelResponse'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - channel_mutes: { type: 'ChannelMute', isSingle: false }, - - channel_mute: { type: 'ChannelMute', isSingle: true }, - - own_user: { type: 'OwnUserResponse', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['MuteResponse'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - mutes: { type: 'UserMuteResponse', isSingle: false }, - - own_user: { type: 'OwnUserResponse', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['NotificationAddedToChannelEvent'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - channel: { type: 'ChannelResponse', isSingle: true }, - - member: { type: 'ChannelMemberResponse', isSingle: true }, - - received_at: { type: 'DatetimeType', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['NotificationChannelDeletedEvent'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - channel: { type: 'ChannelResponse', isSingle: true }, - - received_at: { type: 'DatetimeType', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['NotificationChannelMutesUpdatedEvent'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - me: { type: 'OwnUserResponse', isSingle: true }, - - received_at: { type: 'DatetimeType', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['NotificationChannelTruncatedEvent'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - channel: { type: 'ChannelResponse', isSingle: true }, - - received_at: { type: 'DatetimeType', isSingle: true }, - - message: { type: 'MessageResponse', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['NotificationInviteAcceptedEvent'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - channel: { type: 'ChannelResponse', isSingle: true }, - - member: { type: 'ChannelMemberResponse', isSingle: true }, - - received_at: { type: 'DatetimeType', isSingle: true }, - - user: { type: 'UserResponseCommonFields', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['NotificationInviteRejectedEvent'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - channel: { type: 'ChannelResponse', isSingle: true }, - - member: { type: 'ChannelMemberResponse', isSingle: true }, - - received_at: { type: 'DatetimeType', isSingle: true }, - - user: { type: 'UserResponseCommonFields', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['NotificationInvitedEvent'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - channel: { type: 'ChannelResponse', isSingle: true }, - - member: { type: 'ChannelMemberResponse', isSingle: true }, - - received_at: { type: 'DatetimeType', isSingle: true }, - - user: { type: 'UserResponseCommonFields', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['NotificationMarkReadEvent'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - received_at: { type: 'DatetimeType', isSingle: true }, - - channel: { type: 'ChannelResponse', isSingle: true }, - - thread: { type: 'ThreadResponse', isSingle: true }, - - user: { type: 'UserResponseCommonFields', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['NotificationMarkUnreadEvent'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - last_read_at: { type: 'DatetimeType', isSingle: true }, - - received_at: { type: 'DatetimeType', isSingle: true }, - - channel: { type: 'ChannelResponse', isSingle: true }, - - user: { type: 'UserResponseCommonFields', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['NotificationMutesUpdatedEvent'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - me: { type: 'OwnUserResponse', isSingle: true }, - - received_at: { type: 'DatetimeType', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['NotificationNewMessageEvent'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - channel: { type: 'ChannelResponse', isSingle: true }, - - message: { type: 'MessageResponse', isSingle: true }, - - received_at: { type: 'DatetimeType', isSingle: true }, - - thread_participants: { type: 'UserResponseCommonFields', isSingle: false }, - }; - return decode(typeMappings, input); -}; - -decoders['NotificationRemovedFromChannelEvent'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - channel: { type: 'ChannelResponse', isSingle: true }, - - member: { type: 'ChannelMemberResponse', isSingle: true }, - - received_at: { type: 'DatetimeType', isSingle: true }, - - user: { type: 'UserResponseCommonFields', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['NotificationThreadMessageNewEvent'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - channel: { type: 'ChannelResponse', isSingle: true }, - - message: { type: 'MessageResponse', isSingle: true }, - - received_at: { type: 'DatetimeType', isSingle: true }, - - thread_participants: { type: 'UserResponseCommonFields', isSingle: false }, - }; - return decode(typeMappings, input); -}; - -decoders['OwnUserResponse'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - updated_at: { type: 'DatetimeType', isSingle: true }, - - channel_mutes: { type: 'ChannelMute', isSingle: false }, - - devices: { type: 'DeviceResponse', isSingle: false }, - - mutes: { type: 'UserMuteResponse', isSingle: false }, - - deactivated_at: { type: 'DatetimeType', isSingle: true }, - - deleted_at: { type: 'DatetimeType', isSingle: true }, - - last_active: { type: 'DatetimeType', isSingle: true }, - - revoke_tokens_issued_before: { type: 'DatetimeType', isSingle: true }, - - push_preferences: { type: 'PushPreferencesResponse', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['PendingMessageEvent'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - received_at: { type: 'DatetimeType', isSingle: true }, - - channel: { type: 'ChannelResponse', isSingle: true }, - - message: { type: 'MessageResponse', isSingle: true }, - - user: { type: 'UserResponse', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['PendingMessageResponse'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - channel: { type: 'ChannelResponse', isSingle: true }, - - message: { type: 'MessageResponse', isSingle: true }, - - user: { type: 'UserResponse', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['PollClosedEvent'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - poll: { type: 'PollResponseData', isSingle: true }, - - received_at: { type: 'DatetimeType', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['PollDeletedEvent'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - poll: { type: 'PollResponseData', isSingle: true }, - - received_at: { type: 'DatetimeType', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['PollResponse'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - poll: { type: 'PollResponseData', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['PollResponseData'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - updated_at: { type: 'DatetimeType', isSingle: true }, - - latest_answers: { type: 'PollVoteResponseData', isSingle: false }, - - own_votes: { type: 'PollVoteResponseData', isSingle: false }, - - created_by: { type: 'UserResponse', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['PollUpdatedEvent'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - poll: { type: 'PollResponseData', isSingle: true }, - - received_at: { type: 'DatetimeType', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['PollVoteCastedEvent'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - poll: { type: 'PollResponseData', isSingle: true }, - - poll_vote: { type: 'PollVoteResponseData', isSingle: true }, - - received_at: { type: 'DatetimeType', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['PollVoteChangedEvent'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - poll: { type: 'PollResponseData', isSingle: true }, - - poll_vote: { type: 'PollVoteResponseData', isSingle: true }, - - received_at: { type: 'DatetimeType', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['PollVoteRemovedEvent'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - poll: { type: 'PollResponseData', isSingle: true }, - - poll_vote: { type: 'PollVoteResponseData', isSingle: true }, - - received_at: { type: 'DatetimeType', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['PollVoteResponse'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - poll: { type: 'PollResponseData', isSingle: true }, - - vote: { type: 'PollVoteResponseData', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['PollVoteResponseData'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - updated_at: { type: 'DatetimeType', isSingle: true }, - - user: { type: 'UserResponse', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['PollVotesResponse'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - votes: { type: 'PollVoteResponseData', isSingle: false }, - }; - return decode(typeMappings, input); -}; - -decoders['PushPreferencesResponse'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - disabled_until: { type: 'DatetimeType', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['QueryAppealsResponse'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - items: { type: 'AppealItemResponse', isSingle: false }, - }; - return decode(typeMappings, input); -}; - -decoders['QueryBannedUsersResponse'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - bans: { type: 'BanResponse', isSingle: false }, - }; - return decode(typeMappings, input); -}; - -decoders['QueryChannelsResponse'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - channels: { type: 'ChannelStateResponseFields', isSingle: false }, - }; - return decode(typeMappings, input); -}; - -decoders['QueryDraftsResponse'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - drafts: { type: 'DraftResponse', isSingle: false }, - }; - return decode(typeMappings, input); -}; - -decoders['QueryFutureChannelBansResponse'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - bans: { type: 'FutureChannelBanResponse', isSingle: false }, - }; - return decode(typeMappings, input); -}; - -decoders['QueryMessageFlagsResponse'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - flags: { type: 'MessageFlagResponse', isSingle: false }, - }; - return decode(typeMappings, input); -}; - -decoders['QueryModerationConfigsResponse'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - configs: { type: 'ConfigResponse', isSingle: false }, - }; - return decode(typeMappings, input); -}; - -decoders['QueryPollsResponse'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - polls: { type: 'PollResponseData', isSingle: false }, - }; - return decode(typeMappings, input); -}; - -decoders['QueryReactionsResponse'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - reactions: { type: 'ReactionResponse', isSingle: false }, - }; - return decode(typeMappings, input); -}; - -decoders['QueryRemindersResponse'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - reminders: { type: 'ReminderResponseData', isSingle: false }, - }; - return decode(typeMappings, input); -}; - -decoders['QueryReviewQueueResponse'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - items: { type: 'ReviewQueueItemResponse', isSingle: false }, - }; - return decode(typeMappings, input); -}; - -decoders['QueryThreadsResponse'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - threads: { type: 'ThreadStateResponse', isSingle: false }, - }; - return decode(typeMappings, input); -}; - -decoders['QueryUsersResponse'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - users: { type: 'FullUserResponse', isSingle: false }, - }; - return decode(typeMappings, input); -}; - -decoders['QueueResponse'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - queue: { type: 'ModerationQueueResponse', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['Reaction'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - updated_at: { type: 'DatetimeType', isSingle: true }, - - deleted_at: { type: 'DatetimeType', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['ReactionDeletedEvent'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - channel: { type: 'ChannelResponse', isSingle: true }, - - received_at: { type: 'DatetimeType', isSingle: true }, - - thread_participants: { type: 'UserResponseCommonFields', isSingle: false }, - - message: { type: 'MessageResponse', isSingle: true }, - - reaction: { type: 'ReactionResponse', isSingle: true }, - - user: { type: 'UserResponseCommonFields', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['ReactionGroupResponse'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - first_reaction_at: { type: 'DatetimeType', isSingle: true }, - - last_reaction_at: { type: 'DatetimeType', isSingle: true }, - - latest_reactions_by: { type: 'ReactionGroupUserResponse', isSingle: false }, - }; - return decode(typeMappings, input); -}; - -decoders['ReactionGroupUserResponse'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - user: { type: 'UserResponse', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['ReactionNewEvent'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - channel: { type: 'ChannelResponse', isSingle: true }, - - received_at: { type: 'DatetimeType', isSingle: true }, - - thread_participants: { type: 'UserResponseCommonFields', isSingle: false }, - - message: { type: 'MessageResponse', isSingle: true }, - - reaction: { type: 'ReactionResponse', isSingle: true }, - - user: { type: 'UserResponseCommonFields', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['ReactionResponse'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - updated_at: { type: 'DatetimeType', isSingle: true }, - - user: { type: 'UserResponse', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['ReactionUpdatedEvent'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - channel: { type: 'ChannelResponse', isSingle: true }, - - message: { type: 'MessageResponse', isSingle: true }, - - received_at: { type: 'DatetimeType', isSingle: true }, - - reaction: { type: 'ReactionResponse', isSingle: true }, - - user: { type: 'UserResponseCommonFields', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['ReadStateResponse'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - last_read: { type: 'DatetimeType', isSingle: true }, - - user: { type: 'UserResponse', isSingle: true }, - - last_delivered_at: { type: 'DatetimeType', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['ReminderCreatedEvent'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - received_at: { type: 'DatetimeType', isSingle: true }, - - reminder: { type: 'ReminderResponseData', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['ReminderDeletedEvent'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - received_at: { type: 'DatetimeType', isSingle: true }, - - reminder: { type: 'ReminderResponseData', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['ReminderNotificationEvent'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - received_at: { type: 'DatetimeType', isSingle: true }, - - reminder: { type: 'ReminderResponseData', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['ReminderResponseData'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - updated_at: { type: 'DatetimeType', isSingle: true }, - - remind_at: { type: 'DatetimeType', isSingle: true }, - - channel: { type: 'ChannelResponse', isSingle: true }, - - message: { type: 'MessageResponse', isSingle: true }, - - user: { type: 'UserResponse', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['ReminderUpdatedEvent'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - received_at: { type: 'DatetimeType', isSingle: true }, - - reminder: { type: 'ReminderResponseData', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['RemoveUserGroupMembersResponse'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - user_group: { type: 'UserGroupResponse', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['ReviewQueueItemResponse'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - updated_at: { type: 'DatetimeType', isSingle: true }, - - actions: { type: 'ActionLogResponse', isSingle: false }, - - bans: { type: 'BanInfoResponse', isSingle: false }, - - flags: { type: 'ModerationFlagResponse', isSingle: false }, - - completed_at: { type: 'DatetimeType', isSingle: true }, - - escalated_at: { type: 'DatetimeType', isSingle: true }, - - reviewed_at: { type: 'DatetimeType', isSingle: true }, - - appeal: { type: 'AppealItemResponse', isSingle: true }, - - assigned_to: { type: 'UserResponse', isSingle: true }, - - call: { type: 'ModerationCallResponse', isSingle: true }, - - entity_creator: { type: 'EntityCreatorResponse', isSingle: true }, - - feeds_v2_reaction: { type: 'Reaction', isSingle: true }, - - feeds_v3_activity: { type: 'FeedsV3ActivityResponse', isSingle: true }, - - feeds_v3_comment: { type: 'FeedsV3CommentResponse', isSingle: true }, - - message: { type: 'ChatMessageResponse', isSingle: true }, - - reaction: { type: 'Reaction', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['Role'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - updated_at: { type: 'DatetimeType', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['SearchResponse'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - results: { type: 'SearchResult', isSingle: false }, - }; - return decode(typeMappings, input); -}; - -decoders['SearchResult'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - message: { type: 'SearchResultMessage', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['SearchResultMessage'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - updated_at: { type: 'DatetimeType', isSingle: true }, - - latest_reactions: { type: 'ReactionResponse', isSingle: false }, - - mentioned_users: { type: 'UserResponse', isSingle: false }, - - own_reactions: { type: 'ReactionResponse', isSingle: false }, - - user: { type: 'UserResponse', isSingle: true }, - - deleted_at: { type: 'DatetimeType', isSingle: true }, - - message_text_updated_at: { type: 'DatetimeType', isSingle: true }, - - pin_expires: { type: 'DatetimeType', isSingle: true }, - - pinned_at: { type: 'DatetimeType', isSingle: true }, - - mentioned_groups: { type: 'UserGroupResponse', isSingle: false }, - - thread_participants: { type: 'UserResponse', isSingle: false }, - - channel: { type: 'ChannelResponse', isSingle: true }, - - draft: { type: 'DraftResponse', isSingle: true }, - - pinned_by: { type: 'UserResponse', isSingle: true }, - - poll: { type: 'PollResponseData', isSingle: true }, - - quoted_message: { type: 'MessageResponse', isSingle: true }, - - reaction_groups: { type: 'ReactionGroupResponse', isSingle: false }, - - reminder: { type: 'ReminderResponseData', isSingle: true }, - - shared_location: { type: 'SharedLocationResponseData', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['SearchRolesResponse'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - roles: { type: 'Role', isSingle: false }, - }; - return decode(typeMappings, input); -}; - -decoders['SearchUserGroupsResponse'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - user_groups: { type: 'UserGroupResponse', isSingle: false }, - }; - return decode(typeMappings, input); -}; - -decoders['SendMessageResponse'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - message: { type: 'MessageResponse', isSingle: true }, - - channel_context: { type: 'ChannelContextResponse', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['SendReactionResponse'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - message: { type: 'MessageResponse', isSingle: true }, - - reaction: { type: 'ReactionResponse', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['SharedLocationResponse'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - updated_at: { type: 'DatetimeType', isSingle: true }, - - end_at: { type: 'DatetimeType', isSingle: true }, - - channel: { type: 'ChannelResponse', isSingle: true }, - - message: { type: 'MessageResponse', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['SharedLocationResponseData'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - updated_at: { type: 'DatetimeType', isSingle: true }, - - end_at: { type: 'DatetimeType', isSingle: true }, - - channel: { type: 'ChannelResponse', isSingle: true }, - - message: { type: 'MessageResponse', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['SharedLocationsResponse'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - active_live_locations: { type: 'SharedLocationResponseData', isSingle: false }, - }; - return decode(typeMappings, input); -}; - -decoders['SubmitActionResponse'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - appeal_item: { type: 'AppealItemResponse', isSingle: true }, - - item: { type: 'ReviewQueueItemResponse', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['ThreadParticipant'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - last_read_at: { type: 'DatetimeType', isSingle: true }, - - last_thread_message_at: { type: 'DatetimeType', isSingle: true }, - - left_thread_at: { type: 'DatetimeType', isSingle: true }, - - user: { type: 'UserResponse', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['ThreadResponse'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - updated_at: { type: 'DatetimeType', isSingle: true }, - - deleted_at: { type: 'DatetimeType', isSingle: true }, - - last_message_at: { type: 'DatetimeType', isSingle: true }, - - thread_participants: { type: 'ThreadParticipant', isSingle: false }, - - channel: { type: 'ChannelResponse', isSingle: true }, - - created_by: { type: 'UserResponse', isSingle: true }, - - parent_message: { type: 'MessageResponse', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['ThreadStateResponse'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - updated_at: { type: 'DatetimeType', isSingle: true }, - - latest_replies: { type: 'MessageResponse', isSingle: false }, - - deleted_at: { type: 'DatetimeType', isSingle: true }, - - last_message_at: { type: 'DatetimeType', isSingle: true }, - - read: { type: 'ReadStateResponse', isSingle: false }, - - thread_participants: { type: 'ThreadParticipant', isSingle: false }, - - channel: { type: 'ChannelResponse', isSingle: true }, - - created_by: { type: 'UserResponse', isSingle: true }, - - draft: { type: 'DraftResponse', isSingle: true }, - - parent_message: { type: 'MessageResponse', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['ThreadUpdatedEvent'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - received_at: { type: 'DatetimeType', isSingle: true }, - - thread: { type: 'ThreadResponse', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['TruncateChannelResponse'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - channel: { type: 'ChannelResponse', isSingle: true }, - - message: { type: 'MessageResponse', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['TypingStartEvent'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - received_at: { type: 'DatetimeType', isSingle: true }, - - user: { type: 'UserResponseCommonFields', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['TypingStopEvent'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - received_at: { type: 'DatetimeType', isSingle: true }, - - user: { type: 'UserResponseCommonFields', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['UnreadCountsChannel'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - last_read: { type: 'DatetimeType', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['UnreadCountsThread'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - last_read: { type: 'DatetimeType', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['UpdateBlockListResponse'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - blocklist: { type: 'BlockListResponse', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['UpdateChannelPartialResponse'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - members: { type: 'ChannelMemberResponse', isSingle: false }, - - channel: { type: 'ChannelResponse', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['UpdateChannelResponse'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - members: { type: 'ChannelMemberResponse', isSingle: false }, - - channel: { type: 'ChannelResponse', isSingle: true }, - - message: { type: 'MessageResponse', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['UpdateMemberPartialResponse'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - channel_member: { type: 'ChannelMemberResponse', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['UpdateMessagePartialResponse'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - message: { type: 'MessageResponse', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['UpdateMessageResponse'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - message: { type: 'MessageResponse', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['UpdateReminderResponse'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - reminder: { type: 'ReminderResponseData', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['UpdateThreadPartialResponse'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - thread: { type: 'ThreadResponse', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['UpdateUserGroupResponse'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - user_group: { type: 'UserGroupResponse', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['UpdateUsersResponse'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - users: { type: 'FullUserResponse', isSingle: false }, - }; - return decode(typeMappings, input); -}; - -decoders['UpsertConfigResponse'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - config: { type: 'ConfigResponse', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['UpsertPushPreferencesResponse'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - user_preferences: { type: 'PushPreferencesResponse', isSingle: false }, - }; - return decode(typeMappings, input); -}; - -decoders['UserBannedEvent'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - user: { type: 'UserResponseCommonFields', isSingle: true }, - - expiration: { type: 'DatetimeType', isSingle: true }, - - received_at: { type: 'DatetimeType', isSingle: true }, - - created_by: { type: 'UserResponseCommonFields', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['UserDeactivatedEvent'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - user: { type: 'UserResponseCommonFields', isSingle: true }, - - received_at: { type: 'DatetimeType', isSingle: true }, - - created_by: { type: 'UserResponseCommonFields', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['UserDeletedEvent'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - user: { type: 'UserResponseCommonFields', isSingle: true }, - - received_at: { type: 'DatetimeType', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['UserGroupCreatedEvent'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - received_at: { type: 'DatetimeType', isSingle: true }, - - user: { type: 'UserResponseCommonFields', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['UserGroupDeletedEvent'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - received_at: { type: 'DatetimeType', isSingle: true }, - - user: { type: 'UserResponseCommonFields', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['UserGroupMember'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['UserGroupMemberAddedEvent'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - received_at: { type: 'DatetimeType', isSingle: true }, - - user: { type: 'UserResponseCommonFields', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['UserGroupMemberRemovedEvent'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - received_at: { type: 'DatetimeType', isSingle: true }, - - user: { type: 'UserResponseCommonFields', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['UserGroupResponse'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - updated_at: { type: 'DatetimeType', isSingle: true }, - - members: { type: 'UserGroupMember', isSingle: false }, - }; - return decode(typeMappings, input); -}; - -decoders['UserGroupUpdatedEvent'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - received_at: { type: 'DatetimeType', isSingle: true }, - - user: { type: 'UserResponseCommonFields', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['UserMessagesDeletedEvent'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - user: { type: 'UserResponseCommonFields', isSingle: true }, - - received_at: { type: 'DatetimeType', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['UserMuteResponse'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - updated_at: { type: 'DatetimeType', isSingle: true }, - - expires: { type: 'DatetimeType', isSingle: true }, - - target: { type: 'UserResponse', isSingle: true }, - - user: { type: 'UserResponse', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['UserMutedEvent'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - user: { type: 'UserResponseCommonFields', isSingle: true }, - - received_at: { type: 'DatetimeType', isSingle: true }, - - target_users: { type: 'UserResponseCommonFields', isSingle: false }, - - target_user: { type: 'UserResponseCommonFields', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['UserPresenceChangedEvent'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - user: { type: 'UserResponseCommonFields', isSingle: true }, - - received_at: { type: 'DatetimeType', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['UserReactivatedEvent'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - user: { type: 'UserResponseCommonFields', isSingle: true }, - - received_at: { type: 'DatetimeType', isSingle: true }, - - created_by: { type: 'UserResponseCommonFields', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['UserResponse'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - updated_at: { type: 'DatetimeType', isSingle: true }, - - deactivated_at: { type: 'DatetimeType', isSingle: true }, - - deleted_at: { type: 'DatetimeType', isSingle: true }, - - last_active: { type: 'DatetimeType', isSingle: true }, - - revoke_tokens_issued_before: { type: 'DatetimeType', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['UserResponseCommonFields'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - updated_at: { type: 'DatetimeType', isSingle: true }, - - deactivated_at: { type: 'DatetimeType', isSingle: true }, - - deleted_at: { type: 'DatetimeType', isSingle: true }, - - last_active: { type: 'DatetimeType', isSingle: true }, - - revoke_tokens_issued_before: { type: 'DatetimeType', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['UserUnbannedEvent'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - user: { type: 'UserResponseCommonFields', isSingle: true }, - - received_at: { type: 'DatetimeType', isSingle: true }, - - created_by: { type: 'UserResponseCommonFields', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['UserUpdatedEvent'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - received_at: { type: 'DatetimeType', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['UserWatchingStartEvent'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - user: { type: 'UserResponseCommonFields', isSingle: true }, - - received_at: { type: 'DatetimeType', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['UserWatchingStopEvent'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - user: { type: 'UserResponseCommonFields', isSingle: true }, - - received_at: { type: 'DatetimeType', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['WrappedUnreadCountsResponse'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - channels: { type: 'UnreadCountsChannel', isSingle: false }, - - threads: { type: 'UnreadCountsThread', isSingle: false }, - }; - return decode(typeMappings, input); -}; diff --git a/src/gen/model-decoders/event-decoder-mapping.ts b/src/gen/model-decoders/event-decoder-mapping.ts deleted file mode 100644 index c0dc4826a4..0000000000 --- a/src/gen/model-decoders/event-decoder-mapping.ts +++ /dev/null @@ -1,198 +0,0 @@ -import type { WSEvent } from '../models'; -import { decoders } from '../model-decoders/decoders'; - -const eventDecoderMapping: { - [key in WSEvent['type']]: (data: Record) => WSEvent; -} = { - '*': (data: Record) => decoders.CustomEvent(data), - - 'ai_indicator.clear': (data: Record) => - decoders.AIIndicatorClearEvent(data), - - 'ai_indicator.stop': (data: Record) => decoders.AIIndicatorStopEvent(data), - - 'ai_indicator.update': (data: Record) => - decoders.AIIndicatorUpdateEvent(data), - - 'app.updated': (data: Record) => decoders.AppUpdatedEvent(data), - - 'channel.created': (data: Record) => decoders.ChannelCreatedEvent(data), - - 'channel.deleted': (data: Record) => decoders.ChannelDeletedEvent(data), - - 'channel.frozen': (data: Record) => decoders.ChannelFrozenEvent(data), - - 'channel.hidden': (data: Record) => decoders.ChannelHiddenEvent(data), - - 'channel.kicked': (data: Record) => decoders.ChannelKickedEvent(data), - - 'channel.max_streak_changed': (data: Record) => - decoders.MaxStreakChangedEvent(data), - - 'channel.truncated': (data: Record) => - decoders.ChannelTruncatedEvent(data), - - 'channel.unfrozen': (data: Record) => decoders.ChannelUnFrozenEvent(data), - - 'channel.updated': (data: Record) => decoders.ChannelUpdatedEvent(data), - - 'channel.visible': (data: Record) => decoders.ChannelVisibleEvent(data), - - 'draft.deleted': (data: Record) => decoders.DraftDeletedEvent(data), - - 'draft.updated': (data: Record) => decoders.DraftUpdatedEvent(data), - - 'health.check': (data: Record) => decoders.HealthCheckEvent(data), - - 'member.added': (data: Record) => decoders.MemberAddedEvent(data), - - 'member.removed': (data: Record) => decoders.MemberRemovedEvent(data), - - 'member.updated': (data: Record) => decoders.MemberUpdatedEvent(data), - - 'message.deleted': (data: Record) => decoders.MessageDeletedEvent(data), - - 'message.delivered': (data: Record) => - decoders.MessageDeliveredEvent(data), - - 'message.new': (data: Record) => decoders.MessageNewEvent(data), - - 'message.pending': (data: Record) => decoders.PendingMessageEvent(data), - - 'message.read': (data: Record) => decoders.MessageReadEvent(data), - - 'message.undeleted': (data: Record) => - decoders.MessageUndeletedEvent(data), - - 'message.updated': (data: Record) => decoders.MessageUpdatedEvent(data), - - 'moderation.custom_action': (data: Record) => - decoders.ModerationCustomActionEvent(data), - - 'moderation.flagged': (data: Record) => - decoders.ModerationFlaggedEvent(data), - - 'moderation.mark_reviewed': (data: Record) => - decoders.ModerationMarkReviewedEvent(data), - - 'notification.added_to_channel': (data: Record) => - decoders.NotificationAddedToChannelEvent(data), - - 'notification.channel_deleted': (data: Record) => - decoders.NotificationChannelDeletedEvent(data), - - 'notification.channel_mutes_updated': (data: Record) => - decoders.NotificationChannelMutesUpdatedEvent(data), - - 'notification.channel_truncated': (data: Record) => - decoders.NotificationChannelTruncatedEvent(data), - - 'notification.invite_accepted': (data: Record) => - decoders.NotificationInviteAcceptedEvent(data), - - 'notification.invite_rejected': (data: Record) => - decoders.NotificationInviteRejectedEvent(data), - - 'notification.invited': (data: Record) => - decoders.NotificationInvitedEvent(data), - - 'notification.mark_read': (data: Record) => - decoders.NotificationMarkReadEvent(data), - - 'notification.mark_unread': (data: Record) => - decoders.NotificationMarkUnreadEvent(data), - - 'notification.message_new': (data: Record) => - decoders.NotificationNewMessageEvent(data), - - 'notification.mutes_updated': (data: Record) => - decoders.NotificationMutesUpdatedEvent(data), - - 'notification.reminder_due': (data: Record) => - decoders.ReminderNotificationEvent(data), - - 'notification.removed_from_channel': (data: Record) => - decoders.NotificationRemovedFromChannelEvent(data), - - 'notification.thread_message_new': (data: Record) => - decoders.NotificationThreadMessageNewEvent(data), - - 'poll.closed': (data: Record) => decoders.PollClosedEvent(data), - - 'poll.deleted': (data: Record) => decoders.PollDeletedEvent(data), - - 'poll.updated': (data: Record) => decoders.PollUpdatedEvent(data), - - 'poll.vote_casted': (data: Record) => decoders.PollVoteCastedEvent(data), - - 'poll.vote_changed': (data: Record) => decoders.PollVoteChangedEvent(data), - - 'poll.vote_removed': (data: Record) => decoders.PollVoteRemovedEvent(data), - - 'reaction.deleted': (data: Record) => decoders.ReactionDeletedEvent(data), - - 'reaction.new': (data: Record) => decoders.ReactionNewEvent(data), - - 'reaction.updated': (data: Record) => decoders.ReactionUpdatedEvent(data), - - 'reminder.created': (data: Record) => decoders.ReminderCreatedEvent(data), - - 'reminder.deleted': (data: Record) => decoders.ReminderDeletedEvent(data), - - 'reminder.updated': (data: Record) => decoders.ReminderUpdatedEvent(data), - - 'thread.updated': (data: Record) => decoders.ThreadUpdatedEvent(data), - - 'typing.start': (data: Record) => decoders.TypingStartEvent(data), - - 'typing.stop': (data: Record) => decoders.TypingStopEvent(data), - - 'user.banned': (data: Record) => decoders.UserBannedEvent(data), - - 'user.deactivated': (data: Record) => decoders.UserDeactivatedEvent(data), - - 'user.deleted': (data: Record) => decoders.UserDeletedEvent(data), - - 'user.messages.deleted': (data: Record) => - decoders.UserMessagesDeletedEvent(data), - - 'user.muted': (data: Record) => decoders.UserMutedEvent(data), - - 'user.presence.changed': (data: Record) => - decoders.UserPresenceChangedEvent(data), - - 'user.reactivated': (data: Record) => decoders.UserReactivatedEvent(data), - - 'user.unbanned': (data: Record) => decoders.UserUnbannedEvent(data), - - 'user.updated': (data: Record) => decoders.UserUpdatedEvent(data), - - 'user.watching.start': (data: Record) => - decoders.UserWatchingStartEvent(data), - - 'user.watching.stop': (data: Record) => - decoders.UserWatchingStopEvent(data), - - 'user_group.created': (data: Record) => - decoders.UserGroupCreatedEvent(data), - - 'user_group.deleted': (data: Record) => - decoders.UserGroupDeletedEvent(data), - - 'user_group.member_added': (data: Record) => - decoders.UserGroupMemberAddedEvent(data), - - 'user_group.member_removed': (data: Record) => - decoders.UserGroupMemberRemovedEvent(data), - - 'user_group.updated': (data: Record) => - decoders.UserGroupUpdatedEvent(data), -}; - -export const decodeWSEvent = (data: { type: string } & Record) => { - if (Object.hasOwn(eventDecoderMapping, data.type)) { - return eventDecoderMapping[data.type as WSEvent['type']](data); - } else { - return data; - } -}; diff --git a/src/gen/models/index.ts b/src/gen/models/index.ts index 974e8b4ef1..1b3e375154 100644 --- a/src/gen/models/index.ts +++ b/src/gen/models/index.ts @@ -28,9 +28,11 @@ export type Filters< // The value an operator takes on one filter key. `valueTypes` carries the // per-operator overrides the spec publishes and is checked first, so an override // also suppresses the `| null` the $eq/$ne rule would otherwise add — the backend -// rejects null wherever an override applies. Everything else follows the two -// universal rules ($in/$nin take an array of the key's type, $exists takes a -// boolean) and finally the key's own type. +// rejects null wherever an override applies. `closedSet` suppresses it for the +// same reason: a key that publishes accepted_values takes those values and +// nothing else, null included. Everything else follows the two universal rules +// ($in/$nin take an array of the key's type, $exists takes a boolean) and finally +// the key's own type. export type FilterValue< Entry extends { type: any }, Operator extends string, @@ -41,7 +43,9 @@ export type FilterValue< : Operator extends '$exists' ? boolean : Operator extends '$eq' | '$ne' - ? Entry['type'] | null + ? Entry extends { closedSet: true } + ? Entry['type'] + : Entry['type'] | null : Entry['type']; export type FCHelper< @@ -123,7 +127,7 @@ export interface AIIndicatorClearEvent { /** * Date/time of creation */ - created_at: Date; + created_at: number; custom: CustomEventData; @@ -147,14 +151,14 @@ export interface AIIndicatorClearEvent { */ cid?: string; - received_at?: Date; + received_at?: number; } export interface AIIndicatorStopEvent { /** * Date/time of creation */ - created_at: Date; + created_at: number; custom: CustomEventData; @@ -178,7 +182,7 @@ export interface AIIndicatorStopEvent { */ cid?: string; - received_at?: Date; + received_at?: number; } export interface AIIndicatorUpdateEvent { @@ -190,7 +194,7 @@ export interface AIIndicatorUpdateEvent { /** * Date/time of creation */ - created_at: Date; + created_at: number; /** * The ID of the message @@ -224,7 +228,7 @@ export interface AIIndicatorUpdateEvent { */ cid?: string; - received_at?: Date; + received_at?: number; } export interface AITextConfig { @@ -315,7 +319,7 @@ export interface ActionLogResponse { /** * Timestamp when the action was taken */ - created_at: Date; + created_at: number; /** * Unique identifier of the action log @@ -444,7 +448,7 @@ export interface AppUpdatedEvent { /** * Date/time of creation */ - created_at: Date; + created_at: number; app: AppEventResponse; @@ -455,7 +459,7 @@ export interface AppUpdatedEvent { */ type: string; - received_at?: Date; + received_at?: number; } export interface AppealItemResponse { @@ -467,7 +471,7 @@ export interface AppealItemResponse { /** * When the flag was created */ - created_at: Date; + created_at: number; /** * ID of the entity @@ -489,7 +493,7 @@ export interface AppealItemResponse { /** * When the flag was last updated */ - updated_at: Date; + updated_at: number; /** * Text severity level assigned by the AI provider @@ -752,7 +756,7 @@ export interface BanInfoResponse { /** * When the ban was created */ - created_at: Date; + created_at: number; /** * The channel this ban applies to. Empty if this is an app-wide (global) ban rather than a per-channel ban. @@ -762,7 +766,7 @@ export interface BanInfoResponse { /** * When the ban expires */ - expires?: Date; + expires?: number; /** * Reason for the ban @@ -805,11 +809,6 @@ export interface BanRequest { */ target_user_id: string; - /** - * ID of the user performing the ban - */ - banned_by_id?: string; - /** * Channel where the ban applies */ @@ -836,17 +835,12 @@ export interface BanRequest { * Duration of the ban in minutes */ timeout?: number; - - /** - * User request object - */ - banned_by?: UserRequest; } export interface BanResponse { - created_at: Date; + created_at: number; - expires?: Date; + expires?: number; reason?: string; @@ -925,7 +919,7 @@ export interface BlockListResponse { /** * Date/time of creation */ - created_at?: Date; + created_at?: number; id?: string; @@ -936,7 +930,7 @@ export interface BlockListResponse { /** * Date/time of the last update */ - updated_at?: Date; + updated_at?: number; } export interface BlockListRule { @@ -976,7 +970,7 @@ export interface BlockUsersResponse { /** * Timestamp when the user was blocked */ - created_at: Date; + created_at: number; /** * Duration of the request in milliseconds @@ -990,7 +984,7 @@ export interface BlockedUserResponse { */ blocked_user_id: string; - created_at: Date; + created_at: number; /** * ID of the user who blocked another user @@ -1271,7 +1265,7 @@ export interface ChannelConfigWithInfo { count_messages: boolean; - created_at: Date; + created_at: number; custom_events: boolean; @@ -1281,6 +1275,8 @@ export interface ChannelConfigWithInfo { max_message_length: number; + message_retention: string; + mutes: boolean; name: string; @@ -1307,7 +1303,7 @@ export interface ChannelConfigWithInfo { typing_events: boolean; - updated_at: Date; + updated_at: number; uploads: boolean; @@ -1367,7 +1363,7 @@ export interface ChannelCreatedEvent { /** * Date/time of creation */ - created_at: Date; + created_at: number; /** * Represents channel in chat @@ -1403,7 +1399,7 @@ export interface ChannelCreatedEvent { */ cid?: string; - received_at?: Date; + received_at?: number; /** * The team ID @@ -1419,7 +1415,7 @@ export interface ChannelDeletedEvent { /** * Date/time of creation */ - created_at: Date; + created_at: number; /** * Represents channel in chat @@ -1455,7 +1451,7 @@ export interface ChannelDeletedEvent { */ cid?: string; - received_at?: Date; + received_at?: number; /** * The team ID @@ -1471,7 +1467,7 @@ export interface ChannelFrozenEvent { /** * Date/time of creation */ - created_at: Date; + created_at: number; custom: CustomEventData; @@ -1495,7 +1491,7 @@ export interface ChannelFrozenEvent { */ cid?: string; - received_at?: Date; + received_at?: number; } export interface ChannelGetOrCreateRequest { @@ -1544,7 +1540,7 @@ export interface ChannelHiddenEvent { /** * Date/time of creation */ - created_at: Date; + created_at: number; /** * Represents channel in chat @@ -1580,7 +1576,7 @@ export interface ChannelHiddenEvent { */ cid?: string; - received_at?: Date; + received_at?: number; /** * The team ID @@ -1670,7 +1666,7 @@ export interface ChannelKickedEvent { /** * Date/time of creation */ - created_at: Date; + created_at: number; custom: CustomEventData; @@ -1701,7 +1697,7 @@ export interface ChannelKickedEvent { */ cid?: string; - received_at?: Date; + received_at?: number; /** * The team ID @@ -1729,19 +1725,16 @@ export interface ChannelMemberPartialResponse { } export interface ChannelMemberRequest { - user_id: string; - /** * Role of the member in the channel */ channel_role?: string; + user_id?: string; + custom?: CustomMemberData; - /** - * User response object - */ - user?: UserResponse; + user?: MemberUserRequest; } export interface ChannelMemberResponse { @@ -1758,7 +1751,7 @@ export interface ChannelMemberResponse { /** * Date/time of creation */ - created_at: Date; + created_at: number; notifications_muted: boolean; @@ -1770,38 +1763,38 @@ export interface ChannelMemberResponse { /** * Date/time of the last update */ - updated_at: Date; + updated_at: number; custom: CustomMemberData; - archived_at?: Date; + archived_at?: number; /** * Expiration date of the ban */ - ban_expires?: Date; + ban_expires?: number; /** * Whether the member's ban also applies to channels the channel's creator will create in the future (an active future channel ban by the creator targets this member) */ ban_from_future_channels?: boolean; - deleted_at?: Date; + deleted_at?: number; /** * Expiration date of the future channel ban; absent when the future channel ban is permanent */ - future_channel_ban_expires?: Date; + future_channel_ban_expires?: number; /** * Date when invite was accepted */ - invite_accepted_at?: Date; + invite_accepted_at?: number; /** * Date when invite was rejected */ - invite_rejected_at?: Date; + invite_rejected_at?: number; /** * Whether member was invited or not @@ -1813,7 +1806,7 @@ export interface ChannelMemberResponse { */ is_moderator?: boolean; - pinned_at?: Date; + pinned_at?: number; /** * Permission level of the member in the channel (DEPRECATED: use channel_role instead). One of: member, moderator, admin, owner @@ -1847,7 +1840,7 @@ export interface ChannelMetadata { custom: CustomChannelData; - last_message_at?: Date; + last_message_at?: number; member_count?: number; @@ -1862,17 +1855,17 @@ export interface ChannelMute { /** * Date/time of creation */ - created_at: Date; + created_at: number; /** * Date/time of the last update */ - updated_at: Date; + updated_at: number; /** * Date/time of mute expiration */ - expires?: Date; + expires?: number; /** * Represents channel in chat @@ -1936,7 +1929,7 @@ export type ChannelOwnCapability = export interface ChannelPushPreferencesResponse { chat_level?: string; - disabled_until?: Date; + disabled_until?: number; chat_preferences?: ChatPreferencesResponse; } @@ -1950,7 +1943,7 @@ export interface ChannelResponse { /** * Date/time of creation */ - created_at: Date; + created_at: number; disabled: boolean; @@ -1972,7 +1965,7 @@ export interface ChannelResponse { /** * Date/time of the last update */ - updated_at: Date; + updated_at: number; /** * Custom data for this object @@ -2002,7 +1995,7 @@ export interface ChannelResponse { /** * Date/time of deletion */ - deleted_at?: Date; + deleted_at?: number; /** * Whether this channel is hidden by current user or not @@ -2012,12 +2005,12 @@ export interface ChannelResponse { /** * Date since when the message history is accessible */ - hide_messages_before?: Date; + hide_messages_before?: number; /** * Date of the last message sent */ - last_message_at?: Date; + last_message_at?: number; /** * Number of members in the channel @@ -2032,7 +2025,7 @@ export interface ChannelResponse { /** * Date of mute expiration */ - mute_expires_at?: Date; + mute_expires_at?: number; /** * Whether this channel is muted or not @@ -2047,7 +2040,7 @@ export interface ChannelResponse { /** * Date of the latest truncation of the channel */ - truncated_at?: Date; + truncated_at?: number; /** * List of filter tags associated with the channel @@ -2090,7 +2083,7 @@ export interface ChannelStateResponse { hidden?: boolean; - hide_messages_before?: Date; + hide_messages_before?: number; watcher_count?: number; @@ -2140,7 +2133,7 @@ export interface ChannelStateResponseFields { /** * Messages before this date are hidden from the user */ - hide_messages_before?: Date; + hide_messages_before?: number; /** * Number of channel watchers @@ -2185,7 +2178,7 @@ export interface ChannelTruncatedEvent { /** * Date/time of creation */ - created_at: Date; + created_at: number; /** * Represents channel in chat @@ -2223,7 +2216,7 @@ export interface ChannelTruncatedEvent { message_id?: string; - received_at?: Date; + received_at?: number; /** * The team ID @@ -2244,7 +2237,7 @@ export interface ChannelUnFrozenEvent { /** * Date/time of creation */ - created_at: Date; + created_at: number; custom: CustomEventData; @@ -2268,14 +2261,14 @@ export interface ChannelUnFrozenEvent { */ cid?: string; - received_at?: Date; + received_at?: number; } export interface ChannelUpdatedEvent { /** * Date/time of creation */ - created_at: Date; + created_at: number; /** * Represents channel in chat @@ -2313,7 +2306,7 @@ export interface ChannelUpdatedEvent { message_id?: string; - received_at?: Date; + received_at?: number; /** * The team ID @@ -2334,7 +2327,7 @@ export interface ChannelVisibleEvent { /** * Date/time of creation */ - created_at: Date; + created_at: number; /** * Represents channel in chat @@ -2370,7 +2363,7 @@ export interface ChannelVisibleEvent { */ cid?: string; - received_at?: Date; + received_at?: number; /** * The team ID @@ -2413,7 +2406,7 @@ export interface ChatDraftPayloadResponse { export interface ChatDraftResponse { channel_cid: string; - created_at: Date; + created_at: number; message: ChatDraftPayloadResponse; @@ -2427,7 +2420,7 @@ export interface ChatDraftResponse { export interface ChatMessageResponse { cid: string; - created_at: Date; + created_at: number; deleted_reply_count: number; @@ -2451,7 +2444,7 @@ export interface ChatMessageResponse { type: string; - updated_at: Date; + updated_at: number; attachments: Array; @@ -2476,19 +2469,19 @@ export interface ChatMessageResponse { command?: string; - deleted_at?: Date; + deleted_at?: number; deleted_for_me?: boolean; - message_text_updated_at?: Date; + message_text_updated_at?: number; mml?: string; parent_id?: string; - pin_expires?: Date; + pin_expires?: number; - pinned_at?: Date; + pinned_at?: number; poll_id?: string; @@ -2601,9 +2594,9 @@ export interface ChatPreferencesResponse { export interface ChatReactionGroupResponse { count: number; - first_reaction_at: Date; + first_reaction_at: number; - last_reaction_at: Date; + last_reaction_at: number; sum_scores: number; @@ -2611,7 +2604,7 @@ export interface ChatReactionGroupResponse { } export interface ChatReactionGroupUserResponse { - created_at: Date; + created_at: number; user_id: string; @@ -2622,7 +2615,7 @@ export interface ChatReactionGroupUserResponse { } export interface ChatReactionResponse { - created_at: Date; + created_at: number; message_id: string; @@ -2630,7 +2623,7 @@ export interface ChatReactionResponse { type: string; - updated_at: Date; + updated_at: number; user_id: string; @@ -2645,15 +2638,15 @@ export interface ChatReactionResponse { export interface ChatReminderResponseData { channel_cid: string; - created_at: Date; + created_at: number; message_id: string; - updated_at: Date; + updated_at: number; user_id: string; - remind_at?: Date; + remind_at?: number; message?: ChatMessageResponse; @@ -2666,7 +2659,7 @@ export interface ChatReminderResponseData { export interface ChatSharedLocationResponseData { channel_cid: string; - created_at: Date; + created_at: number; created_by_device_id: string; @@ -2676,11 +2669,11 @@ export interface ChatSharedLocationResponseData { message_id: string; - updated_at: Date; + updated_at: number; user_id: string; - end_at?: Date; + end_at?: number; message?: ChatMessageResponse; } @@ -2721,12 +2714,12 @@ export interface Command { /** * Date/time of creation */ - created_at?: Date; + created_at?: number; /** * Date/time of the last update */ - updated_at?: Date; + updated_at?: number; } export interface ConfigOverridesRequest { @@ -2815,7 +2808,7 @@ export interface ConfigResponse { /** * When the configuration was created */ - created_at: Date; + created_at: number; /** * Unique identifier for the moderation configuration @@ -2830,7 +2823,7 @@ export interface ConfigResponse { /** * When the configuration was last updated */ - updated_at: Date; + updated_at: number; supported_video_call_harm_types: Array; @@ -3093,6 +3086,15 @@ export interface CreateReminderRequest { remind_at?: Date; } +export interface CreateReminderResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; + + reminder: ReminderResponseData; +} + export interface CreateUserGroupRequest { /** * The user friendly name of the user group @@ -3139,13 +3141,13 @@ export interface CustomActionRequestPayload { } export interface CustomEvent { - created_at: Date; + created_at: number; custom: CustomEventData; type: string; - received_at?: Date; + received_at?: number; } export interface Data { @@ -3416,7 +3418,7 @@ export interface DeviceResponse { /** * Date/time of creation */ - created_at: Date; + created_at: number; /** * Device ID @@ -3463,7 +3465,7 @@ export interface DraftDeletedEvent { /** * Date/time of creation */ - created_at: Date; + created_at: number; custom: CustomEventData; @@ -3482,7 +3484,7 @@ export interface DraftDeletedEvent { */ parent_id?: string; - received_at?: Date; + received_at?: number; draft?: DraftResponse; } @@ -3551,7 +3553,7 @@ export interface DraftPayloadResponse { export interface DraftResponse { channel_cid: string; - created_at: Date; + created_at: number; /** * Contains the draft message content @@ -3580,7 +3582,7 @@ export interface DraftUpdatedEvent { /** * Date/time of creation */ - created_at: Date; + created_at: number; custom: CustomEventData; @@ -3599,7 +3601,7 @@ export interface DraftUpdatedEvent { */ parent_id?: string; - received_at?: Date; + received_at?: number; draft?: DraftResponse; } @@ -3666,7 +3668,7 @@ export interface EntityCreatorResponse { banned: boolean; - created_at: Date; + created_at: number; /** * Number of major actions performed on the user @@ -3686,7 +3688,7 @@ export interface EntityCreatorResponse { role: string; - updated_at: Date; + updated_at: number; blocked_user_ids: Array; @@ -3696,17 +3698,17 @@ export interface EntityCreatorResponse { avg_response_time?: number; - deactivated_at?: Date; + deactivated_at?: number; - deleted_at?: Date; + deleted_at?: number; image?: string; - last_active?: Date; + last_active?: number; name?: string; - revoke_tokens_issued_before?: Date; + revoke_tokens_issued_before?: number; teams_role?: Record; } @@ -3763,13 +3765,13 @@ export interface FeedsActivityLocation { } export interface FeedsBookmarkResponse { - created_at: Date; + created_at: number; object_id: string; object_type: string; - updated_at: Date; + updated_at: number; /** * User response object @@ -3782,7 +3784,7 @@ export interface FeedsBookmarkResponse { } export interface FeedsEnrichedCollectionResponse { - created_at: Date; + created_at: number; id: string; @@ -3790,7 +3792,7 @@ export interface FeedsEnrichedCollectionResponse { status: string; - updated_at: Date; + updated_at: number; user_id: string; @@ -3800,7 +3802,7 @@ export interface FeedsEnrichedCollectionResponse { export interface FeedsFeedResponse { activity_count: number; - created_at: Date; + created_at: number; description: string; @@ -3820,14 +3822,14 @@ export interface FeedsFeedResponse { pin_count: number; - updated_at: Date; + updated_at: number; /** * User response object */ created_by: UserResponse; - deleted_at?: Date; + deleted_at?: number; visibility?: string; @@ -3966,19 +3968,19 @@ export interface FeedsPreferencesResponse { export interface FeedsReactionGroupResponse { count: number; - first_reaction_at: Date; + first_reaction_at: number; - last_reaction_at: Date; + last_reaction_at: number; } export interface FeedsReactionResponse { activity_id: string; - created_at: Date; + created_at: number; type: string; - updated_at: Date; + updated_at: number; /** * User response object @@ -3993,7 +3995,7 @@ export interface FeedsReactionResponse { export interface FeedsShareResponse { activity_id: string; - created_at: Date; + created_at: number; /** * User response object @@ -4006,7 +4008,7 @@ export interface FeedsV3ActivityResponse { comment_count: number; - created_at: Date; + created_at: number; hidden: boolean; @@ -4026,7 +4028,7 @@ export interface FeedsV3ActivityResponse { type: string; - updated_at: Date; + updated_at: number; visibility: string; @@ -4061,11 +4063,11 @@ export interface FeedsV3ActivityResponse { */ user: UserResponse; - deleted_at?: Date; + deleted_at?: number; - edited_at?: Date; + edited_at?: number; - expires_at?: Date; + expires_at?: number; friend_reaction_count?: number; @@ -4111,7 +4113,7 @@ export interface FeedsV3CommentResponse { confidence_score: number; - created_at: Date; + created_at: number; downvote_count: number; @@ -4129,7 +4131,7 @@ export interface FeedsV3CommentResponse { status: string; - updated_at: Date; + updated_at: number; upvote_count: number; @@ -4144,9 +4146,9 @@ export interface FeedsV3CommentResponse { controversy_score?: number; - deleted_at?: Date; + deleted_at?: number; - edited_at?: Date; + edited_at?: number; parent_id?: string; @@ -4256,7 +4258,7 @@ export interface FlagDetailsResponse { } export interface FlagFeedbackResponse { - created_at: Date; + created_at: number; message_id: string; @@ -4366,7 +4368,7 @@ export interface FloodSimilarRuleParameters { export interface FullUserResponse { banned: boolean; - created_at: Date; + created_at: number; id: string; @@ -4388,7 +4390,7 @@ export interface FullUserResponse { unread_threads: number; - updated_at: Date; + updated_at: number; blocked_user_ids: Array; @@ -4404,19 +4406,19 @@ export interface FullUserResponse { avg_response_time?: number; - ban_expires?: Date; + ban_expires?: number; - deactivated_at?: Date; + deactivated_at?: number; - deleted_at?: Date; + deleted_at?: number; image?: string; - last_active?: Date; + last_active?: number; name?: string; - revoke_tokens_issued_before?: Date; + revoke_tokens_issued_before?: number; latest_hidden_channels?: Array; @@ -4426,9 +4428,9 @@ export interface FullUserResponse { } export interface FutureChannelBanResponse { - created_at: Date; + created_at: number; - expires?: Date; + expires?: number; reason?: string; @@ -4515,8 +4517,6 @@ export interface GetMessageResponse { * Represents any chat message */ message: MessageWithChannelResponse; - - pending_message_metadata?: Record; } export interface GetOGResponse { @@ -4722,7 +4722,7 @@ export interface HarmConfig { export interface HealthCheckEvent { connection_id: string; - created_at: Date; + created_at: number; custom: CustomEventData; @@ -4730,7 +4730,7 @@ export interface HealthCheckEvent { cid?: string; - received_at?: Date; + received_at?: number; me?: OwnUserResponse; } @@ -5026,11 +5026,11 @@ export interface MarkReadResponseEvent { cid: string; - created_at: Date; + created_at: number; type: string; - channel_last_message_at?: Date; + channel_last_message_at?: number; last_read_message_id?: string; @@ -5081,20 +5081,20 @@ export interface MarkUnreadRequest { } export interface MaxStreakChangedEvent { - created_at: Date; + created_at: number; custom: CustomEventData; type: string; - received_at?: Date; + received_at?: number; } export interface MemberAddedEvent { /** * Date/time of creation */ - created_at: Date; + created_at: number; /** * Represents channel in chat @@ -5135,7 +5135,7 @@ export interface MemberAddedEvent { */ cid?: string; - received_at?: Date; + received_at?: number; /** * The team ID @@ -5151,7 +5151,7 @@ export interface MemberRemovedEvent { /** * Date/time of creation */ - created_at: Date; + created_at: number; /** * Represents channel in chat @@ -5192,7 +5192,7 @@ export interface MemberRemovedEvent { */ cid?: string; - received_at?: Date; + received_at?: number; /** * The team ID @@ -5208,7 +5208,7 @@ export interface MemberUpdatedEvent { /** * Date/time of creation */ - created_at: Date; + created_at: number; /** * Represents channel in chat @@ -5249,7 +5249,7 @@ export interface MemberUpdatedEvent { */ cid?: string; - received_at?: Date; + received_at?: number; /** * The team ID @@ -5261,6 +5261,22 @@ export interface MemberUpdatedEvent { user?: UserResponseCommonFields; } +export interface MemberUserRequest { + id: string; + + image?: string; + + invisible?: boolean; + + language?: string; + + name?: string; + + custom?: CustomUserData; + + privacy_settings?: PrivacySettingsResponse; +} + export interface MembersResponse { /** * Duration of the request in milliseconds @@ -5316,7 +5332,7 @@ export interface MessageDeletedEvent { /** * Date/time of creation */ - created_at: Date; + created_at: number; /** * Whether the message was hard deleted @@ -5367,7 +5383,7 @@ export interface MessageDeletedEvent { */ deleted_for_me?: boolean; - received_at?: Date; + received_at?: number; /** * The team ID @@ -5383,7 +5399,7 @@ export interface MessageDeliveredEvent { /** * Date/time of creation */ - created_at: Date; + created_at: number; custom: CustomEventData; @@ -5427,7 +5443,7 @@ export interface MessageDeliveredEvent { */ last_delivered_message_id?: string; - received_at?: Date; + received_at?: number; /** * The team ID @@ -5445,19 +5461,19 @@ export interface MessageDeliveredEvent { } export interface MessageFlagResponse { - created_at: Date; + created_at: number; created_by_automod: boolean; - updated_at: Date; + updated_at: number; - approved_at?: Date; + approved_at?: number; reason?: string; - rejected_at?: Date; + rejected_at?: number; - reviewed_at?: Date; + reviewed_at?: number; custom?: Record; @@ -5495,7 +5511,7 @@ export interface MessageModerationResult { /** * Date/time of creation */ - created_at: Date; + created_at: number; /** * ID of the message @@ -5505,7 +5521,7 @@ export interface MessageModerationResult { /** * Date/time of the last update */ - updated_at: Date; + updated_at: number; /** * Whether user has bad karma @@ -5544,7 +5560,7 @@ export interface MessageNewEvent { /** * Date/time of creation */ - created_at: Date; + created_at: number; message_id: string; @@ -5595,7 +5611,7 @@ export interface MessageNewEvent { */ parent_author?: string; - received_at?: Date; + received_at?: number; /** * The team ID @@ -5695,7 +5711,7 @@ export interface MessageReadEvent { /** * Date/time of creation */ - created_at: Date; + created_at: number; custom: CustomEventData; @@ -5734,7 +5750,7 @@ export interface MessageReadEvent { */ last_read_message_id?: string; - received_at?: Date; + received_at?: number; /** * The team ID @@ -5852,7 +5868,7 @@ export interface MessageResponse { /** * Date/time of creation */ - created_at: Date; + created_at: number; deleted_reply_count: number; @@ -5909,7 +5925,7 @@ export interface MessageResponse { /** * Date/time of the last update */ - updated_at: Date; + updated_at: number; /** * Array of message attachments @@ -5961,11 +5977,11 @@ export interface MessageResponse { /** * Date/time of deletion */ - deleted_at?: Date; + deleted_at?: number; deleted_for_me?: boolean; - message_text_updated_at?: Date; + message_text_updated_at?: number; /** * Should be empty if `text` is provided. Can only be set when using server-side API @@ -5980,12 +5996,12 @@ export interface MessageResponse { /** * Date when pinned message expires */ - pin_expires?: Date; + pin_expires?: number; /** * Date when message got pinned */ - pinned_at?: Date; + pinned_at?: number; /** * Identifier of the poll to include in the message @@ -6033,6 +6049,11 @@ export interface MessageResponse { member?: ChannelMemberPartialResponse; + /** + * Channel member data for the users mentioned in the message, keyed by user id. Only present when the app has member custom on mentioned users enabled, and only for the first two mentioned users of each message + */ + mentioned_channel_members?: Record; + moderation?: ModerationV2Response; /** @@ -6058,7 +6079,7 @@ export interface MessageUndeletedEvent { /** * Date/time of creation */ - created_at: Date; + created_at: number; message_id: string; @@ -6099,7 +6120,7 @@ export interface MessageUndeletedEvent { */ cid?: string; - received_at?: Date; + received_at?: number; /** * The team ID @@ -6119,7 +6140,7 @@ export interface MessageUpdatedEvent { /** * Date/time of creation */ - created_at: Date; + created_at: number; message_id: string; @@ -6160,7 +6181,7 @@ export interface MessageUpdatedEvent { */ cid?: string; - received_at?: Date; + received_at?: number; /** * The team ID @@ -6183,7 +6204,7 @@ export interface MessageWithChannelResponse { /** * Date/time of creation */ - created_at: Date; + created_at: number; deleted_reply_count: number; @@ -6240,7 +6261,7 @@ export interface MessageWithChannelResponse { /** * Date/time of the last update */ - updated_at: Date; + updated_at: number; /** * Array of message attachments @@ -6297,11 +6318,11 @@ export interface MessageWithChannelResponse { /** * Date/time of deletion */ - deleted_at?: Date; + deleted_at?: number; deleted_for_me?: boolean; - message_text_updated_at?: Date; + message_text_updated_at?: number; /** * Should be empty if `text` is provided. Can only be set when using server-side API @@ -6316,12 +6337,12 @@ export interface MessageWithChannelResponse { /** * Date when pinned message expires */ - pin_expires?: Date; + pin_expires?: number; /** * Date when message got pinned */ - pinned_at?: Date; + pinned_at?: number; /** * Identifier of the poll to include in the message @@ -6369,6 +6390,11 @@ export interface MessageWithChannelResponse { member?: ChannelMemberPartialResponse; + /** + * Channel member data for the users mentioned in the message, keyed by user id. Only present when the app has member custom on mentioned users enabled, and only for the first two mentioned users of each message + */ + mentioned_channel_members?: Record; + moderation?: ModerationV2Response; /** @@ -6440,7 +6466,7 @@ export interface ModerationCallResponse { cid: string; - created_at: Date; + created_at: number; current_session_id: string; @@ -6454,7 +6480,7 @@ export interface ModerationCallResponse { type: string; - updated_at: Date; + updated_at: number; blocked_user_ids: Array; @@ -6462,13 +6488,13 @@ export interface ModerationCallResponse { channel_cid?: string; - ended_at?: Date; + ended_at?: number; join_ahead_time_seconds?: number; routing_number?: string; - starts_at?: Date; + starts_at?: number; team?: string; @@ -6484,7 +6510,7 @@ export interface ModerationCustomActionEvent { */ action_id: string; - created_at: Date; + created_at: number; custom: CustomEventData; @@ -6492,7 +6518,7 @@ export interface ModerationCustomActionEvent { type: string; - received_at?: Date; + received_at?: number; /** * Additional options passed to the custom action @@ -6506,7 +6532,7 @@ export interface ModerationCustomActionEvent { } export interface ModerationFlagResponse { - created_at: Date; + created_at: number; entity_id: string; @@ -6514,7 +6540,7 @@ export interface ModerationFlagResponse { type: string; - updated_at: Date; + updated_at: number; user_id: string; @@ -6549,7 +6575,7 @@ export interface ModerationFlaggedEvent { */ content_type: string; - created_at: Date; + created_at: number; /** * The ID of the flagged content @@ -6560,11 +6586,11 @@ export interface ModerationFlaggedEvent { type: string; - received_at?: Date; + received_at?: number; } export interface ModerationMarkReviewedEvent { - created_at: Date; + created_at: number; custom: CustomEventData; @@ -6572,7 +6598,7 @@ export interface ModerationMarkReviewedEvent { type: string; - received_at?: Date; + received_at?: number; /** * Represents any chat message @@ -6655,7 +6681,7 @@ export interface ModerationPayloadResponse { } export interface ModerationQueueResponse { - created_at: Date; + created_at: number; created_by: string; @@ -6669,7 +6695,7 @@ export interface ModerationQueueResponse { type: string; - updated_at: Date; + updated_at: number; sort: Array>; @@ -6761,7 +6787,7 @@ export interface NotificationAddedToChannelEvent { /** * Date/time of creation */ - created_at: Date; + created_at: number; /** * Represents channel in chat @@ -6796,7 +6822,7 @@ export interface NotificationAddedToChannelEvent { */ cid?: string; - received_at?: Date; + received_at?: number; /** * The team ID @@ -6810,7 +6836,7 @@ export interface NotificationChannelDeletedEvent { /** * Date/time of creation */ - created_at: Date; + created_at: number; /** * Represents channel in chat @@ -6846,7 +6872,7 @@ export interface NotificationChannelDeletedEvent { */ cid?: string; - received_at?: Date; + received_at?: number; /** * The team ID @@ -6877,7 +6903,7 @@ export interface NotificationChannelMutesUpdatedEvent { /** * Date/time of creation */ - created_at: Date; + created_at: number; custom: CustomEventData; @@ -6888,14 +6914,14 @@ export interface NotificationChannelMutesUpdatedEvent { */ type: string; - received_at?: Date; + received_at?: number; } export interface NotificationChannelTruncatedEvent { /** * Date/time of creation */ - created_at: Date; + created_at: number; /** * Represents channel in chat @@ -6933,7 +6959,7 @@ export interface NotificationChannelTruncatedEvent { message_id?: string; - received_at?: Date; + received_at?: number; /** * The team ID @@ -6969,7 +6995,7 @@ export interface NotificationInviteAcceptedEvent { /** * Date/time of creation */ - created_at: Date; + created_at: number; /** * Represents channel in chat @@ -7010,7 +7036,7 @@ export interface NotificationInviteAcceptedEvent { */ cid?: string; - received_at?: Date; + received_at?: number; /** * The team ID @@ -7026,7 +7052,7 @@ export interface NotificationInviteRejectedEvent { /** * Date/time of creation */ - created_at: Date; + created_at: number; /** * Represents channel in chat @@ -7067,7 +7093,7 @@ export interface NotificationInviteRejectedEvent { */ cid?: string; - received_at?: Date; + received_at?: number; /** * The team ID @@ -7083,7 +7109,7 @@ export interface NotificationInvitedEvent { /** * Date/time of creation */ - created_at: Date; + created_at: number; /** * Represents channel in chat @@ -7124,7 +7150,7 @@ export interface NotificationInvitedEvent { */ cid?: string; - received_at?: Date; + received_at?: number; /** * The team ID @@ -7140,7 +7166,7 @@ export interface NotificationMarkReadEvent { /** * Date/time of creation */ - created_at: Date; + created_at: number; /** * The total number of unread messages @@ -7191,7 +7217,7 @@ export interface NotificationMarkReadEvent { */ last_read_message_id?: string; - received_at?: Date; + received_at?: number; /** * The team ID @@ -7231,7 +7257,7 @@ export interface NotificationMarkUnreadEvent { /** * Date/time of creation */ - created_at: Date; + created_at: number; custom: CustomEventData; @@ -7270,14 +7296,14 @@ export interface NotificationMarkUnreadEvent { /** * The time when the channel/thread was marked as unread */ - last_read_at?: Date; + last_read_at?: number; /** * The ID of the last read message */ last_read_message_id?: string; - received_at?: Date; + received_at?: number; /** * The team ID @@ -7335,7 +7361,7 @@ export interface NotificationMutesUpdatedEvent { /** * Date/time of creation */ - created_at: Date; + created_at: number; custom: CustomEventData; @@ -7346,14 +7372,14 @@ export interface NotificationMutesUpdatedEvent { */ type: string; - received_at?: Date; + received_at?: number; } export interface NotificationNewMessageEvent { /** * Date/time of creation */ - created_at: Date; + created_at: number; message_id: string; @@ -7400,7 +7426,7 @@ export interface NotificationNewMessageEvent { parent_author?: string; - received_at?: Date; + received_at?: number; /** * The team ID @@ -7427,7 +7453,7 @@ export interface NotificationRemovedFromChannelEvent { /** * Date/time of creation */ - created_at: Date; + created_at: number; /** * Represents channel in chat @@ -7468,7 +7494,7 @@ export interface NotificationRemovedFromChannelEvent { */ cid?: string; - received_at?: Date; + received_at?: number; /** * The team ID @@ -7484,7 +7510,7 @@ export interface NotificationThreadMessageNewEvent { /** * Date/time of creation */ - created_at: Date; + created_at: number; message_id: string; @@ -7536,7 +7562,7 @@ export interface NotificationThreadMessageNewEvent { parent_author?: string; - received_at?: Date; + received_at?: number; /** * The team ID @@ -7576,7 +7602,7 @@ export interface OnlyUserID { export interface OwnUserResponse { banned: boolean; - created_at: Date; + created_at: number; id: string; @@ -7596,7 +7622,7 @@ export interface OwnUserResponse { unread_threads: number; - updated_at: Date; + updated_at: number; channel_mutes: Array; @@ -7610,17 +7636,17 @@ export interface OwnUserResponse { avg_response_time?: number; - deactivated_at?: Date; + deactivated_at?: number; - deleted_at?: Date; + deleted_at?: number; image?: string; - last_active?: Date; + last_active?: number; name?: string; - revoke_tokens_issued_before?: Date; + revoke_tokens_issued_before?: number; blocked_user_ids?: Array; @@ -7653,7 +7679,7 @@ export interface PendingMessageEvent { /** * Date/time of creation */ - created_at: Date; + created_at: number; /** * The method used for the pending message @@ -7667,7 +7693,7 @@ export interface PendingMessageEvent { */ type: string; - received_at?: Date; + received_at?: number; /** * Represents channel in chat @@ -7701,8 +7727,6 @@ export interface PendingMessageResponse { */ message?: MessageResponse; - metadata?: Record; - /** * User response object */ @@ -7713,7 +7737,7 @@ export interface PollClosedEvent { /** * Date/time of creation */ - created_at: Date; + created_at: number; custom: CustomEventData; @@ -7736,14 +7760,14 @@ export interface PollClosedEvent { */ message_id?: string; - received_at?: Date; + received_at?: number; } export interface PollDeletedEvent { /** * Date/time of creation */ - created_at: Date; + created_at: number; custom: CustomEventData; @@ -7766,7 +7790,7 @@ export interface PollDeletedEvent { */ message_id?: string; - received_at?: Date; + received_at?: number; } export interface PollOptionInput { @@ -7816,7 +7840,7 @@ export interface PollResponseData { answers_count: number; - created_at: Date; + created_at: number; created_by_id: string; @@ -7828,11 +7852,15 @@ export interface PollResponseData { name: string; - updated_at: Date; + updated_at: number; vote_count: number; - voting_visibility: string; + /** + * Voting visibility of the poll + */ + + voting_visibility: 'anonymous' | 'public'; latest_answers: Array; @@ -7860,7 +7888,7 @@ export interface PollUpdatedEvent { /** * Date/time of creation */ - created_at: Date; + created_at: number; custom: CustomEventData; @@ -7883,14 +7911,14 @@ export interface PollUpdatedEvent { */ message_id?: string; - received_at?: Date; + received_at?: number; } export interface PollVoteCastedEvent { /** * Date/time of creation */ - created_at: Date; + created_at: number; custom: CustomEventData; @@ -7915,14 +7943,14 @@ export interface PollVoteCastedEvent { */ message_id?: string; - received_at?: Date; + received_at?: number; } export interface PollVoteChangedEvent { /** * Date/time of creation */ - created_at: Date; + created_at: number; custom: CustomEventData; @@ -7947,14 +7975,14 @@ export interface PollVoteChangedEvent { */ message_id?: string; - received_at?: Date; + received_at?: number; } export interface PollVoteRemovedEvent { /** * Date/time of creation */ - created_at: Date; + created_at: number; custom: CustomEventData; @@ -7979,7 +8007,7 @@ export interface PollVoteRemovedEvent { */ message_id?: string; - received_at?: Date; + received_at?: number; } export interface PollVoteResponse { @@ -7994,7 +8022,7 @@ export interface PollVoteResponse { } export interface PollVoteResponseData { - created_at: Date; + created_at: number; id: string; @@ -8002,7 +8030,7 @@ export interface PollVoteResponseData { poll_id: string; - updated_at: Date; + updated_at: number; answer_text?: string; @@ -8095,7 +8123,7 @@ export interface PushPreferencesResponse { chat_level?: string; - disabled_until?: Date; + disabled_until?: number; feeds_level?: string; @@ -8386,8 +8414,9 @@ export interface QueryChannelsRequest { }; has_unread: { - type: boolean; + type: true; operators: '$eq'; + closedSet: true; }; hidden: { @@ -8599,10 +8628,23 @@ export interface QueryFutureChannelBansResponse { export interface QueryMembersPayload { type: string; + id?: string; + + limit?: number; + + offset?: number; + + members?: Array; + + /** + * Array of sort parameters + */ + sort?: Array; + /** * Filter conditions to apply to the query */ - filter_conditions: Filters<{ + filter_conditions?: Filters<{ banned: { type: boolean; operators: '$eq'; @@ -8693,19 +8735,6 @@ export interface QueryMembersPayload { operators: '$eq' | '$exists' | '$gt' | '$gte' | '$in' | '$lt' | '$lte'; }; }>; - - id?: string; - - limit?: number; - - offset?: number; - - members?: Array; - - /** - * Array of sort parameters - */ - sort?: Array; } export interface QueryMessageFlagsPayload { @@ -9705,15 +9734,15 @@ export interface QueueResponse { export interface Reaction { activity_id: string; - created_at: Date; + created_at: number; kind: string; - updated_at: Date; + updated_at: number; user_id: string; - deleted_at?: Date; + deleted_at?: number; id?: string; @@ -9742,7 +9771,7 @@ export interface ReactionDeletedEvent { /** * Date/time of creation */ - created_at: Date; + created_at: number; /** * Represents channel in chat @@ -9783,7 +9812,7 @@ export interface ReactionDeletedEvent { message_id?: string; - received_at?: Date; + received_at?: number; /** * The team ID @@ -9816,12 +9845,12 @@ export interface ReactionGroupResponse { /** * FirstReactionAt is the time of the first reaction of this type. This is the same also if all reaction of this type are deleted, because if someone will react again with the same type, will be preserved the sorting. */ - first_reaction_at: Date; + first_reaction_at: number; /** * LastReactionAt is the time of the last reaction of this type. */ - last_reaction_at: Date; + last_reaction_at: number; /** * SumScores is the sum of all scores of reactions of this type. Medium allows you to clap articles more than once and shows the sum of all claps from all users. For example, you can send `clap` x5 using `score: 5`. @@ -9838,7 +9867,7 @@ export interface ReactionGroupUserResponse { /** * The time when the user reacted. */ - created_at: Date; + created_at: number; /** * The ID of the user who reacted. @@ -9855,7 +9884,7 @@ export interface ReactionNewEvent { /** * Date/time of creation */ - created_at: Date; + created_at: number; /** * Represents channel in chat @@ -9896,7 +9925,7 @@ export interface ReactionNewEvent { message_id?: string; - received_at?: Date; + received_at?: number; /** * The team ID @@ -9948,7 +9977,7 @@ export interface ReactionResponse { /** * Date/time of creation */ - created_at: Date; + created_at: number; /** * Message ID @@ -9968,7 +9997,7 @@ export interface ReactionResponse { /** * Date/time of the last update */ - updated_at: Date; + updated_at: number; /** * User ID @@ -9990,7 +10019,7 @@ export interface ReactionUpdatedEvent { /** * Date/time of creation */ - created_at: Date; + created_at: number; message_id: string; @@ -10036,7 +10065,7 @@ export interface ReactionUpdatedEvent { */ cid?: string; - received_at?: Date; + received_at?: number; /** * The team ID @@ -10055,7 +10084,7 @@ export interface ReadReceiptsResponse { } export interface ReadStateResponse { - last_read: Date; + last_read: number; unread_messages: number; @@ -10064,7 +10093,7 @@ export interface ReadStateResponse { */ user: UserResponse; - last_delivered_at?: Date; + last_delivered_at?: number; last_delivered_message_id?: string; @@ -10087,7 +10116,7 @@ export interface ReminderCreatedEvent { /** * Date/time of creation */ - created_at: Date; + created_at: number; /** * The ID of the message for which the reminder was created @@ -10101,6 +10130,8 @@ export interface ReminderCreatedEvent { custom: CustomEventData; + reminder: ReminderResponseData; + /** * The type of event: "reminder.created" in this case */ @@ -10111,9 +10142,7 @@ export interface ReminderCreatedEvent { */ parent_id?: string; - received_at?: Date; - - reminder?: ReminderResponseData; + received_at?: number; } export interface ReminderDeletedEvent { @@ -10125,7 +10154,7 @@ export interface ReminderDeletedEvent { /** * Date/time of creation */ - created_at: Date; + created_at: number; /** * The ID of the message for which the reminder was created @@ -10139,6 +10168,8 @@ export interface ReminderDeletedEvent { custom: CustomEventData; + reminder: ReminderResponseData; + /** * The type of event: "reminder.deleted" in this case */ @@ -10149,9 +10180,7 @@ export interface ReminderDeletedEvent { */ parent_id?: string; - received_at?: Date; - - reminder?: ReminderResponseData; + received_at?: number; } export interface ReminderNotificationEvent { @@ -10163,7 +10192,7 @@ export interface ReminderNotificationEvent { /** * Date/time of creation */ - created_at: Date; + created_at: number; /** * The ID of the message for which the reminder was created @@ -10177,6 +10206,8 @@ export interface ReminderNotificationEvent { custom: CustomEventData; + reminder: ReminderResponseData; + /** * The type of event: "notification.reminder_due" in this case */ @@ -10184,23 +10215,21 @@ export interface ReminderNotificationEvent { parent_id?: string; - received_at?: Date; - - reminder?: ReminderResponseData; + received_at?: number; } export interface ReminderResponseData { channel_cid: string; - created_at: Date; + created_at: number; message_id: string; - updated_at: Date; + updated_at: number; user_id: string; - remind_at?: Date; + remind_at?: number; /** * Represents channel in chat @@ -10227,7 +10256,7 @@ export interface ReminderUpdatedEvent { /** * Date/time of creation */ - created_at: Date; + created_at: number; /** * The ID of the message for which the reminder was created @@ -10241,6 +10270,8 @@ export interface ReminderUpdatedEvent { custom: CustomEventData; + reminder: ReminderResponseData; + /** * The type of event: "reminder.updated" in this case */ @@ -10251,9 +10282,7 @@ export interface ReminderUpdatedEvent { */ parent_id?: string; - received_at?: Date; - - reminder?: ReminderResponseData; + received_at?: number; } export interface RemoveUserGroupMembersRequest { @@ -10294,7 +10323,7 @@ export interface ReviewQueueItemResponse { /** * When the item was created */ - created_at: Date; + created_at: number; /** * ID of the entity being reviewed @@ -10343,7 +10372,7 @@ export interface ReviewQueueItemResponse { /** * When the item was last updated */ - updated_at: Date; + updated_at: number; /** * Moderation actions taken @@ -10368,7 +10397,7 @@ export interface ReviewQueueItemResponse { /** * When the review was completed */ - completed_at?: Date; + completed_at?: number; config_key?: string; @@ -10380,7 +10409,7 @@ export interface ReviewQueueItemResponse { /** * When the item was escalated */ - escalated_at?: Date; + escalated_at?: number; /** * ID of the moderator who escalated the item @@ -10390,7 +10419,7 @@ export interface ReviewQueueItemResponse { /** * When the item was reviewed */ - reviewed_at?: Date; + reviewed_at?: number; /** * Teams associated with this item @@ -10434,7 +10463,7 @@ export interface Role { /** * Date/time of creation */ - created_at: Date; + created_at: number; /** * Whether this is a custom role or built-in @@ -10449,7 +10478,7 @@ export interface Role { /** * Date/time of the last update */ - updated_at: Date; + updated_at: number; /** * List of scopes where this role is currently present. `.app` means that role is present in app-level grants @@ -10657,8 +10686,9 @@ export interface SearchPayload { }; has_unread: { - type: boolean; + type: true; operators: '$eq'; + closedSet: true; }; hidden: { @@ -10905,7 +10935,7 @@ export interface SearchResult { export interface SearchResultMessage { cid: string; - created_at: Date; + created_at: number; deleted_reply_count: number; @@ -10929,7 +10959,7 @@ export interface SearchResultMessage { type: string; - updated_at: Date; + updated_at: number; attachments: Array; @@ -10954,19 +10984,19 @@ export interface SearchResultMessage { command?: string; - deleted_at?: Date; + deleted_at?: number; deleted_for_me?: boolean; - message_text_updated_at?: Date; + message_text_updated_at?: number; mml?: string; parent_id?: string; - pin_expires?: Date; + pin_expires?: number; - pinned_at?: Date; + pinned_at?: number; poll_id?: string; @@ -10995,6 +11025,8 @@ export interface SearchResultMessage { member?: ChannelMemberPartialResponse; + mentioned_channel_members?: Record; + moderation?: ModerationV2Response; /** @@ -11103,11 +11135,6 @@ export interface SendMessageResponse { * Map of mentioned user ID to whether that user is currently an active channel member. Only set when include_mentioned_members was requested; omitted when the message has no mentions or the membership lookup failed */ mentioned_members?: Record; - - /** - * Pending message metadata - */ - pending_message_metadata?: Record; } export interface SendReactionRequest { @@ -11167,7 +11194,7 @@ export interface SharedLocationResponse { /** * Date/time of creation */ - created_at: Date; + created_at: number; /** * Device ID that created the live location @@ -11194,7 +11221,7 @@ export interface SharedLocationResponse { /** * Date/time of the last update */ - updated_at: Date; + updated_at: number; /** * User ID @@ -11204,7 +11231,7 @@ export interface SharedLocationResponse { /** * Time when the live location expires */ - end_at?: Date; + end_at?: number; /** * Represents channel in chat @@ -11220,7 +11247,7 @@ export interface SharedLocationResponse { export interface SharedLocationResponseData { channel_cid: string; - created_at: Date; + created_at: number; created_by_device_id: string; @@ -11230,11 +11257,11 @@ export interface SharedLocationResponseData { message_id: string; - updated_at: Date; + updated_at: number; user_id: string; - end_at?: Date; + end_at?: number; /** * Represents channel in chat @@ -11489,18 +11516,18 @@ export interface ThreadParticipant { /** * Date/time of creation */ - created_at: Date; + created_at: number; - last_read_at: Date; + last_read_at: number; custom: CustomThreadData; - last_thread_message_at?: Date; + last_thread_message_at?: number; /** * Left Thread At is the time when the user left the thread */ - left_thread_at?: Date; + left_thread_at?: number; /** * Thead ID is unique string identifier of the thread @@ -11532,7 +11559,7 @@ export interface ThreadResponse { /** * Date/time of creation */ - created_at: Date; + created_at: number; /** * Created By User ID @@ -11562,7 +11589,7 @@ export interface ThreadResponse { /** * Date/time of the last update */ - updated_at: Date; + updated_at: number; /** * Custom data for this object @@ -11572,12 +11599,12 @@ export interface ThreadResponse { /** * Deleted At */ - deleted_at?: Date; + deleted_at?: number; /** * Last Message At */ - last_message_at?: Date; + last_message_at?: number; /** * Thread Participants @@ -11614,7 +11641,7 @@ export interface ThreadStateResponse { /** * Date/time of creation */ - created_at: Date; + created_at: number; /** * Created By User ID @@ -11644,7 +11671,7 @@ export interface ThreadStateResponse { /** * Date/time of the last update */ - updated_at: Date; + updated_at: number; latest_replies: Array; @@ -11656,12 +11683,12 @@ export interface ThreadStateResponse { /** * Deleted At */ - deleted_at?: Date; + deleted_at?: number; /** * Last Message At */ - last_message_at?: Date; + last_message_at?: number; read?: Array; @@ -11689,7 +11716,7 @@ export interface ThreadStateResponse { } export interface ThreadUpdatedEvent { - created_at: Date; + created_at: number; custom: CustomEventData; @@ -11701,7 +11728,7 @@ export interface ThreadUpdatedEvent { cid?: string; - received_at?: Date; + received_at?: number; thread?: ThreadResponse; } @@ -11833,7 +11860,7 @@ export interface TypingStartEvent { /** * Date/time of creation */ - created_at: Date; + created_at: number; custom: CustomEventData; @@ -11862,7 +11889,7 @@ export interface TypingStartEvent { */ parent_id?: string; - received_at?: Date; + received_at?: number; user?: UserResponseCommonFields; } @@ -11871,7 +11898,7 @@ export interface TypingStopEvent { /** * Date/time of creation */ - created_at: Date; + created_at: number; custom: CustomEventData; @@ -11900,7 +11927,7 @@ export interface TypingStopEvent { */ parent_id?: string; - received_at?: Date; + received_at?: number; user?: UserResponseCommonFields; } @@ -11927,6 +11954,12 @@ export interface UnbanActionRequestPayload { target_user_id?: string; } +export interface UnbanRequest {} + +export interface UnbanResponse { + duration: string; +} + export interface UnblockActionRequestPayload { /** * Reason for the appeal decision @@ -11976,7 +12009,7 @@ export interface UnmuteResponse { export interface UnreadCountsChannel { channel_id: string; - last_read: Date; + last_read: number; unread_count: number; } @@ -11990,7 +12023,7 @@ export interface UnreadCountsChannelType { } export interface UnreadCountsThread { - last_read: Date; + last_read: number; last_read_message_id: string; @@ -12092,21 +12125,6 @@ export interface UpdateChannelRequest { */ add_members?: Array; - /** - * List of user IDs to make channel moderators - */ - add_moderators?: Array; - - /** - * List of channel member role assignments. If any specified user is not part of the channel, the request will fail - */ - assign_roles?: Array; - - /** - * List of user IDs to take away moderators status from - */ - demote_moderators?: Array; - /** * List of user IDs to invite to the channel */ @@ -12218,11 +12236,6 @@ export interface UpdateMessagePartialResponse { * Represents any chat message */ message?: MessageResponse; - - /** - * Pending message metadata - */ - pending_message_metadata?: Record; } export interface UpdateMessageRequest { @@ -12249,8 +12262,6 @@ export interface UpdateMessageResponse { * Represents any chat message */ message: MessageResponse; - - pending_message_metadata?: Record; } export interface UpdatePollOptionRequest { @@ -12658,7 +12669,7 @@ export interface UserBannedEvent { /** * Date/time of creation */ - created_at: Date; + created_at: number; custom: CustomEventData; @@ -12691,14 +12702,14 @@ export interface UserBannedEvent { /** * The expiration date of the ban */ - expiration?: Date; + expiration?: number; /** * The reason for the ban */ reason?: string; - received_at?: Date; + received_at?: number; /** * ID of the review queue item (flagged message) that triggered the ban, if the ban was applied from the moderation review queue @@ -12736,7 +12747,7 @@ export interface UserDeactivatedEvent { /** * Date/time of creation */ - created_at: Date; + created_at: number; custom: CustomEventData; @@ -12747,7 +12758,7 @@ export interface UserDeactivatedEvent { */ type: string; - received_at?: Date; + received_at?: number; created_by?: UserResponseCommonFields; } @@ -12756,7 +12767,7 @@ export interface UserDeletedEvent { /** * Date/time of creation */ - created_at: Date; + created_at: number; /** * The type of deletion that was used for the user's conversations. One of: hard, soft, pruning, (empty string) @@ -12797,19 +12808,19 @@ export interface UserDeletedEvent { */ type: string; - received_at?: Date; + received_at?: number; } export interface UserGroup { app_pk: number; - created_at: Date; + created_at: number; id: string; name: string; - updated_at: Date; + updated_at: number; created_by?: string; @@ -12824,7 +12835,7 @@ export interface UserGroupCreatedEvent { /** * Date/time of creation */ - created_at: Date; + created_at: number; custom: CustomEventData; @@ -12833,7 +12844,7 @@ export interface UserGroupCreatedEvent { */ type: string; - received_at?: Date; + received_at?: number; user?: UserResponseCommonFields; @@ -12844,7 +12855,7 @@ export interface UserGroupDeletedEvent { /** * Date/time of creation */ - created_at: Date; + created_at: number; custom: CustomEventData; @@ -12853,7 +12864,7 @@ export interface UserGroupDeletedEvent { */ type: string; - received_at?: Date; + received_at?: number; user?: UserResponseCommonFields; @@ -12863,7 +12874,7 @@ export interface UserGroupDeletedEvent { export interface UserGroupMember { app_pk: number; - created_at: Date; + created_at: number; group_id: string; @@ -12876,7 +12887,7 @@ export interface UserGroupMemberAddedEvent { /** * Date/time of creation */ - created_at: Date; + created_at: number; /** * The user IDs that were added @@ -12890,7 +12901,7 @@ export interface UserGroupMemberAddedEvent { */ type: string; - received_at?: Date; + received_at?: number; user?: UserResponseCommonFields; @@ -12901,7 +12912,7 @@ export interface UserGroupMemberRemovedEvent { /** * Date/time of creation */ - created_at: Date; + created_at: number; /** * The user IDs that were removed @@ -12915,7 +12926,7 @@ export interface UserGroupMemberRemovedEvent { */ type: string; - received_at?: Date; + received_at?: number; user?: UserResponseCommonFields; @@ -12923,13 +12934,13 @@ export interface UserGroupMemberRemovedEvent { } export interface UserGroupResponse { - created_at: Date; + created_at: number; id: string; name: string; - updated_at: Date; + updated_at: number; created_by?: string; @@ -12944,7 +12955,7 @@ export interface UserGroupUpdatedEvent { /** * Date/time of creation */ - created_at: Date; + created_at: number; custom: CustomEventData; @@ -12953,7 +12964,7 @@ export interface UserGroupUpdatedEvent { */ type: string; - received_at?: Date; + received_at?: number; user?: UserResponseCommonFields; @@ -12970,7 +12981,7 @@ export interface UserMessagesDeletedEvent { /** * Date/time of creation */ - created_at: Date; + created_at: number; custom: CustomEventData; @@ -13005,7 +13016,7 @@ export interface UserMessagesDeletedEvent { */ hard_delete?: boolean; - received_at?: Date; + received_at?: number; /** * The team of the channel where the target user's messages were deleted @@ -13016,11 +13027,11 @@ export interface UserMessagesDeletedEvent { } export interface UserMuteResponse { - created_at: Date; + created_at: number; - updated_at: Date; + updated_at: number; - expires?: Date; + expires?: number; /** * User response object @@ -13037,7 +13048,7 @@ export interface UserMutedEvent { /** * Date/time of creation */ - created_at: Date; + created_at: number; custom: CustomEventData; @@ -13048,7 +13059,7 @@ export interface UserMutedEvent { */ type: string; - received_at?: Date; + received_at?: number; /** * The target users that were muted @@ -13062,7 +13073,7 @@ export interface UserPresenceChangedEvent { /** * Date/time of creation */ - created_at: Date; + created_at: number; custom: CustomEventData; @@ -13073,14 +13084,14 @@ export interface UserPresenceChangedEvent { */ type: string; - received_at?: Date; + received_at?: number; } export interface UserReactivatedEvent { /** * Date/time of creation */ - created_at: Date; + created_at: number; custom: CustomEventData; @@ -13091,7 +13102,7 @@ export interface UserReactivatedEvent { */ type: string; - received_at?: Date; + received_at?: number; created_by?: UserResponseCommonFields; } @@ -13133,7 +13144,7 @@ export interface UserResponse { /** * Date/time of creation */ - created_at: Date; + created_at: number; /** * Unique user identifier @@ -13158,7 +13169,7 @@ export interface UserResponse { /** * Date/time of the last update */ - updated_at: Date; + updated_at: number; blocked_user_ids: Array; @@ -13177,19 +13188,19 @@ export interface UserResponse { /** * Date of deactivation */ - deactivated_at?: Date; + deactivated_at?: number; /** * Date/time of deletion */ - deleted_at?: Date; + deleted_at?: number; image?: string; /** * Date of last activity */ - last_active?: Date; + last_active?: number; /** * Optional name of user @@ -13199,7 +13210,7 @@ export interface UserResponse { /** * Revocation date for tokens */ - revoke_tokens_issued_before?: Date; + revoke_tokens_issued_before?: number; teams_role?: Record; } @@ -13207,7 +13218,7 @@ export interface UserResponse { export interface UserResponseCommonFields { banned: boolean; - created_at: Date; + created_at: number; id: string; @@ -13217,7 +13228,7 @@ export interface UserResponseCommonFields { role: string; - updated_at: Date; + updated_at: number; blocked_user_ids: Array; @@ -13227,17 +13238,17 @@ export interface UserResponseCommonFields { avg_response_time?: number; - deactivated_at?: Date; + deactivated_at?: number; - deleted_at?: Date; + deleted_at?: number; image?: string; - last_active?: Date; + last_active?: number; name?: string; - revoke_tokens_issued_before?: Date; + revoke_tokens_issued_before?: number; teams_role?: Record; } @@ -13245,7 +13256,7 @@ export interface UserResponseCommonFields { export interface UserResponsePrivacyFields { banned: boolean; - created_at: Date; + created_at: number; id: string; @@ -13255,7 +13266,7 @@ export interface UserResponsePrivacyFields { role: string; - updated_at: Date; + updated_at: number; blocked_user_ids: Array; @@ -13265,19 +13276,19 @@ export interface UserResponsePrivacyFields { avg_response_time?: number; - deactivated_at?: Date; + deactivated_at?: number; - deleted_at?: Date; + deleted_at?: number; image?: string; invisible?: boolean; - last_active?: Date; + last_active?: number; name?: string; - revoke_tokens_issued_before?: Date; + revoke_tokens_issued_before?: number; privacy_settings?: PrivacySettingsResponse; @@ -13298,7 +13309,7 @@ export interface UserUnbannedEvent { /** * Date/time of creation */ - created_at: Date; + created_at: number; custom: CustomEventData; @@ -13328,7 +13339,7 @@ export interface UserUnbannedEvent { */ cid?: string; - received_at?: Date; + received_at?: number; /** * Whether the target user was shadow unbanned @@ -13349,7 +13360,7 @@ export interface UserUpdatedEvent { /** * Date/time of creation */ - created_at: Date; + created_at: number; custom: CustomEventData; @@ -13360,14 +13371,14 @@ export interface UserUpdatedEvent { */ type: string; - received_at?: Date; + received_at?: number; } export interface UserWatchingStartEvent { /** * Date/time of creation */ - created_at: Date; + created_at: number; /** * The number of users watching the channel @@ -13398,14 +13409,14 @@ export interface UserWatchingStartEvent { */ cid?: string; - received_at?: Date; + received_at?: number; } export interface UserWatchingStopEvent { /** * Date/time of creation */ - created_at: Date; + created_at: number; /** * The number of users watching the channel @@ -13436,7 +13447,7 @@ export interface UserWatchingStopEvent { */ cid?: string; - received_at?: Date; + received_at?: number; } export interface VelocityFilterConfig { diff --git a/src/gen/moderation/ModerationApi.ts b/src/gen/moderation/ModerationApi.ts index fa6e9f9163..67279d1756 100644 --- a/src/gen/moderation/ModerationApi.ts +++ b/src/gen/moderation/ModerationApi.ts @@ -31,6 +31,8 @@ import type { QueueResponse, SubmitActionRequest, SubmitActionResponse, + UnbanRequest, + UnbanResponse, UnmuteRequest, UnmuteResponse, UpdateQueueRequest, @@ -39,7 +41,6 @@ import type { UpsertConfigRequest, UpsertConfigResponse, } from '../models'; -import { decoders } from '../model-decoders/decoders'; export class ModerationApi { constructor(public readonly apiClient: ApiClient) {} @@ -72,8 +73,6 @@ export class ModerationApi { requestOptions, ); - decoders['GetActionConfigResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -105,8 +104,6 @@ export class ModerationApi { requestOptions, ); - decoders['UpsertActionConfigResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -131,8 +128,6 @@ export class ModerationApi { requestOptions, ); - decoders['BulkUpsertActionConfigResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -157,8 +152,6 @@ export class ModerationApi { requestOptions, ); - decoders['BulkDeleteActionConfigResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -182,8 +175,6 @@ export class ModerationApi { requestOptions, ); - decoders['DeleteActionConfigResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -210,8 +201,6 @@ export class ModerationApi { requestOptions, ); - decoders['AppealResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -233,8 +222,6 @@ export class ModerationApi { requestOptions, ); - decoders['GetAppealResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -263,8 +250,6 @@ export class ModerationApi { requestOptions, ); - decoders['QueryAppealsResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -295,8 +280,6 @@ export class ModerationApi { requestOptions, ); - decoders['BulkActionAppealsResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -307,14 +290,12 @@ export class ModerationApi { ): Promise> { const body = { target_user_id: request?.target_user_id, - banned_by_id: request?.banned_by_id, channel_cid: request?.channel_cid, delete_messages: request?.delete_messages, ip_ban: request?.ip_ban, reason: request?.reason, shadow: request?.shadow, timeout: request?.timeout, - banned_by: request?.banned_by, }; const response = await this.apiClient.sendRequest< @@ -329,8 +310,6 @@ export class ModerationApi { requestOptions, ); - decoders['ModerationBanResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -374,8 +353,6 @@ export class ModerationApi { requestOptions, ); - decoders['UpsertConfigResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -402,8 +379,6 @@ export class ModerationApi { requestOptions, ); - decoders['DeleteModerationConfigResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -428,8 +403,6 @@ export class ModerationApi { requestOptions, ); - decoders['GetConfigResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -458,8 +431,6 @@ export class ModerationApi { requestOptions, ); - decoders['QueryModerationConfigsResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -487,8 +458,6 @@ export class ModerationApi { requestOptions, ); - decoders['FlagItemResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -512,8 +481,6 @@ export class ModerationApi { requestOptions, ); - decoders['MuteResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -530,8 +497,6 @@ export class ModerationApi { requestOptions, ); - decoders['ListQueuesResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -558,8 +523,6 @@ export class ModerationApi { requestOptions, ); - decoders['QueueResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -581,8 +544,6 @@ export class ModerationApi { requestOptions, ); - decoders['QueueResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -610,8 +571,6 @@ export class ModerationApi { requestOptions, ); - decoders['QueueResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -634,8 +593,6 @@ export class ModerationApi { requestOptions, ); - decoders['QueueResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -669,8 +626,6 @@ export class ModerationApi { requestOptions, ); - decoders['QueryReviewQueueResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -715,7 +670,28 @@ export class ModerationApi { requestOptions, ); - decoders['SubmitActionResponse']?.(response.body); + return { ...response.body, metadata: response.metadata }; + } + + async unban( + request: UnbanRequest & { target_user_id: string; channel_cid?: string }, + requestOptions?: StreamRequestOptions, + ): Promise> { + const queryParams = { + target_user_id: request?.target_user_id, + channel_cid: request?.channel_cid, + }; + const body = {}; + + const response = await this.apiClient.sendRequest>( + 'POST', + '/api/v2/moderation/unban', + undefined, + queryParams, + body, + 'application/json', + requestOptions, + ); return { ...response.body, metadata: response.metadata }; } @@ -739,8 +715,6 @@ export class ModerationApi { requestOptions, ); - decoders['UnmuteResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } } diff --git a/src/index.ts b/src/index.ts index 2e5aee6d22..2e3ac69fd5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -107,6 +107,16 @@ export { formatMessage, } from './utils'; export { FixedSizeQueueCache } from './utils/FixedSizeQueueCache'; +export { + convertTimestampToDate, + dateToNs, + msToNs, + nowNs, + NS_PER_MS, + nsToDate, + nsToMs, + nsToRfc3339, +} from './utils/time'; export * from './ChannelManager'; export * from './ConnectionRecoveryManager'; export * from './EventHandlerPipeline'; diff --git a/src/messageComposer/LocationComposer.ts b/src/messageComposer/LocationComposer.ts index 9d4fdcad63..b1ca05dbd5 100644 --- a/src/messageComposer/LocationComposer.ts +++ b/src/messageComposer/LocationComposer.ts @@ -1,6 +1,7 @@ import { StateStore } from '../store'; import type { MessageComposer } from './messageComposer'; import type { DraftMessage, LocalMessage, SharedLocation } from '../types'; +import { convertTimestampToDate } from '../utils/time'; export type Coords = { latitude: number; longitude: number }; @@ -24,13 +25,37 @@ export type LocationComposerState = { export type LocationComposerSnapshot = LocationComposerState; +/** + * Composer state holds a location in the shape the API accepts *back* — the fields + * `SharedLocation` declares, plus the `message_id` the SDK has always sent alongside them. + * + * A location read off a message is the other shape: it carries `channel_cid`, `user_id`, + * `created_at` and `updated_at` too, and its `end_at` is a unix-**nanosecond** number rather than a + * `Date`. Narrowing here rather than at each send site is what stops those response-only fields + * riding along into the next composition and reaching the API as raw nanosecond numbers — which is + * not a type error, because {@link LiveLocationPreview} omits `end_at` and so accepts an object + * carrying a numeric one. + */ const initState = ({ message, }: { message?: DraftMessage | LocalMessage; -}): LocationComposerState => ({ - location: message?.shared_location ?? null, -}); +}): LocationComposerState => { + const location = message?.shared_location; + if (!location) return { location: null }; + // Guarded: an absent or non-finite `end_at` leaves the location static rather than producing an + // `Invalid Date` that would later serialize as `null`. + const endAt = convertTimestampToDate(location.end_at); + return { + location: { + created_by_device_id: location.created_by_device_id, + latitude: location.latitude, + longitude: location.longitude, + message_id: location.message_id, + ...(endAt ? { end_at: endAt } : {}), + }, + }; +}; export class LocationComposer { readonly state: StateStore; @@ -55,8 +80,10 @@ export class LocationComposer { return this.state.getLatestValue().location; } - get validLocation(): SharedLocation | null { - const { durationMs, ...location } = (this.location ?? {}) as LiveLocationPreview; + get validLocation(): StaticLocationPreview | null { + const location = (this.location ?? {}) as LiveLocationPreview & + Pick; + const { durationMs, end_at } = location; if ( !!location?.created_by_device_id && location.message_id && @@ -64,10 +91,27 @@ export class LocationComposer { location.longitude && (typeof durationMs === 'undefined' || durationMs >= this.config.minShareDurationMs) ) { + // Listed field by field rather than spread, so this can only ever return what a + // `SharedLocation` request declares. The spread it replaces was how a response-shaped + // location reached the API: it carried the response's own `created_at` / `updated_at` / + // `channel_cid` / `user_id` straight into the outgoing payload. return { - ...location, - end_at: - typeof durationMs === 'number' ? new Date(Date.now() + durationMs) : undefined, + created_by_device_id: location.created_by_device_id, + latitude: location.latitude, + longitude: location.longitude, + message_id: location.message_id, + // Two ways to express an expiry, and they must not be conflated. A `durationMs` is a + // duration, so it resolves against the clock at composition time (see `setData`). An + // `end_at` is already absolute — keep it, rather than recomputing it, or editing a message + // would push its live location's expiry out by however long the edit took. Dropping it + // instead (what the previous `end_at: undefined` did whenever `durationMs` was absent, + // which is always the case for a location hydrated from a message) silently turned a live + // location into a static one on every edit. + ...(typeof durationMs === 'number' + ? { end_at: new Date(Date.now() + durationMs) } + : end_at + ? { end_at } + : {}), }; } return null; diff --git a/src/messageComposer/messageComposer.ts b/src/messageComposer/messageComposer.ts index 934bba5eb5..4c38def16d 100644 --- a/src/messageComposer/messageComposer.ts +++ b/src/messageComposer/messageComposer.ts @@ -15,6 +15,7 @@ import { import type { Unsubscribe } from '../store'; import { StateStore } from '../store'; import { formatMessage, generateUUIDv4, isLocalMessage } from '../utils'; +import { nowNs } from '../utils/time'; import { ConfigController } from '../configuration/ConfigController'; import { deepFreezeConfig } from '../configuration/utils/deepFreezeConfig'; import { mergeServerRestrictions } from '../configuration/utils/serverAuthority'; @@ -59,6 +60,11 @@ import { type UnregisterSubscriptions = Unsubscribe; +/** + * Composer edit-audit clock. Both values are unix nanoseconds, so a locally stamped change and a + * server-derived `created_at` / `updated_at` are directly comparable — which is the whole point of + * this state. + */ export type LastComposerChange = { draftUpdate: number | null; stateUpdate: number }; export type EditingAuditState = { @@ -123,11 +129,11 @@ const initEditingAuditState = ( composition?: DraftResponse | MessageResponse | LocalMessage, ): EditingAuditState => { let draftUpdate = null; - let stateUpdate = new Date().getTime(); + let stateUpdate = nowNs(); if (compositionIsDraftResponse(composition)) { - stateUpdate = draftUpdate = new Date(composition.created_at).getTime(); + stateUpdate = draftUpdate = composition.created_at; } else if (composition && isLocalMessage(composition)) { - stateUpdate = new Date(composition.updated_at).getTime(); + stateUpdate = composition.updated_at; } return { lastChange: { @@ -485,8 +491,8 @@ export class MessageComposer extends WithSubscriptions { // does not mean that the original edited message is different from the current state const editedMessageWasUpdated = - !!this.editedMessage?.updated_at && - new Date(this.editedMessage.updated_at).getTime() < this.lastChange.stateUpdate; + this.editedMessage?.updated_at != null && + this.editedMessage.updated_at < this.lastChange.stateUpdate; const draftWasChanged = !!this.lastChange.draftUpdate && @@ -731,13 +737,13 @@ export class MessageComposer extends WithSubscriptions { private logStateUpdateTimestamp() { this.editingAuditState.partialNext({ - lastChange: { ...this.lastChange, stateUpdate: new Date().getTime() }, + lastChange: { ...this.lastChange, stateUpdate: nowNs() }, }); } private logDraftUpdateTimestamp() { if (!this.config.drafts.enabled) return; - const timestamp = new Date().getTime(); + const timestamp = nowNs(); this.editingAuditState.partialNext({ lastChange: { draftUpdate: timestamp, stateUpdate: timestamp }, }); @@ -1025,7 +1031,7 @@ export class MessageComposer extends WithSubscriptions { }; compose = async (): Promise => { - const created_at = this.editedMessage?.created_at ?? new Date(); + const created_at = this.editedMessage?.created_at ?? nowNs(); const text = ''; const result = await this.compositionMiddlewareExecutor.execute({ @@ -1045,7 +1051,7 @@ export class MessageComposer extends WithSubscriptions { id: this.id, mentioned_users: [] as UserResponse[], parent_id: this.threadId ?? undefined, - pinned_at: this.editedMessage?.pinned_at || undefined, + pinned_at: this.editedMessage?.pinned_at ?? undefined, reaction_groups: undefined, status: this.editedMessage ? this.editedMessage.status : 'sending', text, @@ -1090,7 +1096,7 @@ export class MessageComposer extends WithSubscriptions { try { const optimisticDraftResponse = { channel_cid: this.channel.cid, - created_at: new Date(), + created_at: nowNs(), message: draft as DraftMessage, parent_id: draft.parent_id, quoted_message: this.quotedMessage ?? undefined, diff --git a/src/messageComposer/middleware/messageComposer/sharedLocation.ts b/src/messageComposer/middleware/messageComposer/sharedLocation.ts index aaf15be764..76dd037201 100644 --- a/src/messageComposer/middleware/messageComposer/sharedLocation.ts +++ b/src/messageComposer/middleware/messageComposer/sharedLocation.ts @@ -1,6 +1,7 @@ import type { MiddlewareHandlerParams } from '../../../middleware'; import type { SharedLocationResponseData as Gen_SharedLocationResponseData } from '../../../gen/models'; import type { MessageComposer } from '../../messageComposer'; +import { dateToNs, nowNs } from '../../../utils/time'; import type { MessageComposerMiddlewareState, MessageCompositionMiddleware, @@ -19,14 +20,18 @@ export const createSharedLocationCompositionMiddleware = ( const { locationComposer } = composer; const location = locationComposer.validLocation; if (!locationComposer || !location || !composer.client.user) return forward(); - const timestamp = new Date(); + const timestamp = nowNs(); + + // `localMessage` is response-shaped, so `end_at` crosses from `Date` to unix nanoseconds. + const { end_at, ...locationRest } = location; return next({ ...state, localMessage: { ...state.localMessage, shared_location: { - ...location, + ...locationRest, + ...(end_at != null ? { end_at: dateToNs(end_at) } : {}), channel_cid: composer.channel.cid, created_at: timestamp, updated_at: timestamp, diff --git a/src/messageDelivery/MessageDeliveryReporter.ts b/src/messageDelivery/MessageDeliveryReporter.ts index 607eb21eb4..bfdd73b345 100644 --- a/src/messageDelivery/MessageDeliveryReporter.ts +++ b/src/messageDelivery/MessageDeliveryReporter.ts @@ -232,8 +232,9 @@ export class MessageDeliveryReporter { if (!ownUserId) return; let latestMessages: LocalMessage[] = []; - let lastDeliveredAt: Date | undefined; - let lastReadAt: Date | undefined; + // Wire timestamps (unix nanoseconds), directly comparable against a message's `created_at`. + let lastDeliveredAt: number | undefined; + let lastReadAt: number | undefined; let key: string | undefined = undefined; // todo: unify the API for read state access btw channel and threads @@ -265,7 +266,7 @@ export class MessageDeliveryReporter { const [latestMessage] = latestMessages.slice(-1); const wholeCollectionIsRead = - !latestMessage || lastReadAt >= latestMessage.created_at; + !latestMessage || (lastReadAt ?? 0) >= latestMessage.created_at; if (wholeCollectionIsRead) return { key, id: null }; const wholeCollectionIsMarkedDelivered = !latestMessage || (lastDeliveredAt ?? 0) >= latestMessage.created_at; diff --git a/src/messageDelivery/MessageReceiptsTracker.ts b/src/messageDelivery/MessageReceiptsTracker.ts index efb61b4c7c..7235b30a1d 100644 --- a/src/messageDelivery/MessageReceiptsTracker.ts +++ b/src/messageDelivery/MessageReceiptsTracker.ts @@ -5,9 +5,9 @@ import { WithSubscriptions } from '../utils/WithSubscriptions'; type UserId = string; type MessageId = string; -export type MsgRef = { timestampMs: number; msgId: MessageId }; +export type MsgRef = { timestamp: number; msgId: MessageId }; export type OwnMessageReceiptsTrackerMessageLocator = ( - timestampMs: number, + timestamp: number, ) => MsgRef | null; export type UserProgress = { user: UserResponse; @@ -24,31 +24,28 @@ export type ReadStoreReconcileMeta = { removedUserIds?: string[]; }; type ReadStoreUserState = { - last_read?: Date | string; + last_read?: number; unread_messages?: number; user?: UserResponse; first_unread_message_id?: string; last_read_message_id?: string; - last_delivered_at?: Date | string; + last_delivered_at?: number; last_delivered_message_id?: string; }; // ---------- ordering utilities ---------- -const MIN_REF: MsgRef = { timestampMs: Number.NEGATIVE_INFINITY, msgId: '' } as const; - -const toTimestampMs = (value: Date | string) => - value instanceof Date ? value.getTime() : new Date(value).getTime(); +const MIN_REF: MsgRef = { timestamp: Number.NEGATIVE_INFINITY, msgId: '' } as const; const isValidReadState = ( readState: ReadStoreUserState | undefined, ): readState is ReadStoreUserState & { - last_read: Date | string; + last_read: number; user: UserResponse; -} => !!readState?.user && !!readState.last_read; +} => !!readState?.user && Number.isFinite(readState.last_read); const compareRefsAsc = (a: MsgRef, b: MsgRef) => - a.timestampMs !== b.timestampMs ? a.timestampMs - b.timestampMs : 0; + a.timestamp !== b.timestamp ? a.timestamp - b.timestamp : 0; const findIndex = (arr: T[], target: MsgRef, keyOf: (x: T) => MsgRef): number => { let lo = 0, @@ -137,9 +134,9 @@ export type OwnMessageReceiptsTrackerOptions = { * - `ingestInitial(rows: ReadStateResponse[])`: Builds initial state from server snapshot. * If a user’s `last_read` is ahead of `last_delivered_at`, the tracker enforces * the invariant `lastDeliveredRef >= lastReadRef`. - * - `onMessageRead(user, readAtISO)`: + * - `onMessageRead(user, readAt)`: * Advances the user’s read; also bumps delivered to match if needed. - * - `onMessageDelivered(user, deliveredAtISO)`: + * - `onMessageDelivered(user, deliveredAt)`: * Advances the user’s delivered to `max(currentRead, deliveredAt)`. * * Queries @@ -185,9 +182,9 @@ export class MessageReceiptsTracker extends WithSubscriptions { this.channel = channel; this.locateMessage = locateMessage ?? - ((timestampMs: number) => { - const message = this.channel.messagePaginator.findItemByTimestamp(timestampMs); - return message ? { timestampMs, msgId: message.id } : null; + ((timestamp: number) => { + const message = this.channel.messagePaginator.findItemByTimestamp(timestamp); + return message ? { timestamp, msgId: message.id } : null; }); } @@ -274,16 +271,16 @@ export class MessageReceiptsTracker extends WithSubscriptions { this.readSorted = []; this.deliveredSorted = []; for (const r of responses) { - const lastReadTimestamp = r.last_read ? new Date(r.last_read).getTime() : null; - const lastDeliveredTimestamp = r.last_delivered_at - ? new Date(r.last_delivered_at).getTime() - : null; - const lastReadRef = lastReadTimestamp - ? (this.locateMessage(lastReadTimestamp) ?? MIN_REF) - : MIN_REF; - let lastDeliveredRef = lastDeliveredTimestamp - ? (this.locateMessage(lastDeliveredTimestamp) ?? MIN_REF) - : MIN_REF; + const lastReadTimestamp = r.last_read ?? null; + const lastDeliveredTimestamp = r.last_delivered_at ?? null; + const lastReadRef = + lastReadTimestamp != null + ? (this.locateMessage(lastReadTimestamp) ?? MIN_REF) + : MIN_REF; + let lastDeliveredRef = + lastDeliveredTimestamp != null + ? (this.locateMessage(lastDeliveredTimestamp) ?? MIN_REF) + : MIN_REF; const isReadAfterDelivered = compareRefsAsc(lastDeliveredRef, lastReadRef) < 0; if (isReadAfterDelivered) lastDeliveredRef = lastReadRef; @@ -311,13 +308,14 @@ export class MessageReceiptsTracker extends WithSubscriptions { lastDeliveredMessageId, }: { user: UserResponse; - deliveredAt: Date; + /** Unix nanoseconds, as the API sends it. */ + deliveredAt: number; lastDeliveredMessageId?: string; }) { - const timestampMs = deliveredAt.getTime(); + const timestamp = deliveredAt; const msgRef = lastDeliveredMessageId - ? { timestampMs, msgId: lastDeliveredMessageId } - : this.locateMessage(deliveredAt.getTime()); + ? { timestamp, msgId: lastDeliveredMessageId } + : this.locateMessage(deliveredAt); if (!msgRef) return; const userProgress = this.ensureUser(user); @@ -346,13 +344,14 @@ export class MessageReceiptsTracker extends WithSubscriptions { lastReadMessageId, }: { user: UserResponse; - readAt: Date; + /** Unix nanoseconds, as the API sends it. */ + readAt: number; lastReadMessageId?: string; }) { - const timestampMs = readAt.getTime(); + const timestamp = readAt; const msgRef = lastReadMessageId - ? { timestampMs, msgId: lastReadMessageId } - : this.locateMessage(timestampMs); + ? { timestamp, msgId: lastReadMessageId } + : this.locateMessage(timestamp); if (!msgRef) return; const userProgress = this.ensureUser(user); // newly announced read message is older than or equal the already recorded last read message @@ -395,14 +394,17 @@ export class MessageReceiptsTracker extends WithSubscriptions { lastReadMessageId, }: { user: UserResponse; - lastReadAt?: Date; + /** Unix nanoseconds, as the API sends it. */ + lastReadAt?: number; lastReadMessageId?: string; }) { const userProgress = this.ensureUser(user); - const newReadRef: MsgRef = lastReadAt - ? { timestampMs: lastReadAt.getTime(), msgId: lastReadMessageId ?? '' } - : { ...MIN_REF }; + // `0` is the "never read" sentinel + const newReadRef: MsgRef = + lastReadAt != null && Number.isFinite(lastReadAt) + ? { timestamp: lastReadAt, msgId: lastReadMessageId ?? '' } + : { ...MIN_REF }; // If no change, exit early. if ( @@ -620,27 +622,25 @@ export class MessageReceiptsTracker extends WithSubscriptions { } private readStateToUserProgress(readState: { - last_read: Date | string; + last_read: number; unread_messages?: number; user: UserResponse; first_unread_message_id?: string; last_read_message_id?: string; - last_delivered_at?: Date | string; + last_delivered_at?: number; last_delivered_message_id?: string; }): UserProgress { - const lastReadTimestamp = toTimestampMs(readState.last_read); - const lastDeliveredTimestamp = readState.last_delivered_at - ? toTimestampMs(readState.last_delivered_at) - : null; + const lastReadTimestamp = readState.last_read; + const lastDeliveredTimestamp = readState.last_delivered_at ?? null; const lastReadRef = readState.last_read_message_id - ? { timestampMs: lastReadTimestamp, msgId: readState.last_read_message_id } + ? { timestamp: lastReadTimestamp, msgId: readState.last_read_message_id } : (this.locateMessage(lastReadTimestamp) ?? MIN_REF); let lastDeliveredRef = readState.last_delivered_message_id ? { - timestampMs: lastDeliveredTimestamp ?? lastReadTimestamp, + timestamp: lastDeliveredTimestamp ?? lastReadTimestamp, msgId: readState.last_delivered_message_id, } - : lastDeliveredTimestamp + : lastDeliveredTimestamp != null && Number.isFinite(lastDeliveredTimestamp) ? (this.locateMessage(lastDeliveredTimestamp) ?? MIN_REF) : MIN_REF; @@ -661,17 +661,13 @@ export class MessageReceiptsTracker extends WithSubscriptions { return Object.values(readState).reduce( (responses, userReadState) => { if (!isValidReadState(userReadState)) return responses; - const lastReadDate = new Date(userReadState.last_read); - if (Number.isNaN(lastReadDate.getTime())) return responses; responses.push({ - last_read: lastReadDate, + last_read: userReadState.last_read, user: userReadState.user, last_read_message_id: userReadState.last_read_message_id, unread_messages: userReadState.unread_messages ?? 0, - last_delivered_at: userReadState.last_delivered_at - ? new Date(userReadState.last_delivered_at) - : undefined, + last_delivered_at: userReadState.last_delivered_at, last_delivered_message_id: userReadState.last_delivered_message_id, }); diff --git a/src/messageOperations/MessageOperationStatePolicy.ts b/src/messageOperations/MessageOperationStatePolicy.ts index 2b8f9fa168..f6326ad14c 100644 --- a/src/messageOperations/MessageOperationStatePolicy.ts +++ b/src/messageOperations/MessageOperationStatePolicy.ts @@ -5,6 +5,7 @@ import type { StreamAPIError, } from '../types'; import { formatMessage } from '../utils'; +import { nowNs } from '../utils/time'; import type { QueueableType } from '../offline-support'; import type { MessageOperationSpec, OperationKind, OperationParams } from './types'; @@ -94,7 +95,7 @@ export class MessageOperationStatePolicy { const applied: LocalMessage = { ...localMessage, - deleted_at: new Date(), + deleted_at: nowNs(), type: 'deleted', ...(deleteForMe ? { deleted_for_me: true } : {}), }; @@ -107,7 +108,7 @@ export class MessageOperationStatePolicy { // Preserve the status: an edit must not turn a received message into `sending`, and an edit of a // message that never left the device has to stay `failed`. const isFailed = localMessage.status === 'failed'; - const editedAt = new Date(); + const editedAt = nowNs(); const applied: LocalMessage = { ...localMessage, error: isFailed ? localMessage.error : undefined, @@ -183,10 +184,8 @@ export class MessageOperationStatePolicy { // Reached only when something else did write since the optimistic step. For an edit both copies // are then server derived, so comparing their timestamps compares one clock against itself. For a // send the copy that landed is a `message.new` WS event, which is server derived too. - const serverNewer = - !existing || formatted.updated_at.getTime() > existing.updated_at.getTime(); - const serverSameOrNewer = - !existing || formatted.updated_at.getTime() >= existing.updated_at.getTime(); + const serverNewer = !existing || formatted.updated_at > existing.updated_at; + const serverSameOrNewer = !existing || formatted.updated_at >= existing.updated_at; const existingIsOurOptimisticSend = existing?.status === 'sending'; const applyServerCopy = diff --git a/src/messageOperations/optimistic.ts b/src/messageOperations/optimistic.ts index 8f91869abd..1a9ff852ef 100644 --- a/src/messageOperations/optimistic.ts +++ b/src/messageOperations/optimistic.ts @@ -1,6 +1,7 @@ import { applyReactionLocally } from '../entityStore'; import { isEphemeral } from '../errors'; import { formatMessage } from '../utils'; +import { dateToNs } from '../utils/time'; import type { Channel } from '../channel'; import type { StreamChat } from '../client'; import type { QueueableType } from '../offline-support'; @@ -24,7 +25,7 @@ import type { * ```ts * const undo = applyMessageChangeLocally(accessor, { * messageId, - * produce: (m) => m && { ...m, pinned: true, pinned_at: new Date() }, + * produce: (m) => m && { ...m, pinned: true, pinned_at: nowNs() }, * }); * * try { @@ -203,10 +204,17 @@ export const addReactionOptimistically = async ({ options?: Pick; }) => { const client = channel.getClient(); + // `reaction` is a REQUEST, so any timestamps on it are `Date`s. The local store speaks the wire + // unit, so bring them across rather than handing a `Date` to a numeric field. + const { created_at, updated_at, ...restOfReaction } = reaction; const undo = applyReactionLocally(client, { enforceUnique: options?.enforce_unique ?? false, messageId, - reaction, + reaction: { + ...restOfReaction, + ...(created_at ? { created_at: dateToNs(created_at) } : {}), + ...(updated_at ? { updated_at: dateToNs(updated_at) } : {}), + }, }); try { diff --git a/src/offline-support/offline_support_api.ts b/src/offline-support/offline_support_api.ts index 1cd3dfe04f..c4031928bd 100644 --- a/src/offline-support/offline_support_api.ts +++ b/src/offline-support/offline_support_api.ts @@ -33,6 +33,7 @@ import { localMessageToNewMessagePayload, runDetached, } from '../utils'; +import { nowNs } from '../utils/time'; import { isMessageUpdateReplayable } from './util'; import { QUEUEABLE_OPERATIONS, runQueueableOperation } from './queueableOperations'; import type { QueueableOperation } from './queueableOperations'; @@ -725,7 +726,7 @@ export abstract class AbstractOfflineDB implements OfflineDBApi { execute: false, reads: [ { - last_read: ownReads?.last_read ?? new Date(0), + last_read: ownReads?.last_read ?? 0, last_read_message_id: ownReads?.last_read_message_id, unread_messages: unreadCount, // `client.user` is `ClientUser` (everything optional but `id`), so a @@ -986,7 +987,7 @@ export abstract class AbstractOfflineDB implements OfflineDBApi { execute?: boolean; }) => { const { - received_at: last_read = new Date(), + received_at: last_read = nowNs(), last_read_message_id, // @ts-expect-error property missing unread_messages = 0, @@ -1151,8 +1152,8 @@ export abstract class AbstractOfflineDB implements OfflineDBApi { const ownReads = activeChannel.state.read[userId]; let unreadCount = 0; - if (truncated_at) { - unreadCount = activeChannel.countUnread(new Date(truncated_at)); + if (truncated_at != null) { + unreadCount = activeChannel.countUnread(truncated_at); } const upsertReadQueries = await this.upsertReads({ @@ -1160,7 +1161,7 @@ export abstract class AbstractOfflineDB implements OfflineDBApi { execute: false, reads: [ { - last_read: ownReads?.last_read ?? new Date(0), + last_read: ownReads?.last_read ?? 0, last_read_message_id: ownReads?.last_read_message_id, unread_messages: unreadCount, // See the note above: supply `blocked_user_ids`, do not assert it. @@ -1442,16 +1443,8 @@ export abstract class AbstractOfflineDB implements OfflineDBApi { editedMessage: LocalMessage | Partial; pendingMessage: MessageRequest; }) => { - const normalizedEditedMessageSource = { - ...editedMessage, - } as LocalMessage & { message_text_updated_at?: string }; - - if ((editedMessage as LocalMessage).status === 'failed') { - delete normalizedEditedMessageSource.message_text_updated_at; - } - const normalizedEditedMessage = localMessageToNewMessagePayload( - normalizedEditedMessageSource, + editedMessage as LocalMessage, ); const pendingMessageStatus = (pendingMessage as { status?: string }).status; diff --git a/src/offline-support/offline_sync_manager.ts b/src/offline-support/offline_sync_manager.ts index 644429ba5f..a87793d53d 100644 --- a/src/offline-support/offline_sync_manager.ts +++ b/src/offline-support/offline_sync_manager.ts @@ -4,8 +4,6 @@ import type { AbstractOfflineDB } from './offline_support_api'; import type { AxiosError } from 'axios'; import { isAxiosError } from 'axios'; import { chatLoggerSystem } from '../logger'; -import { decodeWSEvent } from '../gen/model-decoders/event-decoder-mapping'; -import type { WSEvent } from '../gen/models'; import type { APIError } from '../types'; const logger = chatLoggerSystem.getLogger('offline-db'); @@ -197,14 +195,6 @@ export class OfflineDBSyncManager { last_sync_at: lastSyncedAtDate, }); - // Left out decoding is needed here because the timestamps arrive as nanosecond integers, - // `new Date(1786219962651957000)` overflows the Date range and persisting one threw - // `RangeError: Date value out of bounds`, which the catch below reads as the "too many - // events" API error and answers by resetting the whole database. This was introduced with - // the OpenAPI refactor and should be addressed there. - // TODO: Remove this when the upstream decoders properly handle the sync API. - result.events = result.events?.map((event) => decodeWSEvent(event) as WSEvent); - // Opt-in positive cap owned by this manager; undefined/non-positive = no limit. const { syncMaxEventCount } = this; const exceedsLimit = diff --git a/src/offline-support/types.ts b/src/offline-support/types.ts index b160d4d56f..b40ab5601d 100644 --- a/src/offline-support/types.ts +++ b/src/offline-support/types.ts @@ -301,7 +301,8 @@ export type DBDeleteMessagesForChannelType = { /** Channel ID. */ cid: string; /** Timestamp before which messages are deleted. */ - truncated_at?: Date; + /** Unix nanoseconds, as the API sends it. */ + truncated_at?: number; /** Whether to immediately execute the operation. */ execute?: boolean; }; diff --git a/src/pagination/cursorDerivation/createdAtAroundPaginationFlags.ts b/src/pagination/cursorDerivation/createdAtAroundPaginationFlags.ts index f581e807f2..26991a8881 100644 --- a/src/pagination/cursorDerivation/createdAtAroundPaginationFlags.ts +++ b/src/pagination/cursorDerivation/createdAtAroundPaginationFlags.ts @@ -1,9 +1,10 @@ import { binarySearch } from '../sortCompiler'; +import { dateToNs } from '../../utils/time'; import type { BasePaginator, CursorDeriveContext, PaginationFlags } from '../paginators'; import { ComparisonResult } from '../types.normalization'; export const deriveCreatedAtAroundPaginationFlags = < - T extends { id: string; created_at: Date }, + T extends { id: string; created_at: number }, Q extends { created_at_around?: Date | string }, P extends BasePaginator, >({ @@ -17,15 +18,17 @@ export const deriveCreatedAtAroundPaginationFlags = < }: CursorDeriveContext & { paginator: P }): PaginationFlags => { let flags: PaginationFlags = { hasMoreHead, hasMoreTail }; if (!queryShape?.created_at_around) return flags; - const createdAtAroundDate = new Date(queryShape.created_at_around); + // `created_at_around` is a REQUEST field, so it is still a `Date` (or an ISO string). Items carry + // the wire unit, so bring the bound into that unit before comparing the two. + const createdAtAround = dateToNs(new Date(queryShape.created_at_around)); const [firstPageItem, lastPageItem] = [page[0], page.slice(-1)[0]]; // expect ASC order (from oldest to newest) const isAboveHeadBound = - paginator.sortComparator({ created_at: createdAtAroundDate } as T, lastPageItem) === + paginator.sortComparator({ created_at: createdAtAround } as T, lastPageItem) === ComparisonResult.A_PRECEDES_B; const isBelowTailBound = - paginator.sortComparator(firstPageItem, { created_at: createdAtAroundDate } as T) === + paginator.sortComparator(firstPageItem, { created_at: createdAtAround } as T) === ComparisonResult.A_PRECEDES_B; const requestedPageSizeNotMet = @@ -55,11 +58,11 @@ export const deriveCreatedAtAroundPaginationFlags = < const midPointByCount = Math.floor(page.length / 2); const { insertionIndex } = binarySearch({ - needle: { created_at: createdAtAroundDate } as T, + needle: { created_at: createdAtAround } as T, length: page.length, getItemAt: (index) => page[index], - compare: (a, b) => a.created_at?.getTime() - b.created_at.getTime(), - itemIdentityEquals: (a, b) => a.created_at?.getTime() === b.created_at?.getTime(), + compare: (a, b) => a.created_at - b.created_at, + itemIdentityEquals: (a, b) => a.created_at === b.created_at, plateauScan: false, }); diff --git a/src/pagination/cursorDerivation/linearPaginationFlags.ts b/src/pagination/cursorDerivation/linearPaginationFlags.ts index 1a38ed66b0..09302e56d8 100644 --- a/src/pagination/cursorDerivation/linearPaginationFlags.ts +++ b/src/pagination/cursorDerivation/linearPaginationFlags.ts @@ -34,7 +34,7 @@ const HEADWARD_QUERY_PROPERTIES: Array = [ 'id_gte', ]; export const deriveLinearPaginationFlags = < - T extends { id: string; created_at: Date }, + T extends { id: string; created_at: number }, Q extends LinearPaginationQueryShape, >({ direction, diff --git a/src/pagination/paginators/ChannelPaginator.ts b/src/pagination/paginators/ChannelPaginator.ts index 61291d9c29..7083111ba0 100644 --- a/src/pagination/paginators/ChannelPaginator.ts +++ b/src/pagination/paginators/ChannelPaginator.ts @@ -110,7 +110,7 @@ const hasPaginationQueryShapeChanged: PaginationQueryShapeChangeIdentifier< const archivedFilterResolver: FieldToDataResolver = { matchesField: (field) => field === 'archived', - resolve: (channel) => !!channel.state.membership.archived_at, + resolve: (channel) => channel.state.membership.archived_at != null, }; const appBannedFilterResolver: FieldToDataResolver = { @@ -150,11 +150,11 @@ const hiddenFilterResolver: FieldToDataResolver = { const lastUpdatedFilterResolver: FieldToDataResolver = { matchesField: (field) => field === 'last_updated', resolve: (channel) => { - // combination of last_message_at and updated_at - const lastMessageAt = channel.messagePaginator.lastMessageAt?.getTime() ?? null; - const updatedAt = channel.data?.updated_at - ? new Date(channel.data?.updated_at).getTime() - : undefined; + // combination of last_message_at and updated_at — both already wire timestamps, so they are + // directly comparable. Deriving one of them through `new Date(...).getTime()` would put the two + // in different units and make `updated_at` win every comparison. + const lastMessageAt = channel.messagePaginator.lastMessageAt ?? null; + const updatedAt = channel.data?.updated_at; return lastMessageAt !== null && updatedAt !== undefined ? Math.max(lastMessageAt, updatedAt) : (lastMessageAt ?? updatedAt); @@ -189,7 +189,7 @@ const memberUserNameFilterResolver: FieldToDataResolver = { const pinnedFilterResolver: FieldToDataResolver = { matchesField: (field) => field === 'pinned', - resolve: (channel) => !!channel.state.membership.pinned_at, + resolve: (channel) => channel.state.membership.pinned_at != null, }; const mutedFilterResolver: FieldToDataResolver = { diff --git a/src/pagination/paginators/MessageIntervalPaginator.ts b/src/pagination/paginators/MessageIntervalPaginator.ts index ee622b030c..1601a03b49 100644 --- a/src/pagination/paginators/MessageIntervalPaginator.ts +++ b/src/pagination/paginators/MessageIntervalPaginator.ts @@ -116,10 +116,18 @@ const dataFieldFilterResolver: FieldToDataResolver = { resolve: (message, path) => resolveDotPathValue(message, path), }; -export const getMessageCreatedAtTimestamp = (message: LocalMessage): number | null => { - if (!(message.created_at instanceof Date)) return null; - const timestamp = message.created_at.getTime(); - return Number.isFinite(timestamp) ? timestamp : null; +/** + * A message's `created_at` as the comparable wire timestamp (unix nanoseconds), or `null` when it + * is absent or not a finite number. + * + * Accepts `null`/`undefined` so callers holding an optional message (e.g. + * `MessagePaginator.lastMessage`) do not each repeat the guard. + */ +export const getMessageCreatedAtTimestamp = ( + message: LocalMessage | null | undefined, +): number | null => { + const timestamp = message?.created_at; + return typeof timestamp === 'number' && Number.isFinite(timestamp) ? timestamp : null; }; export type MessagePaginatorOptions = { @@ -1086,7 +1094,7 @@ export class MessageIntervalPaginator extends BasePaginator< lastReadAt, messages, }: { - lastReadAt: Date; + lastReadAt: number; messages: LocalMessage[]; }): { firstUnreadMessageId: string | null; lastReadMessageId: string | null } => { // Messages are expected in chronological order. We find: @@ -1095,7 +1103,7 @@ export class MessageIntervalPaginator extends BasePaginator< // // If the page starts after lastReadAt, the entire page is unread and the first message is // used as unread anchor (legacy "whole channel is unread" behavior for this queried window). - const lastReadTimestamp = lastReadAt.getTime(); + const lastReadTimestamp = lastReadAt; if (!Number.isFinite(lastReadTimestamp) || !messages.length) { return { firstUnreadMessageId: null, lastReadMessageId: null }; } @@ -1224,13 +1232,13 @@ export class MessageIntervalPaginator extends BasePaginator< * `isTail`/`hasMoreTail` are set; intervals entirely newer keep their flags (unloaded older * messages may still sit between them and the cutoff). The active window is re-emitted once. */ - truncate = ({ truncatedAt }: { truncatedAt: Date }) => { - const cutoff = truncatedAt.getTime(); - if (Number.isNaN(cutoff)) return; + truncate = ({ truncatedAt }: { truncatedAt: number }) => { + const cutoff = truncatedAt; + if (!Number.isFinite(cutoff)) return; const isOld = (item: LocalMessage | undefined) => { - const time = item?.created_at ? new Date(item.created_at).getTime() : undefined; - return typeof time === 'number' && time < cutoff; + const time = getMessageCreatedAtTimestamp(item); + return time !== null && time < cutoff; }; const removedIds: string[] = []; @@ -1301,7 +1309,7 @@ export class MessageIntervalPaginator extends BasePaginator< }: { userId: string; hardDelete?: boolean; - deletedAt: Date; + deletedAt: number; }) => { const loadedMessages = this.items ?? []; @@ -1453,18 +1461,18 @@ export class MessageIntervalPaginator extends BasePaginator< /** * Map a timestamp to a loaded message — the first message in the latest (head) window whose - * `created_at` is >= `timestampMs` (mirrors the legacy `ChannelState.findMessageByTimestamp` + * `created_at` is >= `timestamp` (mirrors the legacy `ChannelState.findMessageByTimestamp` * lower-bound search), or the newest loaded message when the timestamp is beyond it. Used by the * receipts tracker to resolve read/delivered cursors. Searches the newest loaded window — where * read cursors live — which is already sorted, so this is O(log n) with no re-sort. */ findItemByTimestamp = ( - timestampMs: number, + timestamp: number, exactTsMatch = false, ): LocalMessage | null => { const items = this.headItems; // ascending by created_at if (!items.length) return null; - // Resolve the last message created AT OR BEFORE `timestampMs` (floor). The sole caller is + // Resolve the last message created AT OR BEFORE `timestamp` (floor). The sole caller is // read/delivered cursor resolution (MessageReceiptsTracker): the cursor carries the timestamp of // the last message a participant reached, so a message created strictly after the cursor has NOT // been reached. A ceil match (first message >= target) would over-count it — e.g. a participant @@ -1473,7 +1481,7 @@ export class MessageIntervalPaginator extends BasePaginator< // the floor is the item immediately before it. const firstAfter = lowerBound(items.length, (i) => { const t = getMessageCreatedAtTimestamp(items[i]); - return t === null || t > timestampMs; + return t === null || t > timestamp; }); if (firstAfter === 0) return null; // target precedes every loaded message const found = items[firstAfter - 1]; @@ -1482,7 +1490,7 @@ export class MessageIntervalPaginator extends BasePaginator< // server timestamp) cannot be located by timestamp. if (foundTimestamp === null) return null; if (!exactTsMatch) return found; - return foundTimestamp === timestampMs ? found : null; + return foundTimestamp === timestamp ? found : null; }; filterQueryResults = (items: LocalMessage[]) => diff --git a/src/pagination/paginators/MessagePaginator.ts b/src/pagination/paginators/MessagePaginator.ts index fe044f7b71..ddf268239a 100644 --- a/src/pagination/paginators/MessagePaginator.ts +++ b/src/pagination/paginators/MessagePaginator.ts @@ -12,6 +12,7 @@ import { type MessageQueryShape, } from './MessageIntervalPaginator'; import type { LocalMessage } from '../../types'; +import { nsToDate } from '../../utils/time'; import { StateStore } from '../../store'; export type { @@ -57,7 +58,7 @@ export type MessagePaginatorAggregateState = { * sort key {@link MessagePaginator.lastMessageAt} is derived as the max of the two, so the two can * never drift out of sync. */ - seededLastMessageAt: Date | null; + seededLastMessageAt: number | null; }; export type MessagePaginatorOptions = BaseMessagePaginatorOptions & { @@ -72,7 +73,7 @@ export type MessagePaginatorOptions = BaseMessagePaginatorOptions & { }; export type UnreadSnapshotState = { - lastReadAt: Date | null; + lastReadAt: number | null; unreadCount: number; /** * Snapshot of the first unread message id for the user. @@ -166,12 +167,11 @@ export class MessagePaginator extends MessageIntervalPaginator { * **Derived** (never stored) so it cannot drift from {@link lastMessage}. `null` until seeded or a * message is ingested. */ - get lastMessageAt(): Date | null { + get lastMessageAt(): number | null { const { lastMessage, seededLastMessageAt } = this.aggregateState.getLatestValue(); - const fromMessage = - lastMessage?.created_at instanceof Date ? lastMessage.created_at : null; - if (fromMessage && seededLastMessageAt) { - return fromMessage >= seededLastMessageAt ? fromMessage : seededLastMessageAt; + const fromMessage = getMessageCreatedAtTimestamp(lastMessage); + if (fromMessage !== null && seededLastMessageAt !== null) { + return Math.max(fromMessage, seededLastMessageAt); } return fromMessage ?? seededLastMessageAt; } @@ -244,14 +244,11 @@ export class MessagePaginator extends MessageIntervalPaginator { * authoritative whole-channel aggregate. Monotonic: a no-op when the paginator already advanced * past it (e.g. from ingested messages), so seed order does not matter. */ - seedLastMessageAt(value: string | Date | null | undefined) { - if (!value) return; - const date = value instanceof Date ? value : new Date(value); - const timestamp = date.getTime(); - if (!Number.isFinite(timestamp)) return; + seedLastMessageAt(value: number | null | undefined) { + if (value == null || !Number.isFinite(value)) return; const current = this.aggregateState.getLatestValue().seededLastMessageAt; - if (current && timestamp <= current.getTime()) return; - this.aggregateState.partialNext({ seededLastMessageAt: date }); + if (current !== null && value <= current) return; + this.aggregateState.partialNext({ seededLastMessageAt: value }); } ingestItem(item: LocalMessage): boolean { @@ -425,19 +422,23 @@ export class MessagePaginator extends MessageIntervalPaginator { // We deliberately do NOT persist the inferred boundary back into the snapshot: writing // `firstUnreadMessageId` would make the channel look explicitly marked-unread and suppress // auto-mark-read at the bottom. The separator reads the (re-seeded) snapshot directly. - if (lastReadAt) { + const lastReadBoundary = + lastReadAt != null && Number.isFinite(lastReadAt) ? lastReadAt : null; + + if (lastReadBoundary !== null) { let { firstUnreadMessageId: inferredFirstUnreadMessageId, lastReadMessageId: inferredLastReadMessageId, } = this.resolveUnreadBoundaryIdsByTimestamp({ - lastReadAt, + lastReadAt: lastReadBoundary, messages: this.state.getLatestValue().items ?? [], }); if (!inferredLastReadMessageId) { const result = await this.executeQuery({ queryShape: { - created_at_around: lastReadAt, + // `created_at_around` is a request field and still takes a `Date`. + created_at_around: nsToDate(lastReadBoundary), limit: options?.pageSize, }, updateState: false, @@ -447,7 +448,7 @@ export class MessagePaginator extends MessageIntervalPaginator { firstUnreadMessageId: inferredFirstUnreadMessageId, lastReadMessageId: inferredLastReadMessageId, } = this.resolveUnreadBoundaryIdsByTimestamp({ - lastReadAt, + lastReadAt: lastReadBoundary, messages: result.stateCandidate.items ?? [], })); } diff --git a/src/pagination/paginators/UserGroupPaginator.ts b/src/pagination/paginators/UserGroupPaginator.ts index 502f4fe5f6..43c81d642f 100644 --- a/src/pagination/paginators/UserGroupPaginator.ts +++ b/src/pagination/paginators/UserGroupPaginator.ts @@ -7,6 +7,7 @@ import type { } from './BasePaginator'; import type { ListUserGroupsOptions, UserGroupResponse } from '../../types'; import type { StreamChat } from '../../client'; +import { nsToRfc3339 } from '../../utils/time'; import { StoreBackedItemIndex } from '../../entityStore/StoreBackedItemIndex'; type UserGroupListCursor = { @@ -87,7 +88,12 @@ export class UserGroupPaginator extends BasePaginator< if (!lastItem) return undefined; return JSON.stringify({ - created_at_gt: lastItem.created_at.toISOString(), + // The cursor is a request value, so it has to go back out as RFC3339 rather than as the + // wire number the item carries. `nsToRfc3339` and not `nsToDate(...).toISOString()`: + // `Date` holds only milliseconds, so flooring the boundary item's timestamp would put the + // cursor below it and a strict `created_at_gt` could hand that same item back on the next + // page. + created_at_gt: nsToRfc3339(lastItem.created_at), id_gt: lastItem.id, } satisfies UserGroupListCursor); }; diff --git a/src/pagination/utility.normalization.ts b/src/pagination/utility.normalization.ts index 85bf290618..a3f4f1f87b 100644 --- a/src/pagination/utility.normalization.ts +++ b/src/pagination/utility.normalization.ts @@ -1,3 +1,5 @@ +import { dateToNs, msToNs } from '../utils/time'; + export function asArray(v: any): any[] { return Array.isArray(v) ? v : [v]; } @@ -6,10 +8,23 @@ export function isISODateString(x: any): x is string { return typeof x === 'string' && x.includes('T') && !Number.isNaN(Date.parse(x)); } -export function toEpochMillis(x: any): number | null { - if (x instanceof Date) return x.getTime(); - if (typeof x === 'number' && Number.isFinite(x)) return x; // treat as epoch ms - if (isISODateString(x)) return Date.parse(x); +/** + * Brings anything date-shaped into unix **nanoseconds**, the unit item fields carry, so a filter or + * sort can compare an operand against a value. + * + * Nanoseconds rather than milliseconds because the two sides of a comparison come from different + * places: an item's `created_at` is the wire number, while a filter operand is typed `Date | string` + * in the generated filter types. Normalizing to ms would have meant halving the pair — a `Date` + * became ms while a wire number was passed through untouched and *called* ms, so every mixed + * comparison silently ordered wrong and `normKey` bucketed the two apart. + * + * A bare `number` is therefore read as nanoseconds. That is the SDK's unit for a timestamp + * everywhere else, and it is what a value read off an item will be. + */ +export function toEpochNanos(x: any): number | null { + if (x instanceof Date) return dateToNs(x); + if (typeof x === 'number' && Number.isFinite(x)) return x; // already the wire unit + if (isISODateString(x)) return msToNs(Date.parse(x)); return null; } @@ -23,8 +38,8 @@ export function toNumberLike(x: any): number | null { } export function normalizeComparedValues(a: any, b: any) { - const Ad = toEpochMillis(a), - Bd = toEpochMillis(b); + const Ad = toEpochNanos(a), + Bd = toEpochNanos(b); if (Ad !== null && Bd !== null) return { kind: 'date', a: Ad, b: Bd }; const An = toNumberLike(a), diff --git a/src/poll.ts b/src/poll.ts index f1132d2927..c87f3de065 100644 --- a/src/poll.ts +++ b/src/poll.ts @@ -1,4 +1,5 @@ import { StateStore } from './store'; +import { nowNs } from './utils/time'; import { CORE_NOTIFICATION_TYPE } from './notifications'; import type { StreamChat } from './client'; import type { @@ -48,7 +49,8 @@ export type PollOptionVotesQueryParams = { type OptionId = string; export type PollState = Omit & { - lastActivityAt: Date; // todo: would be ideal to get this from the BE + /** Unix nanoseconds, matching every API timestamp. */ + lastActivityAt: number; // todo: would be ideal to get this from the BE maxVotedOptionIds: OptionId[]; ownVotesByOptionId: Record; ownAnswer?: PollVoteResponseData; // each user can have only one answer @@ -90,7 +92,7 @@ export class Poll { return { ...pollResponseForState, - lastActivityAt: new Date(), + lastActivityAt: nowNs(), maxVotedOptionIds: getMaxVotedOptionIds(pollResponseForState.vote_counts_by_option), ownAnswer, ownVotesByOptionId: getOwnVotesByOptionId(ownVotes), @@ -118,7 +120,7 @@ export class Poll { const { id: _id, ...pollData } = extractPollData(event.poll); // @ts-expect-error type mismatch - this.state.partialNext({ ...pollData, lastActivityAt: new Date(event.created_at) }); + this.state.partialNext({ ...pollData, lastActivityAt: event.created_at }); this.upsertOfflineDb(); }; @@ -127,7 +129,7 @@ export class Poll { if (!isPollClosedEventEvent(event)) return; this.state.partialNext({ is_closed: true, - lastActivityAt: new Date(event.created_at), + lastActivityAt: event.created_at, }); this.upsertOfflineDb(); }; @@ -160,7 +162,7 @@ export class Poll { this.state.partialNext({ ...pollEnrichData, latest_answers: latestAnswers, - lastActivityAt: new Date(event.created_at), + lastActivityAt: event.created_at, ownAnswer, ownVotesByOptionId, maxVotedOptionIds, @@ -220,7 +222,7 @@ export class Poll { this.state.partialNext({ ...pollEnrichData, latest_answers: latestAnswers, - lastActivityAt: new Date(event.created_at), + lastActivityAt: event.created_at, ownAnswer, ownVotesByOptionId, maxVotedOptionIds, @@ -254,7 +256,7 @@ export class Poll { this.state.partialNext({ ...pollEnrichData, latest_answers: latestAnswers, - lastActivityAt: new Date(event.created_at), + lastActivityAt: event.created_at, ownAnswer, ownVotesByOptionId, maxVotedOptionIds, @@ -264,7 +266,7 @@ export class Poll { query = async (id: string) => { const { poll } = await this.client.getPoll({ poll_id: id }); - this.state.partialNext({ ...poll, lastActivityAt: new Date() }); + this.state.partialNext({ ...poll, lastActivityAt: nowNs() }); return poll; }; @@ -411,7 +413,7 @@ export function mapPollStateToResponse(poll: Poll): PollResponseData { const ownVotes = [ ...Object.values(ownVotesByOptionId), ...(ownAnswer ? [ownAnswer] : []), - ].sort((a, b) => a.created_at.getTime() - b.created_at.getTime()); + ].sort((a, b) => a.created_at - b.created_at); return { ...restState, diff --git a/src/reminders/Reminder.ts b/src/reminders/Reminder.ts index 669e02bb3c..ce3b42c3ac 100644 --- a/src/reminders/Reminder.ts +++ b/src/reminders/Reminder.ts @@ -1,20 +1,31 @@ import { ReminderTimer } from './ReminderTimer'; import { StateStore } from '../store'; +import { nowNs, nsToMs } from '../utils/time'; import type { ReminderTimerConfig } from './ReminderTimer'; import type { MessageResponse, ReminderResponseData, UserResponse } from '../types'; -export const timeLeftMs = (remindAt: number) => remindAt - new Date().getTime(); +/** + * Milliseconds until `remindAt`, negative once it has passed. + * + * @param remindAt - Unix nanoseconds, as the API sends it. The subtraction happens in the wire unit + * and is converted once, so the returned duration stays in the milliseconds `setTimeout` speaks. + */ +export const timeLeftMs = (remindAt: number) => nsToMs(remindAt - nowNs()); export type ReminderResponseBaseOrResponse = ReminderResponseData; export type ReminderState = { channel_cid: string; - created_at: Date; + /** Unix nanoseconds, as the API sends it. */ + created_at: number; message: MessageResponse | null; message_id: string; - remind_at: Date | null; + /** Unix nanoseconds, as the API sends it. */ + remind_at: number | null; + /** A duration, so milliseconds — see {@link timeLeftMs}. */ timeLeftMs: number | null; - updated_at: Date; + /** Unix nanoseconds, as the API sends it. */ + updated_at: number; user: UserResponse | null; user_id: string; }; @@ -35,11 +46,13 @@ export class Reminder { static toStateValue = (data: ReminderResponseBaseOrResponse): ReminderState => ({ ...data, - created_at: new Date(data.created_at), + created_at: data.created_at, message: data.message || null, - remind_at: data.remind_at ? new Date(data.remind_at) : null, - timeLeftMs: data.remind_at ? timeLeftMs(new Date(data.remind_at).getTime()) : null, - updated_at: new Date(data.updated_at), + remind_at: data.remind_at ?? null, + // Nullish rather than truthy: `0` is a legitimate timestamp (the epoch), and treating it as + // "no reminder set" is a real bug now that these are numbers rather than `Date` objects. + timeLeftMs: data.remind_at != null ? timeLeftMs(data.remind_at) : null, + updated_at: data.updated_at, user: data.user || null, }); @@ -58,22 +71,22 @@ export class Reminder { setState = (data: ReminderResponseBaseOrResponse) => { this.state.next((current) => { const newState = { ...current, ...Reminder.toStateValue(data) }; - if (newState.remind_at) { - newState.timeLeftMs = timeLeftMs(newState.remind_at.getTime()); + if (newState.remind_at != null) { + newState.timeLeftMs = timeLeftMs(newState.remind_at); } return newState; }); - if (data.remind_at) { + if (data.remind_at != null) { this.initTimer(); - } else if (!data.remind_at) { + } else { this.clearTimer(); } }; refreshTimeLeft = () => { - if (!this.remindAt) return; - this.state.partialNext({ timeLeftMs: timeLeftMs(this.remindAt.getTime()) }); + if (this.remindAt == null) return; + this.state.partialNext({ timeLeftMs: timeLeftMs(this.remindAt) }); }; initTimer = () => { diff --git a/src/reminders/ReminderManager.ts b/src/reminders/ReminderManager.ts index 2bce1d38ad..9148873b7d 100644 --- a/src/reminders/ReminderManager.ts +++ b/src/reminders/ReminderManager.ts @@ -306,7 +306,7 @@ export class ReminderManager extends WithSubscriptions { createReminder = async (options: CreateReminderOptions) => { const response = await this.client.createReminder(options); - return this.upsertToState({ data: response, overwrite: false }); + return this.upsertToState({ data: response.reminder, overwrite: false }); }; updateReminder = async (options: CreateReminderOptions) => { diff --git a/src/reminders/ReminderTimer.ts b/src/reminders/ReminderTimer.ts index 50ae7bd8c9..5385842024 100644 --- a/src/reminders/ReminderTimer.ts +++ b/src/reminders/ReminderTimer.ts @@ -38,8 +38,8 @@ export class ReminderTimer { } getRefreshIntervalLength = () => { - if (!this.reminder.remindAt) return null; - const distanceFromDeadlineMs = Math.abs(timeLeftMs(this.reminder.remindAt.getTime())); + if (this.reminder.remindAt == null) return null; + const distanceFromDeadlineMs = Math.abs(timeLeftMs(this.reminder.remindAt)); let refreshInterval: number | null; if (distanceFromDeadlineMs === 0) { refreshInterval = oneMinute; @@ -56,13 +56,14 @@ export class ReminderTimer { }; init = () => { - if (!this.reminder.remindAt) return null; + if (this.reminder.remindAt == null) return null; const timeoutLength = this.getRefreshIntervalLength(); if (timeoutLength === null) return null; - const boundaryTimestamp = - this.reminder.remindAt?.getTime() + this.stopRefreshBoundaryMs; - const timeLeftToBoundary = boundaryTimestamp - Date.now(); + // `stopRefreshBoundaryMs` is a duration in milliseconds, so it is applied after the wire + // timestamp has been converted rather than added to it. + const timeLeftToBoundary = + timeLeftMs(this.reminder.remindAt) + this.stopRefreshBoundaryMs; if (timeLeftToBoundary <= 0) { this.timeout = null; diff --git a/src/thread.ts b/src/thread.ts index c4fddcb2af..6e727defff 100644 --- a/src/thread.ts +++ b/src/thread.ts @@ -35,6 +35,7 @@ import { deleteReactionOptimistically, MessageOperations, } from './messageOperations'; +import { nowNs } from './utils/time'; import { WithSubscriptions } from './utils/WithSubscriptions'; import { MessagePaginator } from './pagination'; import type { MergeNewestPageOptions } from './pagination'; @@ -56,9 +57,11 @@ export type ThreadState = { */ active: boolean; channel: Channel; - createdAt: Date; + /** Unix nanoseconds, as the API sends it. */ + createdAt: number; custom: CustomThreadData; - deletedAt: Date | null; + /** Unix nanoseconds, as the API sends it. */ + deletedAt: number | null; isLoading: boolean; isStateStale: boolean; /** @@ -70,11 +73,13 @@ export type ThreadState = { read: ThreadReadState; replyCount: number; title: string; - updatedAt: Date | null; + /** Unix nanoseconds, as the API sends it. */ + updatedAt: number | null; }; export type ThreadUserReadState = { - lastReadAt: Date; + /** Unix nanoseconds, as the API sends it. */ + lastReadAt: number; unreadMessageCount: number; user: UserResponse; lastReadMessageId?: string; @@ -83,6 +88,9 @@ export type ThreadUserReadState = { export type ThreadReadState = Record; +const timestampOr = (value: number | undefined, fallback: number): number => + typeof value === 'number' && Number.isFinite(value) ? value : fallback; + const DEFAULT_PAGE_LIMIT = 50; const DEFAULT_SORT: SortParamRequest[] = [{ field: 'created_at', direction: -1 }]; const DEFAULT_ITEM_ORDER: SortParamRequest[] = [{ field: 'created_at', direction: 1 }]; @@ -159,9 +167,9 @@ export class Thread extends WithSubscriptions { isStateStale: false, // 99.9% should never change channel: threadChannel, - createdAt: new Date(threadData.created_at), + createdAt: threadData.created_at, // rest - deletedAt: threadData.deleted_at ? new Date(threadData.deleted_at) : null, + deletedAt: threadData.deleted_at ?? null, parentMessage: formatMessage(threadData.parent_message), participants: threadData.thread_participants, read: formatReadState( @@ -175,7 +183,7 @@ export class Thread extends WithSubscriptions { // INCLUDE them so the top level value renders fewer replies than the channel badge shows. // parent_message.reply_count is the authoritative, channel consistent count. replyCount: threadData.parent_message.reply_count ?? 0, - updatedAt: threadData.updated_at ? new Date(threadData.updated_at) : null, + updatedAt: threadData.updated_at ?? null, title: threadData.title, custom: threadData.custom ?? {}, }); @@ -193,9 +201,7 @@ export class Thread extends WithSubscriptions { } const formattedParentMessage = formatMessage(parentMessage); - const createdAt = parentMessage.created_at - ? new Date(parentMessage.created_at) - : new Date(); + const createdAt = timestampOr(parentMessage.created_at, nowNs()); this.state = new StateStore({ active: false, @@ -210,7 +216,7 @@ export class Thread extends WithSubscriptions { read: formatReadState(getPlaceholderReadResponse(client.userId)), replyCount: parentMessage.reply_count ?? 0, title: '', - updatedAt: parentMessage.updated_at ? new Date(parentMessage.updated_at) : null, + updatedAt: parentMessage.updated_at ?? null, }); this.id = parentMessage.id; @@ -664,8 +670,8 @@ export class Thread extends WithSubscriptions { this.state.partialNext({ title: threadData.title, - updatedAt: new Date(threadData.updated_at), - deletedAt: threadData.deleted_at ? new Date(threadData.deleted_at) : null, + updatedAt: threadData.updated_at, + deletedAt: threadData.deleted_at ?? null, custom: threadData.custom ?? {}, }); }).unsubscribe; @@ -709,7 +715,7 @@ export class Thread extends WithSubscriptions { private subscribeRepliesUnread = () => this.client.on('notification.mark_unread', (event) => { - if (!event.user || !event.created_at || !event.thread_id) return; + if (!event.user || event.created_at == null || !event.thread_id) return; if (event.thread_id !== this.id) return; const userId = event.user.id; @@ -722,10 +728,7 @@ export class Thread extends WithSubscriptions { ...current.read, [userId]: { ...current.read[userId], - lastReadAt: - typeof event.last_read_at !== 'undefined' - ? new Date(event.last_read_at) - : new Date(createdAt), + lastReadAt: timestampOr(event.last_read_at, createdAt), user, firstUnreadMessageId: event.first_unread_message_id, unreadMessageCount: event.unread_messages ?? 0, @@ -767,7 +770,7 @@ export class Thread extends WithSubscriptions { // in that thread nextUserRead = { ...nextUserRead, - lastReadAt: event.created_at ? new Date(event.created_at) : new Date(), + lastReadAt: timestampOr(event.created_at, nowNs()), user: event.user, unreadMessageCount: 0, }; @@ -790,7 +793,7 @@ export class Thread extends WithSubscriptions { private subscribeRepliesRead = () => this.client.on('message.read', (event) => { - if (!event.user || !event.created_at || !event.thread) return; + if (!event.user || event.created_at == null || !event.thread) return; if (event.thread.parent_message_id !== this.id) return; const userId = event.user.id; @@ -802,7 +805,7 @@ export class Thread extends WithSubscriptions { read: { ...current.read, [userId]: { - lastReadAt: new Date(createdAt), + lastReadAt: createdAt, user, lastReadMessageId: event.last_read_message_id, unreadMessageCount: 0, @@ -926,7 +929,7 @@ export class Thread extends WithSubscriptions { this.messagePaginator.applyMessageDeletionForUser({ userId: event.user.id, hardDelete: !!event.hard_delete, - deletedAt: deletedAtSource ? new Date(deletedAtSource) : new Date(), + deletedAt: deletedAtSource ?? nowNs(), }); }).unsubscribe, ); @@ -1159,7 +1162,7 @@ const normalizeThreadParticipants = ( ): ThreadStateResponse['thread_participants'] | undefined => { if (!participants) return undefined; - const now = new Date(); + const now = nowNs(); return participants.map( (participant: MessageThreadParticipant) => @@ -1179,7 +1182,7 @@ const formatReadState = (read: ReadStateResponse[]): ThreadReadState => user: userRead.user, lastReadMessageId: userRead.last_read_message_id, unreadMessageCount: userRead.unread_messages ?? 0, - lastReadAt: new Date(userRead.last_read), + lastReadAt: userRead.last_read, }; return state; }, {}); @@ -1190,7 +1193,7 @@ const getPlaceholderReadResponse = (currentUserId?: string): ReadStateResponse[] { user: { id: currentUserId } as UserResponse, unread_messages: 0, - last_read: new Date(), + last_read: nowNs(), }, ] : []; diff --git a/src/types.ts b/src/types.ts index 9bb48f33b9..0ac88cb84f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -250,13 +250,13 @@ type LocalEvent = ( | ({ type: 'message.read_locally' } & { channel_type: string; cid: string; - created_at: Date; + created_at: number; channel_id?: string; last_read_message_id?: string; team?: string; user?: UserResponse; }) -) & { received_at?: Date }; +) & { received_at?: number }; /** * The hello event of the `/api/v2/connect` WebSocket endpoint, sent once the auth frame @@ -269,9 +269,11 @@ type LocalEvent = ( export type ConnectedEvent = { type: 'connection.ok'; connection_id: string; - created_at: Date; + /** Unix nanoseconds, as every other wire event carries it. */ + created_at: number; me: OwnUserResponse; - received_at?: Date; + /** Unix nanoseconds, as every other wire event carries it. */ + received_at?: number; }; export type Event = WSEvent | ConnectedEvent | LocalEvent | keyof CustomEventTypes; diff --git a/src/utils.ts b/src/utils.ts index f8f191454d..bf1e7caef3 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -13,6 +13,7 @@ import type { Channel } from './channel'; import type { AxiosRequestConfig } from 'axios'; import { LOCAL_MESSAGE_FIELDS, RESERVED_UPDATED_MESSAGE_FIELDS } from './constants'; import { chatLoggerSystem } from './logger'; +import { nowNs, nsToDate } from './utils/time'; const logger = chatLoggerSystem.getLogger('utils'); @@ -254,16 +255,19 @@ export function formatMessage(message: MessageResponse | LocalMessage): LocalMes if (!msg) return null; return { ...msg, - created_at: msg.created_at ? new Date(msg.created_at) : new Date(), - deleted_at: msg.deleted_at ? new Date(msg.deleted_at) : undefined, - pinned_at: msg.pinned_at ? new Date(msg.pinned_at) : undefined, + // Timestamps are the wire's unix-nanosecond numbers and stay that way — there is no + // conversion left to do here. `created_at` / `updated_at` still default, because a locally + // composed message has none until the server answers. + created_at: msg.created_at ?? nowNs(), + deleted_at: msg.deleted_at ?? undefined, + pinned_at: msg.pinned_at ?? undefined, reaction_groups: maybeGetReactionGroupsFallback( msg.reaction_groups, msg.reaction_counts, msg.reaction_scores, ), status: (msg as LocalMessage).status || 'received', - updated_at: msg.updated_at ? new Date(msg.updated_at) : new Date(), + updated_at: msg.updated_at ?? nowNs(), }; }; @@ -405,6 +409,38 @@ export function messageWithReactionRemoved( }; } +/** + * Wire timestamps into the `Date` objects `MessageRequest` declares. Sub-millisecond precision is + * lost, which is inherent to the declared request type. + * + * `shared_location` is listed field by field so only what `SharedLocation` declares can reach the + * API — a location read off a message also carries `channel_cid`, `user_id`, `created_at`, + * `updated_at` and `message_id`. + */ +const toRequestDateFields = ({ + pinned_at, + pin_expires, + shared_location, +}: Pick, 'pinned_at' | 'pin_expires' | 'shared_location'>): Pick< + MessageRequest, + 'pinned_at' | 'pin_expires' | 'shared_location' +> => ({ + ...(pinned_at != null ? { pinned_at: nsToDate(pinned_at) } : {}), + ...(pin_expires != null ? { pin_expires: nsToDate(pin_expires) } : {}), + ...(shared_location + ? { + shared_location: { + latitude: shared_location.latitude, + longitude: shared_location.longitude, + created_by_device_id: shared_location.created_by_device_id, + ...(shared_location.end_at != null + ? { end_at: nsToDate(shared_location.end_at) } + : {}), + }, + } + : {}), +}); + export const localMessageToNewMessagePayload = ( localMessage: LocalMessage, ): MessageRequest => { @@ -414,6 +450,10 @@ export const localMessageToNewMessagePayload = ( created_at: _created_at, updated_at: _updated_at, deleted_at: _deleted_at, + message_text_updated_at: _message_text_updated_at, + pinned_at, + pin_expires, + shared_location, // Client-specific fields error: _error, status: _status, @@ -434,12 +474,15 @@ export const localMessageToNewMessagePayload = ( ...messageFields } = localMessage; + const requestDates = toRequestDateFields({ pinned_at, pin_expires, shared_location }); + // `messageFields` still carries LocalMessage-only fields (cid, deleted_reply_count, mentioned_*, // pinned, shadowed, …) that the stricter OpenAPI `MessageRequest` omits; the server ignores them. return { - ...messageFields, + ...(messageFields as MessageRequest), mentioned_users: mentioned_users?.map((user) => user.id), - } as MessageRequest; + ...requestDates, + }; }; export const toUpdatedMessagePayload = ( @@ -458,10 +501,15 @@ export const toUpdatedMessagePayload = ( return { ...messageFields, - pinned: !!message.pinned_at, + pinned: message.pinned_at != null, mentioned_users: message.mentioned_users?.map((user) => typeof user === 'string' ? user : user.id, ), + ...toRequestDateFields({ + pinned_at: message.pinned_at, + pin_expires: message.pin_expires, + shared_location: message.shared_location, + }), }; }; @@ -533,7 +581,7 @@ export const findIndexInSortedArray = ({ * * @example * ```ts - * selectValueToCompare: (message) => message.created_at.getTime() + * selectValueToCompare: (message) => message.created_at * ``` */ selectValueToCompare?: (arrayElement: T) => L | T; @@ -749,8 +797,6 @@ export const generateChannelTempCid = (channelType: string, members: string[]) = return `${channelType}:!members-${membersStr}`; }; -export const isDate = (value: unknown): value is Date => !!(value as Date).getTime; - export const isLocalMessage = (message: unknown): message is LocalMessage => typeof (message as LocalMessage | undefined)?.status === 'string'; diff --git a/src/utils/time.ts b/src/utils/time.ts new file mode 100644 index 0000000000..fe153f5c7c --- /dev/null +++ b/src/utils/time.ts @@ -0,0 +1,84 @@ +/** + * Unit conversions between the API's timestamps and JavaScript's. + * + * **The invariant this module exists to hold:** a timestamp is a unix-**nanosecond** `number` — + * that is what the API puts on the wire and what every server-sent date field on a generated + * response or event type carries. A duration, interval or delay stays in **milliseconds**, because + * that is what `setTimeout` and every "time left" value the SDK exposes speak. + * + * Two consequences make the helpers below mandatory rather than convenient: + * + * - **Every `Date`-based path is out of range.** `Date` tops out around 8.64e15 ms while a current + * nanosecond timestamp is ~1.79e18, and a date library reads a bare number as milliseconds, so + * both land on an invalid instance. `.toISOString()` throws `RangeError`; `dayjs(ns).format()` + * returns the literal string `'Invalid Date'`. Neither is a type error. + * - **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. + * + * Outgoing **request** date fields are unaffected: they are still typed `Date`, because + * `JSON.stringify` emits RFC3339 for a `Date` and that is the format the request spec declares. + * Use {@link nsToDate} when handing a server-sent timestamp back to the API. + * + * Precision note: nanosecond epoch values exceed `Number.MAX_SAFE_INTEGER` (~9.01e15), so they are + * quantised to roughly 256 ns steps. Ordering is unaffected; exact equality against a value that + * has round-tripped through JSON is not guaranteed. + */ + +/** Nanoseconds per millisecond — the only magic number in this module. */ +export const NS_PER_MS = 1e6; + +/** A wire timestamp as epoch milliseconds, for arithmetic against `Date.now()` or a `Date`. */ +export const nsToMs = (ns: number): number => Math.floor(ns / NS_PER_MS); + +/** Epoch milliseconds as a wire timestamp. */ +export const msToNs = (ms: number): number => ms * NS_PER_MS; + +/** + * The local clock as a wire-comparable timestamp, for optimistic writes into API-shaped objects. + * + * Millisecond resolution, so two writes within the same millisecond produce equal timestamps. + * Callers that order by timestamp must tie-break on something else — `findIndexInSortedArray` + * does so via `selectKey`, and `MessageReceiptsTracker` compares message ids. + */ +export const nowNs = (): number => msToNs(Date.now()); + +/** A wire timestamp as a `Date`, for request payloads and date libraries. */ +export const nsToDate = (ns: number): Date => new Date(nsToMs(ns)); + +/** + * A wire timestamp as a nanosecond-precision RFC3339 string, for an outgoing request date field. + * {@link nsToDate} is lossy here — `Date` holds only milliseconds. + */ +export const nsToRfc3339 = (ns: number): string => { + const ms = nsToMs(ns); + const subMs = String(ns - ms * NS_PER_MS).padStart(6, '0'); + return new Date(ms).toISOString().replace(/\.(\d{3})Z$/, `.$1${subMs}Z`); +}; + +/** A `Date` as a wire timestamp. */ +export const dateToNs = (date: Date): number => msToNs(date.getTime()); + +/** + * A server-sent timestamp as a `Date`, or `undefined` when there is none. + * + * The guarded companion to {@link nsToDate}, for the boundary where a wire timestamp becomes + * something a date library or a UI prop consumes. Use {@link nsToDate} when the value is known to + * be present; use this when it comes straight off a response, an event or persisted state. + * + * The guard is the whole point, and it covers two cases that are silent rather than loud: + * + * - **Absent.** Many timestamps are optional in practice even where the generated type marks them + * required. `nsToDate(undefined as never)` yields an `Invalid Date`, and `.toISOString()` on one + * throws `RangeError: Invalid time value` — typically mid-render, in a component that had no + * reason to expect it. + * - **Not finite.** A malformed payload or a hand-built fixture can produce `NaN`, which reaches + * the same `RangeError` by a different route. + * + * Returning `undefined` rather than throwing lets a caller fall back to whatever it already does + * for a missing timestamp, which is usually to render nothing. + */ +export const convertTimestampToDate = (timestamp?: number | null): Date | undefined => { + if (timestamp == null || !Number.isFinite(timestamp)) return undefined; + return nsToDate(timestamp); +}; diff --git a/test/typescript/index.js b/test/typescript/index.js index 289a954959..a81a2501d2 100644 --- a/test/typescript/index.js +++ b/test/typescript/index.js @@ -27,11 +27,6 @@ const executables = [ imports: ['Channel', 'Unpacked'], type: "Unpacked>", }, - { - f: rg.addModerators, - imports: ['Channel', 'Unpacked'], - type: "Unpacked>", - }, { f: rg.banUsers, imports: ['StreamChat', 'Unpacked'], @@ -141,11 +136,6 @@ const executables = [ imports: ['StreamChat', 'Unpacked'], type: "Unpacked>", }, - { - f: rg.demoteModerators, - imports: ['Channel', 'Unpacked'], - type: "Unpacked>", - }, // { // f: rg.disconnect, // imports: ['StreamChat', 'Unpacked'], diff --git a/test/typescript/response-generators/channel.js b/test/typescript/response-generators/channel.js index 0aa7fd0e3d..5e314b259b 100644 --- a/test/typescript/response-generators/channel.js +++ b/test/typescript/response-generators/channel.js @@ -72,11 +72,6 @@ async function removeFilterTags() { return await channel.removeFilterTags(['tag1']); } -async function addModerators() { - const channel = await utils.createTestChannel(uuidv4(), johnID); - return await channel.addModerators([johnID]); -} - async function create() { const authClient = await utils.getTestClientForUser(johnID, {}); const id = uuidv4(); @@ -118,12 +113,6 @@ async function deleteImage() { return channel.deleteImage(image.file); } -async function demoteModerators() { - const channel = await utils.createTestChannel(uuidv4(), johnID); - await channel.addModerators([johnID]); - return await channel.demoteModerators([johnID]); -} - async function getConfig() { const channel = await utils.createTestChannel(uuidv4(), johnID); @@ -276,12 +265,10 @@ module.exports = { acceptInvite, addMembers, addFilterTags, - addModerators, create, deleteChannel, deleteFile, deleteImage, - demoteModerators, getConfig, hide, inviteMembers, diff --git a/test/unit/ChannelManager.test.ts b/test/unit/ChannelManager.test.ts index 2fe086d577..a7f869c823 100644 --- a/test/unit/ChannelManager.test.ts +++ b/test/unit/ChannelManager.test.ts @@ -20,6 +20,7 @@ vi.mock('../../src/pagination/utility.queryChannel', async () => { }; }); import { getChannel as mockGetChannel } from '../../src/pagination/utility.queryChannel'; +import { convertDateToTimestamp } from './test-utils/time'; describe('ChannelManager', () => { let client: StreamChat; @@ -1840,11 +1841,14 @@ describe('ChannelManager', () => { // what `member.updated` does to the live channel before the manager is notified pinned.state.membership = { ...pinned.state.membership, - pinned_at: new Date().toISOString(), + pinned_at: convertDateToTimestamp(new Date().toISOString()), }; client.dispatchEvent({ cid: pinned.cid, - member: { pinned_at: new Date().toISOString(), user: { id: client.userId } }, + member: { + pinned_at: convertDateToTimestamp(new Date().toISOString()), + user: { id: client.userId }, + }, type: 'member.updated', } as any); @@ -1867,11 +1871,14 @@ describe('ChannelManager', () => { target.state.membership = { ...target.state.membership, - pinned_at: new Date().toISOString(), + pinned_at: convertDateToTimestamp(new Date().toISOString()), }; client.dispatchEvent({ cid: target.cid, - member: { pinned_at: new Date().toISOString(), user: { id: client.userId } }, + member: { + pinned_at: convertDateToTimestamp(new Date().toISOString()), + user: { id: client.userId }, + }, type: 'member.updated', } as any); await vi.waitFor(() => expect(paginator.items?.[0]?.cid).toBe(target.cid)); @@ -2064,7 +2071,7 @@ describe('ChannelManager', () => { const seedUnread = (channel: Channel, unreadMessages = 1) => { channel.state.read = { [client.userId as string]: { - last_read: new Date(0), + last_read: convertDateToTimestamp(new Date(0)), unread_messages: unreadMessages, user: { id: client.userId as string }, }, @@ -2084,7 +2091,7 @@ describe('ChannelManager', () => { channel_id: channel.id, channel_type: channel.type, cid: channel.cid, - created_at: new Date(), + created_at: convertDateToTimestamp(new Date()), type: 'notification.mark_read' as const, user: { id: client.userId as string }, ...payload, @@ -2134,7 +2141,7 @@ describe('ChannelManager', () => { channel_id: channel.id, channel_type: channel.type, cid: channel.cid, - created_at: new Date(), + created_at: convertDateToTimestamp(new Date()), type: 'message.read', user: { id: 'somebody-else' }, }); @@ -2169,7 +2176,7 @@ describe('ChannelManager', () => { // reconciles on the next event naming a channel, or the next query seedUnread(channel, 0); client.dispatchEvent({ - created_at: new Date(), + created_at: convertDateToTimestamp(new Date()), type: 'notification.mark_read', unread_channels: 0, user: { id: client.userId as string }, @@ -2216,7 +2223,7 @@ describe('ChannelManager', () => { client.channelManager.registerSubscriptions(); client.dispatchEvent({ - created_at: new Date(), + created_at: convertDateToTimestamp(new Date()), type: 'notification.mark_read', unread_channels: 0, user: { id: client.userId as string }, @@ -2283,7 +2290,7 @@ describe('ChannelManager', () => { channel_id: '306', channel_type: 'messaging', cid: 'messaging:306', - created_at: new Date(), + created_at: convertDateToTimestamp(new Date()), type: 'notification.mark_read', user: { id: client.userId as string }, }); diff --git a/test/unit/CooldownTimer.test.ts b/test/unit/CooldownTimer.test.ts index 8526ac293d..0b1060dc34 100644 --- a/test/unit/CooldownTimer.test.ts +++ b/test/unit/CooldownTimer.test.ts @@ -4,6 +4,7 @@ import { getClientWithUser } from './test-utils/getClient'; import { generateMsg } from './test-utils/generateMessage'; import { formatMessage } from '../../src'; import type { Channel, ChannelResponse, Event } from '../../src'; +import { convertDateToTimestamp } from './test-utils/time'; // CooldownTimer.refresh() derives the current user's latest message from the message paginator's // latest (head) window, so tests seed the paginator (formatted) rather than legacy channel state. @@ -73,11 +74,13 @@ describe('CooldownTimer', () => { it('picks up the own latest message from a paginator ingest', async () => { const channel = await open('cooldown-paginator'); - const created_at = '2024-01-01T00:00:00.000Z'; + const created_at = convertDateToTimestamp('2024-01-01T00:00:00.000Z'); seedLatestWindow(channel, generateMsg({ created_at, user: { id: 'user-1' } })); - expect(channel.cooldownTimer.ownLatestMessageDate?.toISOString()).toBe(created_at); + expect(channel.cooldownTimer.ownLatestMessageTimestamp).toBe( + convertDateToTimestamp(created_at), + ); }); it('stops deriving once unregistered', async () => { @@ -164,8 +167,8 @@ describe('CooldownTimer', () => { seedLatestWindow( channel, generateMsg({ - created_at: lastOwnMessageAt, - updated_at: lastOwnMessageAt, + created_at: convertDateToTimestamp(lastOwnMessageAt), + updated_at: convertDateToTimestamp(lastOwnMessageAt), user: { id: client.userId as string }, }), ); @@ -201,8 +204,8 @@ describe('CooldownTimer', () => { seedLatestWindow( channel, generateMsg({ - created_at: now, - updated_at: now, + created_at: convertDateToTimestamp(now), + updated_at: convertDateToTimestamp(now), user: { id: client.userId as string }, }), ); @@ -216,8 +219,8 @@ describe('CooldownTimer', () => { seedLatestWindow( channel, generateMsg({ - created_at: now, - updated_at: now, + created_at: convertDateToTimestamp(now), + updated_at: convertDateToTimestamp(now), user: { id: client.userId as string }, }), ); @@ -231,8 +234,8 @@ describe('CooldownTimer', () => { seedLatestWindow( channel, generateMsg({ - created_at: now, - updated_at: now, + created_at: convertDateToTimestamp(now), + updated_at: convertDateToTimestamp(now), user: { id: client.userId as string }, }), ); @@ -264,8 +267,8 @@ describe('CooldownTimer', () => { seedLatestWindow( channel, generateMsg({ - created_at: lastOwnMessageAt, - updated_at: lastOwnMessageAt, + created_at: convertDateToTimestamp(lastOwnMessageAt), + updated_at: convertDateToTimestamp(lastOwnMessageAt), user: { id: client.userId as string }, }), ); @@ -291,8 +294,8 @@ describe('CooldownTimer', () => { seedLatestWindow( channel, generateMsg({ - created_at: now, - updated_at: now, + created_at: convertDateToTimestamp(now), + updated_at: convertDateToTimestamp(now), user: { id: client.userId as string }, }), ); @@ -315,8 +318,8 @@ describe('CooldownTimer', () => { seedLatestWindow( channel, generateMsg({ - created_at: lastOwnMessageAt, - updated_at: lastOwnMessageAt, + created_at: convertDateToTimestamp(lastOwnMessageAt), + updated_at: convertDateToTimestamp(lastOwnMessageAt), user: { id: client.userId as string }, }), ); @@ -350,8 +353,8 @@ describe('CooldownTimer', () => { seedLatestWindow( channel, generateMsg({ - created_at: lastOwnMessageAt, - updated_at: lastOwnMessageAt, + created_at: convertDateToTimestamp(lastOwnMessageAt), + updated_at: convertDateToTimestamp(lastOwnMessageAt), user: { id: client.userId as string }, }), ); @@ -385,8 +388,8 @@ describe('CooldownTimer', () => { seedLatestWindow( channel, generateMsg({ - created_at: lastOwnMessageAt, - updated_at: lastOwnMessageAt, + created_at: convertDateToTimestamp(lastOwnMessageAt), + updated_at: convertDateToTimestamp(lastOwnMessageAt), user: { id: client.userId as string }, }), ); @@ -423,8 +426,8 @@ describe('CooldownTimer', () => { user: { id: client.userId as string }, message: generateMsg({ cid: channel.cid, // must match the paginator filter so message.new ingests into an interval - created_at: now, - updated_at: now, + created_at: convertDateToTimestamp(now), + updated_at: convertDateToTimestamp(now), user: { id: client.userId as string }, }), } as Event); diff --git a/test/unit/LiveLocationManager.test.ts b/test/unit/LiveLocationManager.test.ts index a6cf30d730..f076c3b1f2 100644 --- a/test/unit/LiveLocationManager.test.ts +++ b/test/unit/LiveLocationManager.test.ts @@ -3,12 +3,14 @@ import { Coords, LiveLocationManager, LiveLocationManagerConstructorParameters, + msToNs, SharedLiveLocationResponse, StreamChat, UPDATE_LIVE_LOCATION_REQUEST_MIN_THROTTLE_TIMEOUT, WatchLocationHandler, } from '../../src'; import { getClientWithUser } from './test-utils/getClient'; +import { convertDateToTimestamp } from './test-utils/time'; import { sleep } from '../../src/utils'; const makeWatchLocation = @@ -33,24 +35,24 @@ describe('LiveLocationManager', () => { const user = { id: 'user-id' }; const liveLocation: SharedLiveLocationResponse = { channel_cid: 'channel_cid', - created_at: 'created_at', + created_at: convertDateToTimestamp('2026-01-01T00:00:00.000Z'), created_by_device_id: 'created_by_device_id', - end_at: '9999-12-31T23:59:59.535Z', + end_at: convertDateToTimestamp('9999-12-31T23:59:59.535Z'), latitude: 1, longitude: 2, message_id: 'liveLocation_message_id', - updated_at: 'updated_at', + updated_at: convertDateToTimestamp('2026-01-01T00:00:00.000Z'), user_id: user.id, }; const liveLocation2: SharedLiveLocationResponse = { channel_cid: 'channel_cid2', - created_at: 'created_at', + created_at: convertDateToTimestamp('2026-01-01T00:00:00.000Z'), created_by_device_id: 'created_by_device_id', - end_at: '9999-12-31T23:59:59.535Z', + end_at: convertDateToTimestamp('9999-12-31T23:59:59.535Z'), latitude: 1, longitude: 2, message_id: 'liveLocation_message_id2', - updated_at: 'updated_at', + updated_at: convertDateToTimestamp('2026-01-01T00:00:00.000Z'), user_id: user.id, }; @@ -550,9 +552,9 @@ describe('LiveLocationManager', () => { active_live_locations: [ { ...liveLocation, - end_at: new Date( + end_at: msToNs( Date.now() + UPDATE_LIVE_LOCATION_REQUEST_MIN_THROTTLE_TIMEOUT - 1000, - ).toISOString(), + ), }, ], duration: '', @@ -659,7 +661,7 @@ describe('LiveLocationManager', () => { it('updates location for registered message', async () => { const client = await getClientWithUser(user); vi.spyOn(client, 'getUserLiveLocations').mockResolvedValue({ - active_live_locations: [{ ...liveLocation, end_at: new Date().toISOString() }], + active_live_locations: [{ ...liveLocation, end_at: msToNs(Date.now()) }], duration: '', }); vi.spyOn(client, 'updateLiveLocation').mockResolvedValue(liveLocation); @@ -791,7 +793,7 @@ describe('LiveLocationManager', () => { await manager.init(); expect(manager.messages).toHaveLength(1); - const newEndAt = '1970-01-01T08:08:08.532Z'; + const newEndAt = convertDateToTimestamp('1970-01-01T08:08:08.532Z'); client.dispatchEvent({ message: { id: liveLocation.message_id, @@ -822,7 +824,7 @@ describe('LiveLocationManager', () => { await manager.init(); expect(manager.messages).toHaveLength(1); - const newEndAt = '1970-01-01T08:08:08.532Z'; + const newEndAt = convertDateToTimestamp('1970-01-01T08:08:08.532Z'); client.dispatchEvent({ message: { id: liveLocation.message_id, @@ -919,4 +921,72 @@ describe('LiveLocationManager', () => { expect(manager.deviceId).toBe(deviceId); }); }); + describe('stop-sharing timer', () => { + // `setTimeout` clamps a delay past 2^31-1 ms (~24.9 days) to 1 ms, and only a minimum share + // duration is enforced, so a long share used to unregister itself on the next tick. + it('keeps a share whose expiry is beyond the maximum timeout delay', async () => { + vi.useFakeTimers(); + try { + const client = await getClientWithUser(user); + const farFuture = { ...liveLocation, end_at: msToNs(Date.now() + 90 * 86400000) }; + vi.spyOn(client, 'getUserLiveLocations').mockResolvedValue({ + active_live_locations: [farFuture], + duration: '', + }); + const manager = new LiveLocationManager({ client, getDeviceId, watchLocation }); + + await manager.init(); + expect(manager.messages.size).toBe(1); + + await vi.advanceTimersByTimeAsync(2 ** 31 - 1); + // Re-armed rather than expired: still tracked, and holding a fresh handle to clear. + expect(manager.messages.size).toBe(1); + expect( + manager.messages.get(farFuture.message_id)?.stopSharingTimeout, + ).not.toBeNull(); + } finally { + vi.useRealTimers(); + } + }); + + it('unregisters a share once its expiry actually arrives', async () => { + vi.useFakeTimers(); + try { + const client = await getClientWithUser(user); + const soon = { ...liveLocation, end_at: msToNs(Date.now() + 60_000) }; + vi.spyOn(client, 'getUserLiveLocations').mockResolvedValue({ + active_live_locations: [soon], + duration: '', + }); + const manager = new LiveLocationManager({ client, getDeviceId, watchLocation }); + + await manager.init(); + expect(manager.messages.size).toBe(1); + + await vi.advanceTimersByTimeAsync(60_001); + expect(manager.messages.size).toBe(0); + } finally { + vi.useRealTimers(); + } + }); + + // `end_at` is optional on the wire even though the overlay type marks it required, and + // `undefined < nowNs()` is `false` — so an unguarded filter kept it and scheduled NaN. + it.each([ + ['absent', undefined], + ['non-finite', Number.NaN], + ])('does not track a share whose end_at is %s', async (_label, endAt) => { + const client = await getClientWithUser(user); + vi.spyOn(client, 'getUserLiveLocations').mockResolvedValue({ + active_live_locations: [ + { ...liveLocation, end_at: endAt } as unknown as SharedLiveLocationResponse, + ], + duration: '', + }); + const manager = new LiveLocationManager({ client, getDeviceId, watchLocation }); + + await manager.init(); + expect(manager.messages.size).toBe(0); + }); + }); }); diff --git a/test/unit/MessageComposer/CustomDataManager.test.ts b/test/unit/MessageComposer/CustomDataManager.test.ts index 9a0f00fb98..e1dea84e42 100644 --- a/test/unit/MessageComposer/CustomDataManager.test.ts +++ b/test/unit/MessageComposer/CustomDataManager.test.ts @@ -4,6 +4,7 @@ import { MessageComposer } from '../../../src/messageComposer/messageComposer'; import { Channel } from '../../../src/channel'; import { StreamChat } from '../../../src/client'; import { LocalMessage } from '../../../src/types'; +import { convertDateToTimestamp } from '../test-utils/time'; describe('CustomDataManager', () => { let customDataManager: CustomDataManager; @@ -44,11 +45,11 @@ describe('CustomDataManager', () => { type: 'regular', attachments: [], mentioned_users: [], - created_at: new Date(), + created_at: convertDateToTimestamp(new Date()), deleted_at: null, pinned_at: null, status: 'sent', - updated_at: new Date(), + updated_at: convertDateToTimestamp(new Date()), }; const managerWithMessage = new CustomDataManager({ @@ -79,11 +80,11 @@ describe('CustomDataManager', () => { type: 'regular', attachments: [], mentioned_users: [], - created_at: new Date(), + created_at: convertDateToTimestamp(new Date()), deleted_at: null, pinned_at: null, status: 'sent', - updated_at: new Date(), + updated_at: convertDateToTimestamp(new Date()), }; customDataManager.initState({ message }); diff --git a/test/unit/MessageComposer/LocationComposer.test.ts b/test/unit/MessageComposer/LocationComposer.test.ts index b86850a0b1..b90999d096 100644 --- a/test/unit/MessageComposer/LocationComposer.test.ts +++ b/test/unit/MessageComposer/LocationComposer.test.ts @@ -6,6 +6,7 @@ import { MessageComposer, StreamChat, } from '../../../src'; +import { convertDateToTimestamp } from '../test-utils/time'; const deviceId = 'deviceId'; @@ -41,25 +42,30 @@ const setup = ({ }); return { mockClient, mockChannel, messageComposer }; }; +// Wire-shaped, because that is the whole point: a `shared_location` read off a message carries the +// response-only fields below and a unix-**nanosecond** `end_at`. A fixture written with ISO strings +// or `Date`s cannot catch either thing leaking back into a request. +const END_AT_ISO = '2099-12-31T23:59:59.535Z'; +const sharedLocationResponse = { + channel_cid: 'channelType:channelId', + created_at: convertDateToTimestamp('2026-01-01T00:00:00.000Z'), + created_by_device_id: 'created_by_device_id', + end_at: convertDateToTimestamp(END_AT_ISO), + latitude: 1, + longitude: 2, + message_id: 'liveLocation_message_id', + updated_at: convertDateToTimestamp('2026-01-01T00:00:00.000Z'), + user_id: user.id, +}; const locationMessage: LocalMessage = { - created_at: new Date(), - updated_at: new Date(), - deleted_at: null, - pinned_at: null, + created_at: convertDateToTimestamp('2026-01-01T00:00:00.000Z'), + updated_at: convertDateToTimestamp('2026-01-01T00:00:00.000Z'), + deleted_at: undefined, + pinned_at: undefined, type: 'regular', status: 'received', id: 'messageId', - shared_location: { - channel_cid: 'channel_cid', - created_at: 'created_at', - created_by_device_id: 'created_by_device_id', - end_at: '9999-12-31T23:59:59.535Z', - latitude: 1, - longitude: 2, - message_id: 'liveLocation_message_id', - updated_at: 'updated_at', - user_id: user.id, - }, + shared_location: sharedLocationResponse, }; describe('LocationComposer', () => { it('constructor initiates state and variables', () => { @@ -73,16 +79,88 @@ describe('LocationComposer', () => { expect(locationComposer.config).toEqual(defaultConfig); }); - it('overrides state with initState', () => { + it('overrides state with initState, narrowed to the request shape', () => { const { messageComposer: { locationComposer }, } = setup(); locationComposer.initState({ message: locationMessage }); - expect(locationComposer.state.getLatestValue()).toEqual({ - location: locationMessage.shared_location, + // Not the response object: the response-only fields are dropped and the nanosecond `end_at` + // becomes the `Date` a `SharedLocation` request declares. Storing the response verbatim is how + // `channel_cid` / `user_id` / a numeric `created_at` reached the API on the next composition. + expect(locationComposer.state.getLatestValue()).toStrictEqual({ + location: { + created_by_device_id: 'created_by_device_id', + end_at: new Date(END_AT_ISO), + latitude: 1, + longitude: 2, + message_id: 'liveLocation_message_id', + }, + }); + }); + + it('keeps a hydrated live location live rather than silently making it static', () => { + const { + messageComposer: { locationComposer }, + } = setup(); + locationComposer.initState({ message: locationMessage }); + + // A location off a message has an absolute `end_at` and no `durationMs`. Resolving the expiry + // from `durationMs` alone dropped it here, so editing the message unshared the live location. + expect(locationComposer.validLocation?.end_at).toStrictEqual(new Date(END_AT_ISO)); + }); + + it('emits only request fields for a hydrated location', () => { + const { + messageComposer: { locationComposer }, + } = setup(); + locationComposer.initState({ message: locationMessage }); + + expect(locationComposer.validLocation).toStrictEqual({ + created_by_device_id: 'created_by_device_id', + end_at: new Date(END_AT_ISO), + latitude: 1, + longitude: 2, + message_id: 'liveLocation_message_id', + }); + // Serialized, so a regression shows up as the nanosecond number it would put on the wire. + expect(JSON.parse(JSON.stringify(locationComposer.validLocation))).toStrictEqual({ + created_by_device_id: 'created_by_device_id', + end_at: END_AT_ISO, + latitude: 1, + longitude: 2, + message_id: 'liveLocation_message_id', }); }); + it('hydrates a static location without inventing an expiry', () => { + const { + messageComposer: { locationComposer }, + } = setup(); + locationComposer.initState({ + message: { + ...locationMessage, + shared_location: { ...sharedLocationResponse, end_at: undefined }, + }, + }); + + expect(locationComposer.location).not.toHaveProperty('end_at'); + expect(locationComposer.validLocation).not.toHaveProperty('end_at'); + }); + + it('drops a non-finite end_at rather than storing an Invalid Date', () => { + const { + messageComposer: { locationComposer }, + } = setup(); + locationComposer.initState({ + message: { + ...locationMessage, + shared_location: { ...sharedLocationResponse, end_at: NaN }, + }, + }); + + expect(locationComposer.location).not.toHaveProperty('end_at'); + }); + it('does not override state with initState with message without shared_location', () => { const { messageComposer: { locationComposer }, diff --git a/test/unit/MessageComposer/attachmentManager.test.ts b/test/unit/MessageComposer/attachmentManager.test.ts index 2a925e7de3..cf4ad58fb4 100644 --- a/test/unit/MessageComposer/attachmentManager.test.ts +++ b/test/unit/MessageComposer/attachmentManager.test.ts @@ -14,6 +14,7 @@ import { import { AppResponseFields } from '../../../src'; import * as Utils from '../../../src/utils'; import { beforeEach } from 'node:test'; +import { convertDateToTimestamp } from '../test-utils/time'; /** * Utility to generate a file @@ -165,7 +166,7 @@ describe('AttachmentManager', () => { ], }, channel_cid: 'channel-cid', - created_at: new Date().toISOString(), + created_at: convertDateToTimestamp(new Date().toISOString()), }; // ts-expect-error mocked channel @@ -191,11 +192,11 @@ describe('AttachmentManager', () => { id: 'test-message-id', text: '', type: 'regular', - created_at: new Date(), + created_at: convertDateToTimestamp(new Date()), deleted_at: null, pinned_at: null, status: 'pending', - updated_at: new Date(), + updated_at: convertDateToTimestamp(new Date()), attachments: [ { type: 'image', @@ -329,7 +330,7 @@ describe('AttachmentManager', () => { }), }, channel_cid: 'channel-cid', - created_at: new Date().toISOString(), + created_at: convertDateToTimestamp(new Date().toISOString()), }; // Initialize with message containing maximum attachments @@ -456,7 +457,7 @@ describe('AttachmentManager', () => { ], }, channel_cid: 'channel-cid', - created_at: new Date().toISOString(), + created_at: convertDateToTimestamp(new Date().toISOString()), }; // Initialize with message containing attachments diff --git a/test/unit/MessageComposer/linkPreviewsManager.test.ts b/test/unit/MessageComposer/linkPreviewsManager.test.ts index ed2d743edf..58d6d8e9f4 100644 --- a/test/unit/MessageComposer/linkPreviewsManager.test.ts +++ b/test/unit/MessageComposer/linkPreviewsManager.test.ts @@ -12,6 +12,7 @@ import { import { DeepPartial } from '../../../src/types.utility'; import { mergeWith } from '../../../src/utils/mergeWith'; import { stubServerConfig } from '../test-utils/stubServerConfig'; +import { convertDateToTimestamp } from '../test-utils/time'; const existingLinkUrl = 'https://existing.com'; const linkUrl = 'https://example.com'; @@ -139,11 +140,11 @@ describe('LinkPreviewsManager', () => { id: 'test-message-id', text: '', type: 'regular', - created_at: new Date(), + created_at: convertDateToTimestamp(new Date()), deleted_at: null, pinned_at: null, status: 'pending', - updated_at: new Date(), + updated_at: convertDateToTimestamp(new Date()), attachments: [ { og_scrape_url: linkUrl, @@ -166,11 +167,11 @@ describe('LinkPreviewsManager', () => { id: 'test-message-id', text: '', type: 'regular', - created_at: new Date(), + created_at: convertDateToTimestamp(new Date()), deleted_at: null, pinned_at: null, status: 'pending', - updated_at: new Date(), + updated_at: convertDateToTimestamp(new Date()), attachments: [ { og_scrape_url: linkUrl, diff --git a/test/unit/MessageComposer/messageComposer.test.ts b/test/unit/MessageComposer/messageComposer.test.ts index 29e5a5cb68..65bee5ce0e 100644 --- a/test/unit/MessageComposer/messageComposer.test.ts +++ b/test/unit/MessageComposer/messageComposer.test.ts @@ -21,6 +21,8 @@ import { MockOfflineDB } from '../offline-support/MockOfflineDB'; import { getCommandByName } from '../../../src/messageComposer/middleware/textComposer/commandUtils'; import { generateMsg } from '../test-utils/generateMessage'; import { stubServerConfig } from '../test-utils/stubServerConfig'; +import { msToNs, nowNs } from '../../../src/utils/time'; +import { convertDateToTimestamp } from '../test-utils/time'; const generateUuidV4Output = 'test-uuid'; // Mock dependencies @@ -48,10 +50,10 @@ vi.mock('../../../src/utils', async (importOriginal) => ({ const quotedMessage = { id: 'quoted-message-id', type: 'regular' as const, - created_at: new Date(), + created_at: nowNs(), deleted_at: null, pinned_at: null, - updated_at: new Date(), + updated_at: nowNs(), status: 'received', text: 'Quoted message', user: { id: 'user-id', name: 'User Name' }, @@ -69,8 +71,8 @@ const getThread = (channel: Channel, client: StreamChat, threadId: string) => text: 'Test message', type: 'regular' as const, user, - created_at: new Date(), - updated_at: new Date(), + created_at: nowNs(), + updated_at: nowNs(), }, channel: { id: channel.id!, @@ -80,8 +82,8 @@ const getThread = (channel: Channel, client: StreamChat, threadId: string) => frozen: false, }, title: 'Test Thread', - created_at: new Date(), - updated_at: new Date(), + created_at: nowNs(), + updated_at: nowNs(), channel_cid: channel.cid, latest_replies: [], thread_participants: [], @@ -418,7 +420,7 @@ describe('MessageComposer', () => { mentioned_users: [], }, channel_cid: 'test-channel-id', - created_at: new Date().toISOString(), + created_at: nowNs(), }; const { messageComposer } = setup({ composition: draftMessage }); @@ -581,10 +583,10 @@ describe('MessageComposer', () => { expect(messageComposer.quotedMessage).toEqual({ id: 'quoted-message-id', type: 'regular', - created_at: expect.any(Date), + created_at: expect.any(Number), deleted_at: null, pinned_at: null, - updated_at: expect.any(Date), + updated_at: expect.any(Number), status: 'received', text: 'Quoted message', user: { id: 'user-id', name: 'User Name' }, @@ -687,8 +689,8 @@ describe('MessageComposer', () => { id: 'id', type: 'regular', status: 'delivered', - created_at: new Date(), - updated_at: new Date(), + created_at: nowNs(), + updated_at: nowNs(), deleted_at: null, pinned_at: null, }); @@ -844,8 +846,8 @@ describe('MessageComposer', () => { id: 'id', type: 'regular', status: 'delivered', - created_at: new Date(), - updated_at: new Date(), + created_at: nowNs(), + updated_at: nowNs(), deleted_at: null, pinned_at: null, }); @@ -928,8 +930,8 @@ describe('MessageComposer', () => { id: 'id', type: 'regular', status: 'delivered', - created_at: new Date(), - updated_at: new Date(), + created_at: nowNs(), + updated_at: nowNs(), deleted_at: null, pinned_at: null, }); @@ -1325,11 +1327,11 @@ describe('MessageComposer', () => { const baseline: LocalMessage = { id: 'edited-message-id', type: 'regular', - created_at: new Date(), + created_at: nowNs(), deleted_at: null, pinned_at: null, status: 'received', - updated_at: new Date(), + updated_at: nowNs(), }; messageComposer.setEditedMessage(baseline); @@ -1346,11 +1348,11 @@ describe('MessageComposer', () => { const baseline: LocalMessage = { id: 'edited-message-id', type: 'regular', - created_at: new Date(), + created_at: nowNs(), deleted_at: null, pinned_at: null, status: 'received', - updated_at: new Date(), + updated_at: nowNs(), }; messageComposer.setEditedMessage(baseline); @@ -1397,7 +1399,7 @@ describe('MessageComposer', () => { localMessage: { attachments: [], cid: 'messaging:test-channel-id', - created_at: expect.any(Date), + created_at: expect.any(Number), deleted_at: undefined, error: undefined, id: 'test-uuid', @@ -1413,7 +1415,7 @@ describe('MessageComposer', () => { status: 'sending', text: 'Test message', type: 'regular', - updated_at: expect.any(Date), + updated_at: expect.any(Number), user: { id: 'user-id', name: 'User Name', @@ -1626,9 +1628,7 @@ describe('MessageComposer', () => { // The echo carries the channel, like a real response — the paginator matches on `{ cid }`. cid: mockChannel.cid, id: composed!.localMessage.id, - updated_at: new Date( - composed!.localMessage.updated_at.getTime() + 100, - ).toISOString(), + updated_at: composed!.localMessage.updated_at + msToNs(100), }); await mockChannel.sendMessageWithLocalUpdate({ localMessage: composed!.localMessage, @@ -1645,11 +1645,11 @@ describe('MessageComposer', () => { messageComposer.textComposer.setText('Hello'); const composed = await messageComposer.compose(); const messageId = composed!.localMessage.id; - const composedUpdatedAt = composed!.localMessage.updated_at.getTime(); - const olderServerTime = new Date(composedUpdatedAt - 5000); + const composedUpdatedAt = composed!.localMessage.updated_at; + const olderServerTime = composedUpdatedAt - msToNs(5000); const serverMessage = generateMsg({ id: messageId, - updated_at: olderServerTime.toISOString(), + updated_at: olderServerTime, }); await mockChannel.sendMessageWithLocalUpdate({ localMessage: composed!.localMessage, @@ -1661,7 +1661,7 @@ describe('MessageComposer', () => { ...composed!.localMessage, status: 'received', text: 'from the websocket echo', - updated_at: new Date(composedUpdatedAt + 5000), + updated_at: convertDateToTimestamp(new Date(composedUpdatedAt + 5000)), }); return { message: serverMessage }; }, @@ -1677,8 +1677,8 @@ describe('MessageComposer', () => { messageComposer.textComposer.setText('Hello'); const composed = await messageComposer.compose(); const messageId = composed!.localMessage.id; - const composedUpdatedAt = composed!.localMessage.updated_at.getTime(); - const olderServerTime = new Date(composedUpdatedAt - 2000); + const composedUpdatedAt = composed!.localMessage.updated_at; + const olderServerTime = composedUpdatedAt - msToNs(2000); await mockChannel.sendMessageWithLocalUpdate({ localMessage: composed!.localMessage, message: composed!.message, @@ -1689,12 +1689,12 @@ describe('MessageComposer', () => { ...composed!.localMessage, status: 'received', text: 'from the websocket echo', - updated_at: new Date(composedUpdatedAt + 5000), + updated_at: convertDateToTimestamp(new Date(composedUpdatedAt + 5000)), }); return { message: generateMsg({ id: messageId, - updated_at: olderServerTime.toISOString(), + updated_at: olderServerTime, }), }; }, @@ -1710,8 +1710,8 @@ describe('MessageComposer', () => { messageComposer.textComposer.setText('Hello'); const composed = await messageComposer.compose(); const messageId = composed!.localMessage.id; - const composedUpdatedAt = composed!.localMessage.updated_at.getTime(); - const olderServerTime = new Date(composedUpdatedAt - 1000); + const composedUpdatedAt = composed!.localMessage.updated_at; + const olderServerTime = composedUpdatedAt - msToNs(1000); await mockChannel.sendMessageWithLocalUpdate({ localMessage: composed!.localMessage, message: composed!.message, @@ -1722,12 +1722,12 @@ describe('MessageComposer', () => { ...composed!.localMessage, status: 'received', text: 'from the websocket echo', - updated_at: new Date(composedUpdatedAt + 5000), + updated_at: convertDateToTimestamp(new Date(composedUpdatedAt + 5000)), }); return { message: generateMsg({ id: messageId, - updated_at: olderServerTime.toISOString(), + updated_at: olderServerTime, }), }; }, @@ -1746,12 +1746,10 @@ describe('MessageComposer', () => { const existingSending = { ...composed!.localMessage, status: 'sending' as const, - updated_at: new Date(Date.now() - 5000), + updated_at: convertDateToTimestamp(new Date(Date.now() - 5000)), }; mockChannel.messagePaginator.ingestItem(existingSending); - const serverUpdatedAt = new Date( - composed!.localMessage.updated_at.getTime() + 100, - ); + const serverUpdatedAt = composed!.localMessage.updated_at + msToNs(100); await mockChannel.sendMessageWithLocalUpdate({ localMessage: composed!.localMessage, message: composed!.message, @@ -1760,13 +1758,13 @@ describe('MessageComposer', () => { message: generateMsg({ cid: mockChannel.cid, id: messageId, - updated_at: serverUpdatedAt.toISOString(), + updated_at: serverUpdatedAt, }), }), }); const after = mockChannel.messagePaginator.getItem(messageId); expect(after?.status).toBe('received'); - expect(after?.updated_at.getTime()).toBe(serverUpdatedAt.getTime()); + expect(after?.updated_at).toBe(serverUpdatedAt); }); it('updates the message in state if it does not exist on the server and the send request failed', async () => { @@ -2338,7 +2336,7 @@ describe('MessageComposer', () => { attachments: [], mentioned_users: [], }, - created_at: new Date().toISOString(), + created_at: convertDateToTimestamp(new Date().toISOString()), }; it('should not create draft if edited message exists', async () => { const editedMessage = { diff --git a/test/unit/MessageComposer/middleware/messageComposer/attachments.test.ts b/test/unit/MessageComposer/middleware/messageComposer/attachments.test.ts index 50393ee288..f077011f4a 100644 --- a/test/unit/MessageComposer/middleware/messageComposer/attachments.test.ts +++ b/test/unit/MessageComposer/middleware/messageComposer/attachments.test.ts @@ -11,6 +11,7 @@ import { createDraftAttachmentsCompositionMiddleware } from '../../../../../src/ import { MessageDraftComposerMiddlewareValueState } from '../../../../../src/messageComposer/middleware/messageComposer/types'; import { MessageComposerMiddlewareState } from '../../../../../src/messageComposer/middleware/messageComposer/types'; import { MiddlewareStatus } from '../../../../../src/middleware'; +import { convertDateToTimestamp } from '../../../test-utils/time'; const setup = (initialState: MessageComposerMiddlewareState) => { return { @@ -142,7 +143,7 @@ describe('stream-io/message-composer-middleware/attachments', () => { }, localMessage: { attachments: [], - created_at: new Date(), + created_at: convertDateToTimestamp(new Date()), deleted_at: null, error: undefined, id: 'test-id', @@ -153,7 +154,7 @@ describe('stream-io/message-composer-middleware/attachments', () => { status: 'sending', text: '', type: 'regular', - updated_at: new Date(), + updated_at: convertDateToTimestamp(new Date()), }, sendOptions: {}, }), @@ -190,7 +191,7 @@ describe('stream-io/message-composer-middleware/attachments', () => { }, localMessage: { attachments: [attachment], - created_at: new Date(), + created_at: convertDateToTimestamp(new Date()), deleted_at: null, error: undefined, id: 'test-id', @@ -201,7 +202,7 @@ describe('stream-io/message-composer-middleware/attachments', () => { status: 'sending', text: '', type: 'regular', - updated_at: new Date(), + updated_at: convertDateToTimestamp(new Date()), }, sendOptions: {}, }), @@ -251,7 +252,7 @@ describe('stream-io/message-composer-middleware/attachments', () => { }, localMessage: { attachments, - created_at: new Date(), + created_at: convertDateToTimestamp(new Date()), deleted_at: null, error: undefined, id: 'test-id', @@ -262,7 +263,7 @@ describe('stream-io/message-composer-middleware/attachments', () => { status: 'sending', text: '', type: 'regular', - updated_at: new Date(), + updated_at: convertDateToTimestamp(new Date()), }, sendOptions: {}, }), @@ -306,7 +307,7 @@ describe('stream-io/message-composer-middleware/attachments', () => { }, localMessage: { attachments: [attachment], - created_at: new Date(), + created_at: convertDateToTimestamp(new Date()), deleted_at: null, error: undefined, id: 'test-id', @@ -317,7 +318,7 @@ describe('stream-io/message-composer-middleware/attachments', () => { status: 'sending', text: '', type: 'regular', - updated_at: new Date(), + updated_at: convertDateToTimestamp(new Date()), }, sendOptions: {}, }), @@ -354,7 +355,7 @@ describe('stream-io/message-composer-middleware/attachments', () => { }, localMessage: { attachments: [attachment], - created_at: new Date(), + created_at: convertDateToTimestamp(new Date()), deleted_at: null, error: undefined, id: 'test-id', @@ -365,7 +366,7 @@ describe('stream-io/message-composer-middleware/attachments', () => { status: 'sending', text: '', type: 'regular', - updated_at: new Date(), + updated_at: convertDateToTimestamp(new Date()), }, sendOptions: {}, }), diff --git a/test/unit/MessageComposer/middleware/messageComposer/cleanData.test.ts b/test/unit/MessageComposer/middleware/messageComposer/cleanData.test.ts index a612d58a87..ae1790054b 100644 --- a/test/unit/MessageComposer/middleware/messageComposer/cleanData.test.ts +++ b/test/unit/MessageComposer/middleware/messageComposer/cleanData.test.ts @@ -4,6 +4,7 @@ import { LocalMessage, MessageComposerMiddlewareState, } from '../../../../../src'; +import { convertDateToTimestamp } from '../../../test-utils/time'; const setupMiddlewareApi = (initialState: MessageComposerMiddlewareState) => { return { @@ -23,7 +24,7 @@ const stateSeed: MessageComposerMiddlewareState = { }, localMessage: { attachments: [], - created_at: new Date(), + created_at: convertDateToTimestamp(new Date()), deleted_at: null, error: undefined, id: 'test-id', @@ -34,7 +35,7 @@ const stateSeed: MessageComposerMiddlewareState = { status: 'sending', text: '', type: 'regular', - updated_at: new Date(), + updated_at: convertDateToTimestamp(new Date()), }, sendOptions: {}, }; diff --git a/test/unit/MessageComposer/middleware/messageComposer/commandInjection.test.ts b/test/unit/MessageComposer/middleware/messageComposer/commandInjection.test.ts index c5dfe4240a..8b36646ffc 100644 --- a/test/unit/MessageComposer/middleware/messageComposer/commandInjection.test.ts +++ b/test/unit/MessageComposer/middleware/messageComposer/commandInjection.test.ts @@ -10,6 +10,7 @@ import { MessageDraftComposerMiddlewareValueState, MiddlewareStatus, } from '../../../../../src'; +import { convertDateToTimestamp } from '../../../test-utils/time'; const setup = (initialState: MessageComposerMiddlewareState) => { return { @@ -146,7 +147,7 @@ describe('stream-io/message-composer-middleware/command-injection', () => { }, localMessage: { attachments: [], - created_at: new Date(), + created_at: convertDateToTimestamp(new Date()), deleted_at: null, error: undefined, id: 'test-id', @@ -157,7 +158,7 @@ describe('stream-io/message-composer-middleware/command-injection', () => { status: 'sending', text: 'haha', type: 'regular', - updated_at: new Date(), + updated_at: convertDateToTimestamp(new Date()), }, sendOptions: {}, }), @@ -182,7 +183,7 @@ describe('stream-io/message-composer-middleware/command-injection', () => { }, localMessage: { attachments: [], - created_at: new Date(), + created_at: convertDateToTimestamp(new Date()), deleted_at: null, error: undefined, id: 'test-id', @@ -193,7 +194,7 @@ describe('stream-io/message-composer-middleware/command-injection', () => { status: 'sending', text: 'haha', type: 'regular', - updated_at: new Date(), + updated_at: convertDateToTimestamp(new Date()), }, sendOptions: {}, }), diff --git a/test/unit/MessageComposer/middleware/messageComposer/compositionValidation.test.ts b/test/unit/MessageComposer/middleware/messageComposer/compositionValidation.test.ts index 9228a2ea29..dbecf11d16 100644 --- a/test/unit/MessageComposer/middleware/messageComposer/compositionValidation.test.ts +++ b/test/unit/MessageComposer/middleware/messageComposer/compositionValidation.test.ts @@ -19,6 +19,7 @@ import { LocalMessage, MessageResponse } from '../../../../../src'; import type { DeepPartial } from '../../../../../src/types.utility'; import { generateChannel } from '../../../test-utils/generateChannel'; import { stubServerConfig } from '../../../test-utils/stubServerConfig'; +import { convertDateToTimestamp } from '../../../test-utils/time'; const setupMiddleware = ( custom: { @@ -81,7 +82,7 @@ const setupCompositionState = (text = ''): MessageComposerMiddlewareState => ({ }, localMessage: { attachments: [], - created_at: new Date(), + created_at: convertDateToTimestamp(new Date()), deleted_at: null, error: undefined, id: 'test-id', @@ -92,7 +93,7 @@ const setupCompositionState = (text = ''): MessageComposerMiddlewareState => ({ status: 'sending', text, type: 'regular', - updated_at: new Date(), + updated_at: convertDateToTimestamp(new Date()), }, sendOptions: {}, }); @@ -124,7 +125,7 @@ describe('stream-io/message-composer-middleware/data-validation', () => { }, localMessage: { attachments: [], - created_at: new Date(), + created_at: convertDateToTimestamp(new Date()), deleted_at: null, error: undefined, id: 'test-id', @@ -135,7 +136,7 @@ describe('stream-io/message-composer-middleware/data-validation', () => { status: 'sending', text: '', type: 'regular', - updated_at: new Date(), + updated_at: convertDateToTimestamp(new Date()), }, sendOptions: {}, }), @@ -158,7 +159,7 @@ describe('stream-io/message-composer-middleware/data-validation', () => { }, localMessage: { attachments: [], - created_at: new Date(), + created_at: convertDateToTimestamp(new Date()), deleted_at: null, error: undefined, id: 'test-id', @@ -169,7 +170,7 @@ describe('stream-io/message-composer-middleware/data-validation', () => { status: 'sending', text: 'Hello world', type: 'regular', - updated_at: new Date(), + updated_at: convertDateToTimestamp(new Date()), }, sendOptions: {}, }), @@ -181,7 +182,7 @@ describe('stream-io/message-composer-middleware/data-validation', () => { it('should discard raw known commands while editing', async () => { const editedMessage: MessageResponse = { attachments: [], - created_at: new Date().toISOString(), + created_at: convertDateToTimestamp(new Date().toISOString()), id: 'edited-message-id', mentioned_users: [], parent_id: undefined, @@ -190,7 +191,7 @@ describe('stream-io/message-composer-middleware/data-validation', () => { status: 'received', text: 'original text', type: 'regular', - updated_at: new Date().toISOString(), + updated_at: convertDateToTimestamp(new Date().toISOString()), }; const { messageComposer, validationMiddleware } = setupMiddleware({ editedMessage, @@ -484,7 +485,7 @@ describe('stream-io/message-composer-middleware/data-validation', () => { }, localMessage: { attachments: [attachment], - created_at: new Date(), + created_at: convertDateToTimestamp(new Date()), deleted_at: null, error: undefined, id: 'test-id', @@ -495,7 +496,7 @@ describe('stream-io/message-composer-middleware/data-validation', () => { status: 'sending', text: '', type: 'regular', - updated_at: new Date(), + updated_at: convertDateToTimestamp(new Date()), }, sendOptions: {}, }), @@ -523,7 +524,7 @@ describe('stream-io/message-composer-middleware/data-validation', () => { }, localMessage: { attachments: [], - created_at: new Date(), + created_at: convertDateToTimestamp(new Date()), deleted_at: null, error: undefined, id: 'test-id', @@ -534,7 +535,7 @@ describe('stream-io/message-composer-middleware/data-validation', () => { status: 'sending', text: 'Hello @user1', type: 'regular', - updated_at: new Date(), + updated_at: convertDateToTimestamp(new Date()), }, sendOptions: {}, }, @@ -557,7 +558,7 @@ describe('stream-io/message-composer-middleware/data-validation', () => { }, localMessage: { attachments: [], - created_at: new Date(), + created_at: convertDateToTimestamp(new Date()), deleted_at: null, error: undefined, id: 'test-id', @@ -569,7 +570,7 @@ describe('stream-io/message-composer-middleware/data-validation', () => { status: 'sending', text: '', type: 'regular', - updated_at: new Date(), + updated_at: convertDateToTimestamp(new Date()), }, sendOptions: {}, }, @@ -582,7 +583,7 @@ describe('stream-io/message-composer-middleware/data-validation', () => { it('should not discard composition for edited message without any local change', async () => { const editedMessage: MessageResponse = { attachments: [], - created_at: new Date().toISOString(), + created_at: convertDateToTimestamp(new Date().toISOString()), id: 'test-id', mentioned_users: [], parent_id: undefined, @@ -591,7 +592,7 @@ describe('stream-io/message-composer-middleware/data-validation', () => { status: 'sending', text: 'Hello world', type: 'regular', - updated_at: new Date().toISOString(), + updated_at: convertDateToTimestamp(new Date().toISOString()), }; const { messageComposer, validationMiddleware } = setupMiddleware({ editedMessage }); @@ -607,10 +608,10 @@ describe('stream-io/message-composer-middleware/data-validation', () => { }, localMessage: { ...editedMessage, - created_at: new Date(editedMessage.created_at as string), + created_at: editedMessage.created_at, deleted_at: null, pinned_at: null, - updated_at: new Date(editedMessage.updated_at as string), + updated_at: editedMessage.updated_at, } as LocalMessage, sendOptions: {}, }), @@ -623,7 +624,7 @@ describe('stream-io/message-composer-middleware/data-validation', () => { const { messageComposer, validationMiddleware } = setupMiddleware(); const localMessage: LocalMessage = { attachments: [], - created_at: new Date(), + created_at: convertDateToTimestamp(new Date()), deleted_at: null, error: undefined, id: 'test-id', @@ -634,7 +635,7 @@ describe('stream-io/message-composer-middleware/data-validation', () => { status: 'sending', text: 'Hello world', type: 'regular', - updated_at: new Date(), + updated_at: convertDateToTimestamp(new Date()), }; messageComposer.editedMessage = undefined; vi.spyOn(messageComposer, 'lastChangeOriginIsLocal', 'get').mockReturnValue(false); diff --git a/test/unit/MessageComposer/middleware/messageComposer/customData.test.ts b/test/unit/MessageComposer/middleware/messageComposer/customData.test.ts index b82a958513..5f34f525f3 100644 --- a/test/unit/MessageComposer/middleware/messageComposer/customData.test.ts +++ b/test/unit/MessageComposer/middleware/messageComposer/customData.test.ts @@ -11,6 +11,7 @@ import type { MessageDraftComposerMiddlewareValueState, } from '../../../../../src/messageComposer/middleware/messageComposer/types'; import { MiddlewareStatus } from '../../../../../src'; +import { convertDateToTimestamp } from '../../../test-utils/time'; const setup = (initialState: MessageComposerMiddlewareState) => { return { @@ -65,8 +66,8 @@ describe('Custom Data Middleware', () => { text: '', type: 'regular', status: 'sending', - created_at: new Date(), - updated_at: new Date(), + created_at: convertDateToTimestamp(new Date()), + updated_at: convertDateToTimestamp(new Date()), attachments: [], mentioned_users: [], reaction_groups: null, @@ -91,8 +92,8 @@ describe('Custom Data Middleware', () => { text: '', type: 'regular', status: 'sending', - created_at: new Date(), - updated_at: new Date(), + created_at: convertDateToTimestamp(new Date()), + updated_at: convertDateToTimestamp(new Date()), attachments: [], mentioned_users: [], reaction_groups: null, diff --git a/test/unit/MessageComposer/middleware/messageComposer/linkPreviews.test.ts b/test/unit/MessageComposer/middleware/messageComposer/linkPreviews.test.ts index c5747ba5df..b8246b5a7b 100644 --- a/test/unit/MessageComposer/middleware/messageComposer/linkPreviews.test.ts +++ b/test/unit/MessageComposer/middleware/messageComposer/linkPreviews.test.ts @@ -20,6 +20,7 @@ import { } from '../../../../../src'; import { getClientWithUser } from '../../../test-utils/getClient'; import { stubServerConfig } from '../../../test-utils/stubServerConfig'; +import { convertDateToTimestamp } from '../../../test-utils/time'; const enrichURLReturnValue = { asset_url: 'https://example.com/image.jpg', @@ -105,7 +106,7 @@ describe('stream-io/message-composer-middleware/link-previews', () => { }, localMessage: { attachments: [], - created_at: new Date(), + created_at: convertDateToTimestamp(new Date()), deleted_at: null, error: undefined, id: 'test-id', @@ -116,7 +117,7 @@ describe('stream-io/message-composer-middleware/link-previews', () => { status: 'sending', text: '', type: 'regular', - updated_at: new Date(), + updated_at: convertDateToTimestamp(new Date()), }, sendOptions: {}, }), @@ -159,7 +160,7 @@ describe('stream-io/message-composer-middleware/link-previews', () => { }, localMessage: { attachments: [], - created_at: new Date(), + created_at: convertDateToTimestamp(new Date()), deleted_at: null, error: undefined, id: 'test-id', @@ -170,7 +171,7 @@ describe('stream-io/message-composer-middleware/link-previews', () => { status: 'sending', text: 'https://example.com', type: 'regular', - updated_at: new Date(), + updated_at: convertDateToTimestamp(new Date()), }, sendOptions: {}, }), @@ -216,7 +217,7 @@ describe('stream-io/message-composer-middleware/link-previews', () => { }, localMessage: { attachments: [], - created_at: new Date(), + created_at: convertDateToTimestamp(new Date()), deleted_at: null, error: undefined, id: 'test-id', @@ -227,7 +228,7 @@ describe('stream-io/message-composer-middleware/link-previews', () => { status: 'sending', text: 'https://example.com', type: 'regular', - updated_at: new Date(), + updated_at: convertDateToTimestamp(new Date()), }, sendOptions: {}, }), @@ -271,7 +272,7 @@ describe('stream-io/message-composer-middleware/link-previews', () => { }, localMessage: { attachments: [], - created_at: new Date(), + created_at: convertDateToTimestamp(new Date()), deleted_at: null, error: undefined, id: 'test-id', @@ -282,7 +283,7 @@ describe('stream-io/message-composer-middleware/link-previews', () => { status: 'sending', text: 'https://example.com', type: 'regular', - updated_at: new Date(), + updated_at: convertDateToTimestamp(new Date()), }, sendOptions: {}, }), @@ -325,7 +326,7 @@ describe('stream-io/message-composer-middleware/link-previews', () => { }, localMessage: { attachments: [], - created_at: new Date(), + created_at: convertDateToTimestamp(new Date()), deleted_at: null, error: undefined, id: 'test-id', @@ -336,7 +337,7 @@ describe('stream-io/message-composer-middleware/link-previews', () => { status: 'sending', text: 'https://example.com', type: 'regular', - updated_at: new Date(), + updated_at: convertDateToTimestamp(new Date()), }, sendOptions: {}, }), @@ -411,7 +412,7 @@ describe('stream-io/message-composer-middleware/link-previews', () => { }, localMessage: { attachments: [], - created_at: new Date(), + created_at: convertDateToTimestamp(new Date()), deleted_at: null, error: undefined, id: 'test-id', @@ -422,7 +423,7 @@ describe('stream-io/message-composer-middleware/link-previews', () => { status: 'sending', text: 'https://example1.com https://example2.com https://example3.com', type: 'regular', - updated_at: new Date(), + updated_at: convertDateToTimestamp(new Date()), }, sendOptions: {}, }), @@ -484,7 +485,7 @@ describe('stream-io/message-composer-middleware/link-previews', () => { }, localMessage: { attachments: [], - created_at: new Date(), + created_at: convertDateToTimestamp(new Date()), deleted_at: null, error: undefined, id: 'test-id', @@ -495,7 +496,7 @@ describe('stream-io/message-composer-middleware/link-previews', () => { status: 'sending', text: 'https://example1.com https://example2.com', type: 'regular', - updated_at: new Date(), + updated_at: convertDateToTimestamp(new Date()), }, sendOptions: {}, }), @@ -550,7 +551,7 @@ describe('stream-io/message-composer-middleware/link-previews', () => { image_url: 'https://example.com/image.jpg', }, ], - created_at: new Date(), + created_at: convertDateToTimestamp(new Date()), deleted_at: null, error: undefined, id: 'test-id', @@ -561,7 +562,7 @@ describe('stream-io/message-composer-middleware/link-previews', () => { status: 'sending', text: 'https://example.com', type: 'regular', - updated_at: new Date(), + updated_at: convertDateToTimestamp(new Date()), }, sendOptions: {}, }), diff --git a/test/unit/MessageComposer/middleware/messageComposer/messageComposerState.test.ts b/test/unit/MessageComposer/middleware/messageComposer/messageComposerState.test.ts index 97cdd28d06..3163107e65 100644 --- a/test/unit/MessageComposer/middleware/messageComposer/messageComposerState.test.ts +++ b/test/unit/MessageComposer/middleware/messageComposer/messageComposerState.test.ts @@ -8,6 +8,7 @@ import { createDraftMessageComposerStateCompositionMiddleware } from '../../../. import { MessageComposerMiddlewareState } from '../../../../../src/messageComposer/middleware/messageComposer/types'; import { MiddlewareStatus } from '../../../../../src/middleware'; import { MessageDraftComposerMiddlewareValueState } from '../../../../../src/messageComposer/middleware/messageComposer/types'; +import { convertDateToTimestamp } from '../../../test-utils/time'; const setupHandlerParams = (initialState: MessageComposerMiddlewareState) => { return { @@ -77,7 +78,7 @@ describe('stream-io/message-composer-middleware/own-state', () => { }, localMessage: { attachments: [], - created_at: new Date(), + created_at: convertDateToTimestamp(new Date()), deleted_at: null, error: undefined, id: 'test-id', @@ -88,7 +89,7 @@ describe('stream-io/message-composer-middleware/own-state', () => { status: 'sending', text: '', type: 'regular', - updated_at: new Date(), + updated_at: convertDateToTimestamp(new Date()), }, sendOptions: {}, }), @@ -106,7 +107,7 @@ describe('stream-io/message-composer-middleware/own-state', () => { const quotedMessage: LocalMessage = { id: 'quoted-message-id', attachments: [], - created_at: new Date(), + created_at: convertDateToTimestamp(new Date()), deleted_at: null, error: undefined, mentioned_users: [], @@ -116,7 +117,7 @@ describe('stream-io/message-composer-middleware/own-state', () => { status: 'sending', text: 'This is a quoted message', type: 'regular', - updated_at: new Date(), + updated_at: convertDateToTimestamp(new Date()), }; // Mock the composer properties @@ -132,7 +133,7 @@ describe('stream-io/message-composer-middleware/own-state', () => { }, localMessage: { attachments: [], - created_at: new Date(), + created_at: convertDateToTimestamp(new Date()), deleted_at: null, error: undefined, id: 'test-id', @@ -143,7 +144,7 @@ describe('stream-io/message-composer-middleware/own-state', () => { status: 'sending', text: '', type: 'regular', - updated_at: new Date(), + updated_at: convertDateToTimestamp(new Date()), }, sendOptions: {}, }), @@ -168,7 +169,7 @@ describe('stream-io/message-composer-middleware/own-state', () => { }, localMessage: { attachments: [], - created_at: new Date(), + created_at: convertDateToTimestamp(new Date()), deleted_at: null, error: undefined, id: 'test-id', @@ -179,7 +180,7 @@ describe('stream-io/message-composer-middleware/own-state', () => { status: 'sending', text: '', type: 'regular', - updated_at: new Date(), + updated_at: convertDateToTimestamp(new Date()), }, sendOptions: {}, }), @@ -194,7 +195,7 @@ describe('stream-io/message-composer-middleware/own-state', () => { const quotedMessage: LocalMessage = { id: 'quoted-message-id', attachments: [], - created_at: new Date(), + created_at: convertDateToTimestamp(new Date()), deleted_at: null, error: undefined, mentioned_users: [], @@ -204,7 +205,7 @@ describe('stream-io/message-composer-middleware/own-state', () => { status: 'sending', text: 'This is a quoted message', type: 'regular', - updated_at: new Date(), + updated_at: convertDateToTimestamp(new Date()), }; // Mock the composer properties @@ -220,7 +221,7 @@ describe('stream-io/message-composer-middleware/own-state', () => { }, localMessage: { attachments: [], - created_at: new Date(), + created_at: convertDateToTimestamp(new Date()), deleted_at: null, error: undefined, id: 'test-id', @@ -231,7 +232,7 @@ describe('stream-io/message-composer-middleware/own-state', () => { status: 'sending', text: '', type: 'regular', - updated_at: new Date(), + updated_at: convertDateToTimestamp(new Date()), }, sendOptions: {}, }), @@ -249,7 +250,7 @@ describe('stream-io/message-composer-middleware/own-state', () => { const quotedMessage: LocalMessage = { id: 'quoted-message-id', attachments: [], - created_at: new Date(), + created_at: convertDateToTimestamp(new Date()), deleted_at: null, error: undefined, mentioned_users: [], @@ -259,7 +260,7 @@ describe('stream-io/message-composer-middleware/own-state', () => { status: 'sending', text: 'This is a quoted message', type: 'regular', - updated_at: new Date(), + updated_at: convertDateToTimestamp(new Date()), }; // Mock the composer properties @@ -276,7 +277,7 @@ describe('stream-io/message-composer-middleware/own-state', () => { }, localMessage: { attachments: [], - created_at: new Date(), + created_at: convertDateToTimestamp(new Date()), deleted_at: null, error: undefined, id: 'test-id', @@ -287,7 +288,7 @@ describe('stream-io/message-composer-middleware/own-state', () => { status: 'sending', text: 'Original local message text', type: 'regular', - updated_at: new Date(), + updated_at: convertDateToTimestamp(new Date()), }, sendOptions: {}, }), @@ -317,7 +318,7 @@ describe('stream-io/message-composer-middleware/own-state', () => { }, localMessage: { attachments: [], - created_at: new Date(), + created_at: convertDateToTimestamp(new Date()), deleted_at: null, error: undefined, id: 'test-id', @@ -328,7 +329,7 @@ describe('stream-io/message-composer-middleware/own-state', () => { status: 'sending', text: '', type: 'regular', - updated_at: new Date(), + updated_at: convertDateToTimestamp(new Date()), }, sendOptions: {}, }), @@ -349,7 +350,7 @@ describe('stream-io/message-composer-middleware/own-state', () => { }, localMessage: { attachments: [], - created_at: new Date(), + created_at: convertDateToTimestamp(new Date()), deleted_at: null, error: undefined, id: 'test-id', @@ -360,7 +361,7 @@ describe('stream-io/message-composer-middleware/own-state', () => { status: 'sending', text: '', type: 'regular', - updated_at: new Date(), + updated_at: convertDateToTimestamp(new Date()), }, sendOptions: {}, }), @@ -423,11 +424,11 @@ describe('stream-io/message-composer-middleware/draft-own-state', () => { const quotedMessage = { id: 'quoted-message-id', type: 'regular' as const, - created_at: new Date(), + created_at: convertDateToTimestamp(new Date()), deleted_at: null, pinned_at: null, status: 'received', - updated_at: new Date(), + updated_at: convertDateToTimestamp(new Date()), }; vi.spyOn(messageComposer, 'quotedMessage', 'get').mockReturnValue(quotedMessage); @@ -465,11 +466,11 @@ describe('stream-io/message-composer-middleware/draft-own-state', () => { const quotedMessage = { id: 'quoted-message-id', type: 'regular' as const, - created_at: new Date(), + created_at: convertDateToTimestamp(new Date()), deleted_at: null, pinned_at: null, status: 'received', - updated_at: new Date(), + updated_at: convertDateToTimestamp(new Date()), }; vi.spyOn(messageComposer, 'quotedMessage', 'get').mockReturnValue(quotedMessage); @@ -492,11 +493,11 @@ describe('stream-io/message-composer-middleware/draft-own-state', () => { const quotedMessage = { id: 'quoted-message-id', type: 'regular' as const, - created_at: new Date(), + created_at: convertDateToTimestamp(new Date()), deleted_at: null, pinned_at: null, status: 'received', - updated_at: new Date(), + updated_at: convertDateToTimestamp(new Date()), }; vi.spyOn(messageComposer, 'quotedMessage', 'get').mockReturnValue(quotedMessage); diff --git a/test/unit/MessageComposer/middleware/messageComposer/pollOnly.test.ts b/test/unit/MessageComposer/middleware/messageComposer/pollOnly.test.ts index 9f5df46aa8..28256a62c5 100644 --- a/test/unit/MessageComposer/middleware/messageComposer/pollOnly.test.ts +++ b/test/unit/MessageComposer/middleware/messageComposer/pollOnly.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from 'vitest'; import { MessageComposerMiddlewareState } from '../../../../../src'; import { createPollOnlyCompositionMiddleware } from '../../../../../src/messageComposer/middleware/messageComposer/pollOnly'; +import { convertDateToTimestamp } from '../../../test-utils/time'; const setupMiddlewareApi = (initialState: MessageComposerMiddlewareState) => { return { @@ -20,7 +21,7 @@ const stateSeed: MessageComposerMiddlewareState = { }, localMessage: { attachments: [], - created_at: new Date(), + created_at: convertDateToTimestamp(new Date()), deleted_at: null, error: undefined, id: 'test-id', @@ -31,7 +32,7 @@ const stateSeed: MessageComposerMiddlewareState = { status: 'sending', text: '', type: 'regular', - updated_at: new Date(), + updated_at: convertDateToTimestamp(new Date()), }, sendOptions: {}, }; diff --git a/test/unit/MessageComposer/middleware/messageComposer/sharedLocation.test.ts b/test/unit/MessageComposer/middleware/messageComposer/sharedLocation.test.ts index 90f3abbc09..80d41dbf23 100644 --- a/test/unit/MessageComposer/middleware/messageComposer/sharedLocation.test.ts +++ b/test/unit/MessageComposer/middleware/messageComposer/sharedLocation.test.ts @@ -8,6 +8,7 @@ import { MiddlewareStatus, StreamChat, } from '../../../../../src'; +import { msToNs } from '../../../../../src/utils/time'; const user = { id: 'user-id' }; @@ -65,10 +66,10 @@ describe('stream-io/message-composer-middleware/shared-location', () => { localMessage: { shared_location: { channel_cid: messageComposer.channel.cid, - created_at: expect.any(Date), + created_at: expect.any(Number), created_by_device_id: messageComposer.locationComposer.deviceId, message_id: messageComposer.id, - updated_at: expect.any(Date), + updated_at: expect.any(Number), user_id: user.id, ...coords, }, @@ -85,6 +86,70 @@ describe('stream-io/message-composer-middleware/shared-location', () => { }); }); + it('crosses end_at to nanoseconds for the optimistic message, keeping the request a Date', async () => { + // Only a live location produces `end_at`, which is why the static-coords tests above miss it. + const { messageComposer } = setup(); + const middleware = createSharedLocationCompositionMiddleware(messageComposer); + const durationMs = 60 * 60 * 1000; + messageComposer.locationComposer.setData({ latitude: 1, longitude: 1, durationMs }); + + const result = await middleware.handlers.compose(setupMiddlewareHandlerParams()); + const localEndAt = result.state.localMessage.shared_location?.end_at; + const requestEndAt = result.state.message.shared_location?.end_at; + + expect(typeof localEndAt).toBe('number'); + expect(requestEndAt).toBeInstanceOf(Date); + expect(localEndAt).toBe((requestEndAt as Date).getTime() * 1e6); + // An hour out must not read as already elapsed. + expect(localEndAt as number).toBeGreaterThan(Date.now() * 1e6); + }); + + it('sends only request fields when the location came off the edited message', async () => { + // The full chain, not just this middleware: `cleanData` spreads `state.message` OVER the + // narrowed payload `toUpdatedMessagePayload` builds, so whatever this middleware puts there is + // what goes on the wire. Hydrating composer state from a response used to carry the response's + // own `channel_cid` / `user_id` / numeric `created_at` / `updated_at` straight through, and to + // drop `end_at` — a field `SharedLocation` declares — turning a live location static on edit. + const endAtIso = '2099-12-31T23:59:59.535Z'; + const editedMessage = { + created_at: msToNs(Date.parse('2026-01-01T00:00:00.000Z')), + updated_at: msToNs(Date.parse('2026-01-01T00:00:00.000Z')), + id: 'edited-message', + status: 'received', + text: 'shared', + type: 'regular', + shared_location: { + channel_cid: 'channelType:channelId', + created_at: msToNs(Date.parse('2026-01-01T00:00:00.000Z')), + created_by_device_id: 'device', + end_at: msToNs(Date.parse(endAtIso)), + latitude: 1, + longitude: 2, + message_id: 'edited-message', + updated_at: msToNs(Date.parse('2026-01-01T00:00:00.000Z')), + user_id: user.id, + }, + } as LocalMessage; + const { messageComposer } = setup({ composition: editedMessage }); + + const composition = await messageComposer.compose(); + + // Serialized, so a regression reads as the nanosecond number it would actually send. + expect( + JSON.parse(JSON.stringify(composition?.message.shared_location)), + ).toStrictEqual({ + created_by_device_id: 'device', + end_at: endAtIso, + latitude: 1, + longitude: 2, + message_id: 'edited-message', + }); + // The optimistic copy is response-shaped, so its `end_at` is back in the wire unit. + expect(composition?.localMessage.shared_location?.end_at).toBe( + msToNs(Date.parse(endAtIso)), + ); + }); + it('does not inject shared_location to localMessage and message payloads if none is set', async () => { const { messageComposer } = setup(); const middleware = createSharedLocationCompositionMiddleware(messageComposer); diff --git a/test/unit/MessageComposer/middleware/messageComposer/textComposer.test.ts b/test/unit/MessageComposer/middleware/messageComposer/textComposer.test.ts index d68a18e282..7ee51cd167 100644 --- a/test/unit/MessageComposer/middleware/messageComposer/textComposer.test.ts +++ b/test/unit/MessageComposer/middleware/messageComposer/textComposer.test.ts @@ -9,6 +9,7 @@ import { MessageDraftComposerMiddlewareValueState, MiddlewareStatus, } from '../../../../../src'; +import { convertDateToTimestamp } from '../../../test-utils/time'; const setup = (initialState: MessageComposerMiddlewareState) => { return { @@ -140,7 +141,7 @@ describe('stream-io/message-composer-middleware/text-composition', () => { }, localMessage: { attachments: [], - created_at: new Date(), + created_at: convertDateToTimestamp(new Date()), deleted_at: null, error: undefined, id: 'test-id', @@ -151,7 +152,7 @@ describe('stream-io/message-composer-middleware/text-composition', () => { status: 'sending', text: '', type: 'regular', - updated_at: new Date(), + updated_at: convertDateToTimestamp(new Date()), }, sendOptions: {}, }), @@ -173,7 +174,7 @@ describe('stream-io/message-composer-middleware/text-composition', () => { }, localMessage: { attachments: [], - created_at: new Date(), + created_at: convertDateToTimestamp(new Date()), deleted_at: null, error: undefined, id: 'test-id', @@ -184,7 +185,7 @@ describe('stream-io/message-composer-middleware/text-composition', () => { status: 'sending', text: '', type: 'regular', - updated_at: new Date(), + updated_at: convertDateToTimestamp(new Date()), }, sendOptions: {}, }), @@ -214,7 +215,7 @@ describe('stream-io/message-composer-middleware/text-composition', () => { }, localMessage: { attachments: [], - created_at: new Date(), + created_at: convertDateToTimestamp(new Date()), deleted_at: null, error: undefined, id: 'test-id', @@ -225,7 +226,7 @@ describe('stream-io/message-composer-middleware/text-composition', () => { status: 'sending', text: '', type: 'regular', - updated_at: new Date(), + updated_at: convertDateToTimestamp(new Date()), }, sendOptions: {}, }), @@ -258,7 +259,7 @@ describe('stream-io/message-composer-middleware/text-composition', () => { }, localMessage: { attachments: [], - created_at: new Date(), + created_at: convertDateToTimestamp(new Date()), deleted_at: null, error: undefined, id: 'test-id', @@ -269,7 +270,7 @@ describe('stream-io/message-composer-middleware/text-composition', () => { status: 'sending', text: '', type: 'regular', - updated_at: new Date(), + updated_at: convertDateToTimestamp(new Date()), }, sendOptions: {}, }), @@ -306,7 +307,7 @@ describe('stream-io/message-composer-middleware/text-composition', () => { }, localMessage: { attachments: [], - created_at: new Date(), + created_at: convertDateToTimestamp(new Date()), deleted_at: null, error: undefined, id: 'test-id', @@ -317,7 +318,7 @@ describe('stream-io/message-composer-middleware/text-composition', () => { status: 'sending', text: '', type: 'regular', - updated_at: new Date(), + updated_at: convertDateToTimestamp(new Date()), }, sendOptions: {}, }), @@ -357,7 +358,7 @@ describe('stream-io/message-composer-middleware/text-composition', () => { }, localMessage: { attachments: [], - created_at: new Date(), + created_at: convertDateToTimestamp(new Date()), deleted_at: null, error: undefined, id: 'test-id', @@ -368,7 +369,7 @@ describe('stream-io/message-composer-middleware/text-composition', () => { status: 'sending', text: '', type: 'regular', - updated_at: new Date(), + updated_at: convertDateToTimestamp(new Date()), }, sendOptions: {}, }), @@ -393,7 +394,7 @@ describe('stream-io/message-composer-middleware/text-composition', () => { }, localMessage: { attachments: [], - created_at: new Date(), + created_at: convertDateToTimestamp(new Date()), deleted_at: null, error: undefined, id: 'test-id', @@ -404,7 +405,7 @@ describe('stream-io/message-composer-middleware/text-composition', () => { status: 'sending', text: '', type: 'regular', - updated_at: new Date(), + updated_at: convertDateToTimestamp(new Date()), }, sendOptions: {}, }), @@ -427,7 +428,7 @@ describe('stream-io/message-composer-middleware/text-composition', () => { }, localMessage: { attachments: [], - created_at: new Date(), + created_at: convertDateToTimestamp(new Date()), deleted_at: null, error: undefined, id: 'test-id', @@ -438,7 +439,7 @@ describe('stream-io/message-composer-middleware/text-composition', () => { status: 'sending', text: '', type: 'regular', - updated_at: new Date(), + updated_at: convertDateToTimestamp(new Date()), }, sendOptions: {}, }), diff --git a/test/unit/MessageComposer/middleware/messageComposer/userDataInjection.test.ts b/test/unit/MessageComposer/middleware/messageComposer/userDataInjection.test.ts index d9103d90e9..52e5bcf2ad 100644 --- a/test/unit/MessageComposer/middleware/messageComposer/userDataInjection.test.ts +++ b/test/unit/MessageComposer/middleware/messageComposer/userDataInjection.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from 'vitest'; import { MessageComposerMiddlewareState } from '../../../../../src'; import { createUserDataInjectionMiddleware } from '../../../../../src/messageComposer/middleware/messageComposer/userDataInjection'; +import { convertDateToTimestamp } from '../../../test-utils/time'; const setupMiddlewareApi = (initialState: MessageComposerMiddlewareState) => { return { @@ -20,7 +21,7 @@ const stateSeed: MessageComposerMiddlewareState = { }, localMessage: { attachments: [], - created_at: new Date(), + created_at: convertDateToTimestamp(new Date()), deleted_at: null, error: undefined, id: 'test-id', @@ -31,7 +32,7 @@ const stateSeed: MessageComposerMiddlewareState = { status: 'sending', text: '', type: 'regular', - updated_at: new Date(), + updated_at: convertDateToTimestamp(new Date()), }, sendOptions: {}, }; diff --git a/test/unit/MessageComposer/middleware/textComposer/MentionsSearchSource.test.ts b/test/unit/MessageComposer/middleware/textComposer/MentionsSearchSource.test.ts index 749e2792c6..08031324fe 100644 --- a/test/unit/MessageComposer/middleware/textComposer/MentionsSearchSource.test.ts +++ b/test/unit/MessageComposer/middleware/textComposer/MentionsSearchSource.test.ts @@ -18,6 +18,7 @@ import type { UserResponse, } from '../../../../../src/types'; import type { MentionSuggestion } from '../../../../../src/messageComposer/middleware/textComposer/types'; +import { convertDateToTimestamp } from '../../../test-utils/time'; describe('calculateLevenshtein', () => { it('should return length of first string if second is empty', () => { @@ -96,19 +97,31 @@ describe('MentionsSearchSource', () => { mockUserGroups = [ { - created_at: '2026-05-08T12:00:00.000Z', + created_at: convertDateToTimestamp('2026-05-08T12:00:00.000Z'), id: 'backend-team', members: [ - { created_at: '2026-05-08T12:00:00.000Z', id: 'member-1', is_admin: false }, + { + created_at: convertDateToTimestamp('2026-05-08T12:00:00.000Z'), + id: 'member-1', + is_admin: false, + }, ], name: 'Backend Team', }, { - created_at: '2026-05-08T12:01:00.000Z', + created_at: convertDateToTimestamp('2026-05-08T12:01:00.000Z'), id: 'admins-group', members: [ - { created_at: '2026-05-08T12:00:00.000Z', id: 'member-1', is_admin: false }, - { created_at: '2026-05-08T12:01:00.000Z', id: 'member-2', is_admin: false }, + { + created_at: convertDateToTimestamp('2026-05-08T12:00:00.000Z'), + id: 'member-1', + is_admin: false, + }, + { + created_at: convertDateToTimestamp('2026-05-08T12:01:00.000Z'), + id: 'member-2', + is_admin: false, + }, ], name: 'Admins', }, @@ -505,8 +518,8 @@ describe('MentionsSearchSource', () => { const mute: UserMuteResponse = { target: { id: 'user1' }, user: { id: 'currentUser' }, - created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), + created_at: convertDateToTimestamp(new Date().toISOString()), + updated_at: convertDateToTimestamp(new Date().toISOString()), }; client.mutedUsers = [mute]; @@ -528,8 +541,8 @@ describe('MentionsSearchSource', () => { const mute: UserMuteResponse = { target: { id: 'user1' }, user: { id: 'currentUser' }, - created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), + created_at: convertDateToTimestamp(new Date().toISOString()), + updated_at: convertDateToTimestamp(new Date().toISOString()), }; client.mutedUsers = [mute]; diff --git a/test/unit/MessageComposer/textComposer.test.ts b/test/unit/MessageComposer/textComposer.test.ts index 4304b103f3..2351c6b620 100644 --- a/test/unit/MessageComposer/textComposer.test.ts +++ b/test/unit/MessageComposer/textComposer.test.ts @@ -14,6 +14,7 @@ import { LinkPreviewStatus } from '../../../src/messageComposer/linkPreviewsMana import type { LocalAttachment } from '../../../src/messageComposer/types'; import { getClientWithUser } from '../test-utils/getClient'; import { stubServerConfig } from '../test-utils/stubServerConfig'; +import { convertDateToTimestamp } from '../test-utils/time'; const textComposerMiddlewareExecuteOutput = { state: { @@ -140,11 +141,11 @@ describe('TextComposer', () => { type: 'regular', text: 'Hello world', mentioned_users: [{ id: 'user-1' }, { id: 'user-2', name: 'User 2' }], - created_at: new Date(), + created_at: convertDateToTimestamp(new Date()), deleted_at: null, pinned_at: null, status: 'pending', - updated_at: new Date(), + updated_at: convertDateToTimestamp(new Date()), }; const { @@ -170,10 +171,10 @@ describe('TextComposer', () => { mentioned_channel: true, mentioned_groups: [ { - created_at: '2026-05-28T00:00:00.000Z', + created_at: convertDateToTimestamp('2026-05-28T00:00:00.000Z'), id: 'backend-team', name: 'Backend Team', - updated_at: '2026-05-28T00:00:00.000Z', + updated_at: convertDateToTimestamp('2026-05-28T00:00:00.000Z'), }, ], mentioned_here: true, @@ -198,11 +199,11 @@ describe('TextComposer', () => { id: 'test-message', type: 'regular', text: 'Hello world', - created_at: new Date(), + created_at: convertDateToTimestamp(new Date()), deleted_at: null, pinned_at: null, status: 'pending', - updated_at: new Date(), + updated_at: convertDateToTimestamp(new Date()), }; const { @@ -264,11 +265,11 @@ describe('TextComposer', () => { type: 'regular', text: 'Hello world', mentioned_users: [{ id: 'user-1' }, { id: 'user-2', name: 'User 2' }], - created_at: new Date(), + created_at: convertDateToTimestamp(new Date()), deleted_at: null, pinned_at: null, status: 'pending', - updated_at: new Date(), + updated_at: convertDateToTimestamp(new Date()), }; const initialState = { command: null, @@ -291,11 +292,11 @@ describe('TextComposer', () => { type: 'regular', text: 'Hello world', mentioned_users: [{ id: 'user-1' }], - created_at: new Date(), + created_at: convertDateToTimestamp(new Date()), deleted_at: null, pinned_at: null, status: 'pending', - updated_at: new Date(), + updated_at: convertDateToTimestamp(new Date()), }; const { messageComposer: { textComposer }, diff --git a/test/unit/channel.test.js b/test/unit/channel.test.js index 752e9844fa..bcf7d7ae79 100644 --- a/test/unit/channel.test.js +++ b/test/unit/channel.test.js @@ -14,6 +14,8 @@ import { MockOfflineDB } from './offline-support/MockOfflineDB'; import { formatMessage, generateUUIDv4 as uuidv4 } from '../../src/utils'; import { describe, beforeEach, afterEach, it, expect, vi } from 'vitest'; +import { convertDateToTimestamp } from './test-utils/time'; +import { msToNs } from '../../src/utils/time'; // Seed the channel's messagePaginator "latest" (head) window from raw generated messages. // The unread/last-message readers now source from `messagePaginator.headItems`/`headmostItem`, @@ -33,7 +35,7 @@ const seedOwnUnreadCount = (channel, unread_messages) => { channel.state.read = { ...channel.state.read, [ownUser.id]: { - last_read: new Date(0), + last_read: convertDateToTimestamp(new Date(0)), user: ownUser, ...channel.state.read[ownUser.id], unread_messages, @@ -49,7 +51,7 @@ describe('Channel count unread', function () { let client; beforeEach(() => { user = { id: 'user' }; - lastRead = new Date('2020-01-01T00:00:00'); + lastRead = convertDateToTimestamp('2020-01-01T00:00:00'); const channelResponse = generateChannel(); client = new StreamChat('apiKey'); @@ -151,6 +153,26 @@ describe('Channel count unread', function () { expect(channel.countUnread(lastRead)).to.be.equal(2); }); + it('countUnread should count from the epoch when lastRead is 0, not fall back to the cache', function () { + // `0` is the epoch sentinel the read-state seeding writes for a channel with no own read row + // ("nothing has been read"). A truthiness guard here reads it as "no argument supplied" and + // returns the cached aggregate instead of counting — so a channel opened uninitialized or with + // `state: false` reports the wrong unread count. + seedOwnUnreadCount(channel, 99); + seedLatestWindow(channel, [ + ...ignoredMessages, + generateMsg({ date: '2021-01-01T00:00:00' }), + generateMsg({ date: '2022-01-01T00:00:00' }), + ]); + + // Against the epoch every countable message is newer, so the three date-excluded ones join the + // two added here (5); only the shadowed/silent/muted three stay out. Crucially the answer is a + // real count, not the seeded 99 the cache would have returned. + expect(channel.countUnread(0)).to.be.equal(5); + expect(channel.countUnread(0)).not.to.be.equal(99); + seedOwnUnreadCount(channel, 0); + }); + it('countUnread should read the latest window, not the active one', () => { expect(channel.countUnread(lastRead)).to.be.equal(0); // latest (head) window @@ -707,7 +729,7 @@ describe('Channel localized unread count (isLocalUnreadCountEnabled)', function expect(channel.state.read[user.id]).to.be.ok; expect(channel.state.read[user.id].unread_messages).to.be.equal(1); expect(channel.state.read[user.id].user.id).to.be.equal(user.id); - expect(channel.state.read[user.id].last_read.getTime()).to.be.equal(0); + expect(channel.state.read[user.id].last_read).to.be.equal(0); }); it('message.new does not increment the unread count with read events off when the flag is not set', function () { @@ -730,7 +752,7 @@ describe('Channel localized unread count (isLocalUnreadCountEnabled)', function const lastMsg = generateMsg({ user: otherUser }); seedLatestWindow(channel, [lastMsg]); channel.state.read[user.id] = { - last_read: new Date('2020-01-01T00:00:00'), + last_read: convertDateToTimestamp(new Date('2020-01-01T00:00:00')), unread_messages: 5, user, }; @@ -753,14 +775,15 @@ describe('Channel localized unread count (isLocalUnreadCountEnabled)', function expect(event.channel_type).to.be.equal(channel.type); expect(event.user.id).to.be.equal(user.id); expect(event.last_read_message_id).to.be.equal(lastMsg.id); - // markReadLocally now builds the event with a Date `created_at` (not an ISO string). - expect(event.created_at).to.be.instanceof(Date); + // markReadLocally builds the event with a wire `created_at` (unix nanoseconds), matching the + // server `message.read` it is shaped after. + expect(event.created_at).to.be.a('number'); // markReadLocally returns the same dispatched event so callers (e.g. the RN SDK) can sync // their own unread UI from that read info instead of re-deriving it. expect(returned).to.equal(event); expect(returned.last_read_message_id).to.be.equal(lastMsg.id); - expect(returned.created_at).to.be.instanceof(Date); + expect(returned.created_at).to.be.a('number'); }); it('markReadLocally returns undefined and dispatches nothing when there is no connected user', function () { @@ -837,7 +860,7 @@ describe('Channel _handleChannelEvent', function () { const currentMember = generateMember({ user, - pinned_at: new Date().toISOString(), + pinned_at: convertDateToTimestamp(new Date().toISOString()), archived_at: new Date().toISOString(), }); @@ -1085,7 +1108,9 @@ describe('Channel _handleChannelEvent', function () { id: 'pinned-existing', cid: channel.cid, pinned: true, - pinned_at: new Date('2020-01-01T00:00:00.001Z').toISOString(), + pinned_at: convertDateToTimestamp( + new Date('2020-01-01T00:00:00.001Z').toISOString(), + ), }); channel.pinnedMessagesPaginator.ingestPage({ page: [formatMessage(existing)], @@ -1102,7 +1127,9 @@ describe('Channel _handleChannelEvent', function () { id: 'pinned-new', cid: channel.cid, pinned: true, - pinned_at: new Date('2020-01-01T00:00:00.002Z').toISOString(), + pinned_at: convertDateToTimestamp( + new Date('2020-01-01T00:00:00.002Z').toISOString(), + ), }); channel._handleChannelEvent({ type: 'message.new', message: newlyPinned, user }); expect(channel.pinnedMessagesPaginator.items?.map((m) => m.id)).to.include( @@ -1195,8 +1222,8 @@ describe('Channel _handleChannelEvent', function () { it('should extend "message.updated" and "message.deleted" event payloads with "own_reactions"', () => { const own_reactions = [ { - created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), + created_at: convertDateToTimestamp(new Date().toISOString()), + updated_at: convertDateToTimestamp(new Date().toISOString()), type: 'wow', }, ]; @@ -1309,9 +1336,9 @@ describe('Channel _handleChannelEvent', function () { describe('channel.truncated', () => { it('message.truncate removes all messages if "truncated_at" is "now"', function () { const messages = [ - { created_at: '2021-01-01T00:01:00' }, - { created_at: '2021-01-01T00:02:00' }, - { created_at: '2021-01-01T00:03:00' }, + { created_at: convertDateToTimestamp('2021-01-01T00:01:00') }, + { created_at: convertDateToTimestamp('2021-01-01T00:02:00') }, + { created_at: convertDateToTimestamp('2021-01-01T00:03:00') }, ].map(generateMsg); seedLatestWindow(channel, messages); @@ -1321,7 +1348,7 @@ describe('Channel _handleChannelEvent', function () { type: 'channel.truncated', user: { id: 'id' }, channel: { - truncated_at: new Date().toISOString(), + truncated_at: convertDateToTimestamp(), }, }); @@ -1333,7 +1360,7 @@ describe('Channel _handleChannelEvent', function () { channel.state.read = { [userId]: { unread_messages: 5, - last_read: new Date('2021-01-01T00:00:00.000Z'), + last_read: convertDateToTimestamp(new Date('2021-01-01T00:00:00.000Z')), user: { id: userId }, }, }; @@ -1370,7 +1397,7 @@ describe('Channel _handleChannelEvent', function () { type: 'channel.truncated', user: { id: 'id' }, channel: { - truncated_at: new Date().toISOString(), + truncated_at: convertDateToTimestamp(), }, }); @@ -1388,9 +1415,9 @@ describe('Channel _handleChannelEvent', function () { it('message.truncate removes messages up to specified date', function () { const messages = [ - { created_at: '2021-01-01T00:01:00' }, - { created_at: '2021-01-01T00:02:00' }, - { created_at: '2021-01-01T00:03:00' }, + { created_at: convertDateToTimestamp('2021-01-01T00:01:00') }, + { created_at: convertDateToTimestamp('2021-01-01T00:02:00') }, + { created_at: convertDateToTimestamp('2021-01-01T00:03:00') }, ].map(generateMsg); seedLatestWindow(channel, messages); @@ -1416,7 +1443,7 @@ describe('Channel _handleChannelEvent', function () { channel._handleChannelEvent({ type: 'channel.truncated', - channel: { truncated_at: '2020-02-01T00:00:00.000Z' }, + channel: { truncated_at: convertDateToTimestamp('2020-02-01T00:00:00.000Z') }, }); expect(pinnedIds()).to.eql(['new']); @@ -1455,7 +1482,10 @@ describe('Channel _handleChannelEvent', function () { channel._handleChannelEvent({ type: 'message.deleted', user: { id: 'id' }, - message: { ...originalMessage, deleted_at: new Date().toISOString() }, + message: { + ...originalMessage, + deleted_at: convertDateToTimestamp(new Date().toISOString()), + }, }); expect( @@ -1487,7 +1517,7 @@ describe('Channel _handleChannelEvent', function () { }); channel.messagePaginator.ingestItem(message); - const deletedAt = new Date().toISOString(); + const deletedAt = convertDateToTimestamp(); channel._handleChannelEvent({ type: 'message.deleted', user: { id: 'id' }, @@ -1495,7 +1525,7 @@ describe('Channel _handleChannelEvent', function () { }); const itemFromPaginator = channel.messagePaginator.getItem(message.id); - expect(itemFromPaginator?.deleted_at?.toISOString()).to.equal(deletedAt); + expect(itemFromPaginator?.deleted_at).to.equal(deletedAt); }); it('message.deleted (soft) ignores thread replies in messagePaginator', function () { @@ -1509,7 +1539,10 @@ describe('Channel _handleChannelEvent', function () { channel._handleChannelEvent({ type: 'message.deleted', user: { id: 'id' }, - message: { ...threadReply, deleted_at: new Date().toISOString() }, + message: { + ...threadReply, + deleted_at: convertDateToTimestamp(new Date().toISOString()), + }, }); // A pure thread reply must never leak a "deleted" placeholder into the channel list. @@ -1561,7 +1594,7 @@ describe('Channel _handleChannelEvent', function () { ...quotedMessage, type: 'deleted', text: 'after delete', - deleted_at: new Date().toISOString(), + deleted_at: convertDateToTimestamp(new Date().toISOString()), }, }); @@ -1595,7 +1628,7 @@ describe('Channel _handleChannelEvent', function () { type: 'love', user_id: 'user-1', message_id: message.id, - created_at: new Date().toISOString(), + created_at: convertDateToTimestamp(new Date().toISOString()), }, }); @@ -1615,7 +1648,7 @@ describe('Channel _handleChannelEvent', function () { type: 'love', user_id: 'user-1', message_id: message.id, - created_at: new Date().toISOString(), + created_at: convertDateToTimestamp(new Date().toISOString()), }, }); @@ -1633,7 +1666,7 @@ describe('Channel _handleChannelEvent', function () { type: 'like', user_id: user.id, message_id: 'p', - created_at: new Date().toISOString(), + created_at: convertDateToTimestamp(new Date().toISOString()), }, }); @@ -1655,7 +1688,7 @@ describe('Channel _handleChannelEvent', function () { type: 'love', user_id: 'user-1', message_id: message.id, - created_at: new Date().toISOString(), + created_at: convertDateToTimestamp(new Date().toISOString()), }, }); @@ -1675,7 +1708,7 @@ describe('Channel _handleChannelEvent', function () { type: 'love', user_id: 'user-1', message_id: message.id, - created_at: new Date().toISOString(), + created_at: convertDateToTimestamp(new Date().toISOString()), }, }); @@ -1689,7 +1722,7 @@ describe('Channel _handleChannelEvent', function () { const otherUser = { id: 'other-user' }; it('updates messagePaginator items on soft delete', () => { - const deletedAt = new Date('2025-02-01T14:01:30.000Z'); + const deletedAt = convertDateToTimestamp('2025-02-01T14:01:30.000Z'); const bannedMessage = generateMsg({ id: 'mp-soft-banned', user: bannedUser }); const quoteCarrier = generateMsg({ id: 'mp-soft-quote-carrier', @@ -1710,24 +1743,20 @@ describe('Channel _handleChannelEvent', function () { channel_id: channel.id, user: bannedUser, soft_delete: true, - created_at: deletedAt.toISOString(), + created_at: deletedAt, }); const deletedFromPaginator = channel.messagePaginator.getItem(bannedMessage.id); expect(deletedFromPaginator?.type).to.equal('deleted'); - expect(deletedFromPaginator?.deleted_at?.toISOString()).to.equal( - deletedAt.toISOString(), - ); + expect(deletedFromPaginator?.deleted_at).to.equal(deletedAt); const quoteCarrierFromPaginator = channel.messagePaginator.getItem(quoteCarrier.id); expect(quoteCarrierFromPaginator?.quoted_message?.type).to.equal('deleted'); - expect( - quoteCarrierFromPaginator?.quoted_message?.deleted_at?.toISOString(), - ).to.equal(deletedAt.toISOString()); + expect(quoteCarrierFromPaginator?.quoted_message?.deleted_at).to.equal(deletedAt); }); it('updates messagePaginator items on hard delete', () => { - const deletedAt = new Date('2025-02-01T14:01:30.000Z'); + const deletedAt = convertDateToTimestamp('2025-02-01T14:01:30.000Z'); const bannedMessage = generateMsg({ id: 'mp-hard-banned', user: bannedUser }); const quoteCarrier = generateMsg({ id: 'mp-hard-quote-carrier', @@ -1748,7 +1777,7 @@ describe('Channel _handleChannelEvent', function () { channel_id: channel.id, user: bannedUser, hard_delete: true, - created_at: deletedAt.toISOString(), + created_at: deletedAt, }); expect( @@ -1756,9 +1785,7 @@ describe('Channel _handleChannelEvent', function () { ).toBeUndefined(); const quoteCarrierFromPaginator = channel.messagePaginator.getItem(quoteCarrier.id); expect(quoteCarrierFromPaginator?.quoted_message?.type).to.equal('deleted'); - expect( - quoteCarrierFromPaginator?.quoted_message?.deleted_at?.toISOString(), - ).to.equal(deletedAt.toISOString()); + expect(quoteCarrierFromPaginator?.quoted_message?.deleted_at).to.equal(deletedAt); }); // Pinned-message deletion for a banned user (moved from the pinnedMessagesPaginator suite). @@ -1769,7 +1796,7 @@ describe('Channel _handleChannelEvent', function () { type: 'user.messages.deleted', user: bannedUser, soft_delete: true, - created_at: '2025-01-01T00:00:00.000Z', + created_at: convertDateToTimestamp('2025-01-01T00:00:00.000Z'), }); expect(channel.pinnedMessagesPaginator.getItem('p')?.type).to.equal('deleted'); @@ -1785,7 +1812,7 @@ describe('Channel _handleChannelEvent', function () { type: 'user.messages.deleted', user: bannedUser, hard_delete: true, - created_at: '2025-01-01T00:00:00.000Z', + created_at: convertDateToTimestamp('2025-01-01T00:00:00.000Z'), }); expect(channel.pinnedMessagesPaginator.items?.map((m) => m.id)).to.eql(['other']); @@ -1801,11 +1828,11 @@ describe('Channel _handleChannelEvent', function () { it('does not throw on channel-scoped hard-delete when channel contains a same-user self-quote', () => { const m1 = generateMsg({ - created_at: '2020-01-01T00:00:01.000Z', + created_at: convertDateToTimestamp('2020-01-01T00:00:01.000Z'), user: bannedUser, }); const m2 = generateMsg({ - created_at: '2020-01-01T00:00:02.000Z', + created_at: convertDateToTimestamp('2020-01-01T00:00:02.000Z'), user: bannedUser, quoted_message: m1, quoted_message_id: m1.id, @@ -1823,7 +1850,7 @@ describe('Channel _handleChannelEvent', function () { channel_id: channel.id, user: bannedUser, hard_delete: true, - created_at: '2025-02-01T14:01:30.000Z', + created_at: convertDateToTimestamp('2025-02-01T14:01:30.000Z'), }; expect(() => channel._handleChannelEvent(event)).not.to.throw(); @@ -1844,25 +1871,23 @@ describe('Channel _handleChannelEvent', function () { beforeEach(() => { initialCountUnread = 0; initialReadState = { - last_read: new Date().toISOString(), + last_read: convertDateToTimestamp(), last_read_message_id: '6', user, unread_messages: initialCountUnread, - last_delivered_at: new Date(1000).toISOString(), + last_delivered_at: msToNs(1000), last_delivered_message_id: 'delivered-msg-id', }; notificationMarkUnreadEvent = { type: 'notification.mark_unread', - created_at: new Date().toISOString(), + created_at: convertDateToTimestamp(), cid: channel.cid, channel_id: channel.id, channel_type: channel.type, channel: null, user, first_unread_message_id: '2', - last_read_at: new Date( - new Date(initialReadState.last_read).getTime() - 1000, - ).toISOString(), + last_read_at: initialReadState.last_read - msToNs(1000), last_read_message_id: '1', unread_messages: 5, unread_count: 6, @@ -1878,9 +1903,7 @@ describe('Channel _handleChannelEvent', function () { channel._handleChannelEvent(event); expect(channel.state.unreadCount).to.be.equal(event.unread_messages); - expect(new Date(channel.state.read[user.id].last_read).getTime()).to.be.equal( - new Date(event.last_read_at).getTime(), - ); + expect(channel.state.read[user.id].last_read).to.be.equal(event.last_read_at); expect(channel.state.read[user.id].last_read_message_id).to.be.equal( event.last_read_message_id, ); @@ -1898,7 +1921,7 @@ describe('Channel _handleChannelEvent', function () { ).toBe(event.last_read_message_id); expect(channel.messagePaginator.unreadStateSnapshot.getLatestValue()).toEqual({ firstUnreadMessageId: event.first_unread_message_id, - lastReadAt: new Date(event.last_read_at), + lastReadAt: event.last_read_at, lastReadMessageId: event.last_read_message_id, unreadCount: event.unread_messages, }); @@ -1931,8 +1954,8 @@ describe('Channel _handleChannelEvent', function () { channel._handleChannelEvent(event); expect(channel.state.unreadCount).to.be.equal(initialCountUnread); - expect(new Date(channel.state.read[user.id].last_read).getTime()).to.be.equal( - new Date(initialReadState.last_read).getTime(), + expect(channel.state.read[user.id].last_read).to.be.equal( + initialReadState.last_read, ); expect(channel.state.read[user.id].last_read_message_id).to.be.equal( initialReadState.last_read_message_id, @@ -1952,17 +1975,17 @@ describe('Channel _handleChannelEvent', function () { beforeEach(() => { initialCountUnread = 100; initialReadState = { - last_read: new Date(1500).toISOString(), + last_read: msToNs(1500), last_read_message_id: '6', first_unread_message_id: 'first-unread-msg-id', user, unread_messages: initialCountUnread, - last_delivered_at: new Date(1000).toISOString(), + last_delivered_at: msToNs(1000), last_delivered_message_id: 'delivered-msg-id', }; messageReadEvent = { type: 'message.read', - created_at: new Date(2000).toISOString(), + created_at: msToNs(2000), cid: channel.cid, channel_member_count: 100, channel_type: channel.type, @@ -1979,16 +2002,14 @@ describe('Channel _handleChannelEvent', function () { channel._handleChannelEvent(event); expect(channel.state.unreadCount).toBe(0); - expect(new Date(channel.state.read[user.id].last_read).getTime()).toBe( - new Date(messageReadEvent.created_at).getTime(), - ); + expect(channel.state.read[user.id].last_read).toBe(messageReadEvent.created_at); expect(channel.state.read[user.id].last_read_message_id).toBe( event.last_read_message_id, ); expect(channel.state.read[user.id].first_unread_message_id).toBeUndefined(); expect(channel.state.read[user.id].unread_messages).toBe(0); - expect(new Date(channel.state.read[user.id].last_delivered_at).getTime()).toBe( - new Date(messageReadEvent.created_at).getTime(), + expect(channel.state.read[user.id].last_delivered_at).toBe( + messageReadEvent.created_at, ); expect(channel.state.read[user.id].last_delivered_message_id).toBe( event.last_read_message_id, @@ -2007,17 +2028,17 @@ describe('Channel _handleChannelEvent', function () { channel._handleChannelEvent(event); expect(channel.state.unreadCount).toBe(initialCountUnread); - expect(new Date(channel.state.read[anotherUser.id].last_read).getTime()).toBe( - new Date(messageReadEvent.created_at).getTime(), + expect(channel.state.read[anotherUser.id].last_read).toBe( + messageReadEvent.created_at, ); expect(channel.state.read[anotherUser.id].last_read_message_id).toBe( event.last_read_message_id, ); expect(channel.state.read[anotherUser.id].first_unread_message_id).toBeUndefined(); expect(channel.state.read[anotherUser.id].unread_messages).toBe(0); - expect( - new Date(channel.state.read[anotherUser.id].last_delivered_at).getTime(), - ).toBe(new Date(messageReadEvent.created_at).getTime()); + expect(channel.state.read[anotherUser.id].last_delivered_at).toBe( + messageReadEvent.created_at, + ); expect(channel.state.read[anotherUser.id].last_delivered_message_id).toBe( event.last_read_message_id, ); @@ -2042,9 +2063,7 @@ describe('Channel _handleChannelEvent', function () { expect(changes).to.have.length(1); expect(changes[0].next).to.not.equal(changes[0].prev); - expect(new Date(changes[0].next.last_read).getTime()).toBe( - new Date(messageReadEvent.created_at).getTime(), - ); + expect(changes[0].next.last_read).toBe(messageReadEvent.created_at); }); }); @@ -2053,7 +2072,7 @@ describe('Channel _handleChannelEvent', function () { beforeEach(() => { channel.state.read[user.id] = { - last_read: new Date(1500).toISOString(), + last_read: msToNs(1500), last_read_message_id: '6', first_unread_message_id: 'first-unread-msg-id', user, @@ -2061,7 +2080,7 @@ describe('Channel _handleChannelEvent', function () { }; markReadEvent = { type: 'notification.mark_read', - created_at: new Date(2000).toISOString(), + created_at: msToNs(2000), cid: channel.cid, channel_type: channel.type, channel_id: channel.id, @@ -2077,9 +2096,7 @@ describe('Channel _handleChannelEvent', function () { expect(channel.state.read[user.id].unread_messages).toBe(0); expect(channel.state.unreadCount).toBe(0); - expect(new Date(channel.state.read[user.id].last_read).getTime()).toBe( - new Date(markReadEvent.created_at).getTime(), - ); + expect(channel.state.read[user.id].last_read).toBe(markReadEvent.created_at); expect(channel.state.read[user.id].last_read_message_id).toBe( markReadEvent.last_read_message_id, ); @@ -2090,7 +2107,7 @@ describe('Channel _handleChannelEvent', function () { channel._handleChannelEvent({ ...markReadEvent, thread_id: 'thread-1' }); expect(channel.state.read[user.id].unread_messages).toBe(100); - expect(new Date(channel.state.read[user.id].last_read).getTime()).toBe(1500); + expect(channel.state.read[user.id].last_read).toBe(msToNs(1500)); }); }); @@ -2102,16 +2119,16 @@ describe('Channel _handleChannelEvent', function () { beforeEach(() => { initialCountUnread = 100; initialReadState = { - last_read: new Date(1500).toISOString(), + last_read: msToNs(1500), last_read_message_id: '6', user, unread_messages: initialCountUnread, - last_delivered_at: new Date(1000).toISOString(), + last_delivered_at: msToNs(1000), last_delivered_message_id: 'delivered-msg-id', }; messageDeliveredEvent = { type: 'message.delivered', - created_at: new Date(2000).toISOString(), + created_at: msToNs(2000), cid: channel.cid, channel_member_count: 100, channel_type: channel.type, @@ -2128,17 +2145,15 @@ describe('Channel _handleChannelEvent', function () { channel._handleChannelEvent(messageDeliveredEvent); expect(channel.state.unreadCount).toBe(initialReadState.unread_messages); - expect(new Date(channel.state.read[user.id].last_read).getTime()).toBe( - new Date(initialReadState.last_read).getTime(), - ); + expect(channel.state.read[user.id].last_read).toBe(initialReadState.last_read); expect(channel.state.read[user.id].last_read_message_id).toBe( initialReadState.last_read_message_id, ); expect(channel.state.read[user.id].unread_messages).toBe( initialReadState.unread_messages, ); - expect(new Date(channel.state.read[user.id].last_delivered_at).getTime()).toBe( - new Date(messageDeliveredEvent.last_delivered_at).getTime(), + expect(channel.state.read[user.id].last_delivered_at).toBe( + msToNs(Date.parse(messageDeliveredEvent.last_delivered_at)), ); expect(channel.state.read[user.id].last_delivered_message_id).toBe( messageDeliveredEvent.last_delivered_message_id, @@ -2148,21 +2163,19 @@ describe('Channel _handleChannelEvent', function () { it('should not move canonical delivered state backwards on out-of-order events', () => { channel.state.read[user.id] = { ...initialReadState, - last_delivered_at: new Date(3000).toISOString(), + last_delivered_at: msToNs(3000), last_delivered_message_id: 'newer-message-id', }; const olderDeliveryEvent = { ...messageDeliveredEvent, - created_at: new Date(2000).toISOString(), + created_at: msToNs(2000), last_delivered_at: new Date(2000).toISOString(), last_delivered_message_id: 'older-message-id', }; channel._handleChannelEvent(olderDeliveryEvent); - expect(new Date(channel.state.read[user.id].last_delivered_at).getTime()).toBe( - new Date(3000).getTime(), - ); + expect(channel.state.read[user.id].last_delivered_at).toBe(msToNs(3000)); expect(channel.state.read[user.id].last_delivered_message_id).toBe( 'newer-message-id', ); @@ -2177,8 +2190,8 @@ describe('Channel _handleChannelEvent', function () { channel._handleChannelEvent(event); expect(channel.state.unreadCount).toBe(initialCountUnread); - expect(new Date(channel.state.read[anotherUser.id].last_read).getTime()).toBe( - new Date(initialReadState.last_read).getTime(), + expect(channel.state.read[anotherUser.id].last_read).toBe( + initialReadState.last_read, ); expect(channel.state.read[anotherUser.id].last_read_message_id).toBe( initialReadState.last_read_message_id, @@ -2186,9 +2199,9 @@ describe('Channel _handleChannelEvent', function () { expect(channel.state.read[anotherUser.id].unread_messages).toBe( initialReadState.unread_messages, ); - expect( - new Date(channel.state.read[anotherUser.id].last_delivered_at).getTime(), - ).toBe(new Date(event.last_delivered_at).getTime()); + expect(channel.state.read[anotherUser.id].last_delivered_at).toBe( + msToNs(Date.parse(event.last_delivered_at)), + ); expect(channel.state.read[anotherUser.id].last_delivered_message_id).toBe( event.last_delivered_message_id, ); @@ -2297,13 +2310,13 @@ describe('Channel _handleChannelEvent', function () { user: { id: 'admin', role: 'admin', - created_at: '2022-03-08T09:46:56.840739Z', - updated_at: '2022-03-15T08:30:09.796926Z', + created_at: convertDateToTimestamp('2022-03-08T09:46:56.840739Z'), + updated_at: convertDateToTimestamp('2022-03-15T08:30:09.796926Z'), last_active: '2023-05-24T09:20:31.041292724Z', banned: false, online: true, }, - created_at: '2023-05-24T09:20:43.986615426Z', + created_at: convertDateToTimestamp('2023-05-24T09:20:43.986615426Z'), }; channel.data.hidden = true; channel.data.blocked = true; @@ -2325,13 +2338,13 @@ describe('Channel _handleChannelEvent', function () { user: { id: 'admin', role: 'admin', - created_at: '2022-03-08T09:46:56.840739Z', - updated_at: '2022-03-15T08:30:09.796926Z', + created_at: convertDateToTimestamp('2022-03-08T09:46:56.840739Z'), + updated_at: convertDateToTimestamp('2022-03-15T08:30:09.796926Z'), last_active: '2023-05-24T09:20:31.041292724Z', banned: false, online: true, }, - created_at: '2023-05-24T09:20:43.986615426Z', + created_at: convertDateToTimestamp('2023-05-24T09:20:43.986615426Z'), }; channel.data.hidden = true; channel.data.blocked = true; @@ -2588,7 +2601,7 @@ describe('Uninitialized Channel', () => { channel_id: channel.id, user: otherUser, message: generateMsg({ user: otherUser }), - created_at: new Date().toISOString(), + created_at: convertDateToTimestamp(new Date().toISOString()), }); it('does not throw and still updates channel state on message.new', () => { @@ -2647,7 +2660,7 @@ describe('reactive channel mute status', () => { preMutedClient.mutedChannels = [ { channel: { cid: 'messaging:premuted' }, - created_at: '2024-01-01T00:00:00.000Z', + created_at: convertDateToTimestamp('2024-01-01T00:00:00.000Z'), }, ]; @@ -2673,7 +2686,7 @@ describe('reactive channel mute status', () => { channel_mutes: [ { channel: { cid: channel.cid }, - created_at: '2024-01-01T00:00:00.000Z', + created_at: convertDateToTimestamp('2024-01-01T00:00:00.000Z'), }, ], }, @@ -3182,8 +3195,8 @@ describe('Channel lastMessage', async () => { generateMsg({ date: latestMessageDate }), ]); - expect(channel.messagePaginator.headmostItem.created_at.getTime()).to.be.equal( - new Date(latestMessageDate).getTime(), + expect(channel.messagePaginator.headmostItem.created_at).to.be.equal( + convertDateToTimestamp(latestMessageDate), ); }); @@ -3196,8 +3209,8 @@ describe('Channel lastMessage', async () => { generateMsg({ date: '2018-01-01T00:00:00' }), ]); - expect(channel.messagePaginator.headmostItem.created_at.getTime()).to.be.equal( - new Date(latestMessageDate).getTime(), + expect(channel.messagePaginator.headmostItem.created_at).to.be.equal( + convertDateToTimestamp(latestMessageDate), ); }); @@ -3220,8 +3233,8 @@ describe('Channel lastMessage', async () => { setActive: false, }); - expect(channel.messagePaginator.headmostItem.created_at.getTime()).to.be.equal( - new Date(latestMessageDate).getTime(), + expect(channel.messagePaginator.headmostItem.created_at).to.be.equal( + convertDateToTimestamp(latestMessageDate), ); }); @@ -3239,9 +3252,7 @@ describe('Channel lastMessage', async () => { // ingestion advances the tracked latest, skipping the newest (system) message per config. seedLatestWindow(channel, latestMessages); - expect(channel.messagePaginator.lastMessageAt.getTime()).toBe( - new Date(latestMessages[1].created_at).getTime(), - ); + expect(channel.messagePaginator.lastMessageAt).toBe(latestMessages[1].created_at); }); }); @@ -3260,17 +3271,17 @@ describe('Channel last_message_at', () => { it('advances monotonically as messages are tracked', () => { expect(channel.messagePaginator.lastMessageAt).to.be.null; track(generateMsg({ id: '0', date: '2020-01-01T00:00:00.000Z' })); - expect(channel.messagePaginator.lastMessageAt.getTime()).to.be.equal( - new Date('2020-01-01T00:00:00.000Z').getTime(), + expect(channel.messagePaginator.lastMessageAt).to.be.equal( + convertDateToTimestamp('2020-01-01T00:00:00.000Z'), ); track(generateMsg({ id: '1', date: '2019-01-01T00:00:00.000Z' })); - expect(channel.messagePaginator.lastMessageAt.getTime()).to.be.equal( - new Date('2020-01-01T00:00:00.000Z').getTime(), + expect(channel.messagePaginator.lastMessageAt).to.be.equal( + convertDateToTimestamp('2020-01-01T00:00:00.000Z'), ); track(generateMsg({ id: '2', date: '2020-01-01T00:00:00.001Z' })); - expect(channel.messagePaginator.lastMessageAt.getTime()).to.be.equal( - new Date('2020-01-01T00:00:00.001Z').getTime(), + expect(channel.messagePaginator.lastMessageAt).to.be.equal( + convertDateToTimestamp('2020-01-01T00:00:00.001Z'), ); }); @@ -3289,17 +3300,21 @@ describe('Channel last_message_at', () => { it('is seeded from the server-provided last_message_at', () => { // A channel surfaced by the channel-list query: lastMessageAt is seeded from the server // aggregate so it sorts correctly even before its message paginator loads a page. - channel.messagePaginator.seedLastMessageAt('2023-05-03T11:12:53.993Z'); - expect(channel.messagePaginator.lastMessageAt.getTime()).to.be.equal( - new Date('2023-05-03T11:12:53.993Z').getTime(), + channel.messagePaginator.seedLastMessageAt( + convertDateToTimestamp('2023-05-03T11:12:53.993Z'), + ); + expect(channel.messagePaginator.lastMessageAt).to.be.equal( + convertDateToTimestamp('2023-05-03T11:12:53.993Z'), ); }); it('advances past the seeded value when a newer message is tracked (monotonic max)', () => { - channel.messagePaginator.seedLastMessageAt('2020-01-01T00:00:00.000Z'); + channel.messagePaginator.seedLastMessageAt( + convertDateToTimestamp('2020-01-01T00:00:00.000Z'), + ); track(generateMsg({ id: '0', date: '2021-06-01T00:00:00.000Z' })); - expect(channel.messagePaginator.lastMessageAt.getTime()).to.be.equal( - new Date('2021-06-01T00:00:00.000Z').getTime(), + expect(channel.messagePaginator.lastMessageAt).to.be.equal( + convertDateToTimestamp('2021-06-01T00:00:00.000Z'), ); }); }); @@ -3353,7 +3368,7 @@ describe('Channel _initializeState', () => { }); channel.state.read = { [existingUser.id]: { - last_read: new Date('2026-01-01T00:00:00.000Z'), + last_read: convertDateToTimestamp(new Date('2026-01-01T00:00:00.000Z')), unread_messages: 1, user: existingUser, }, @@ -3364,7 +3379,9 @@ describe('Channel _initializeState', () => { { last_delivered_at: new Date('2026-01-02T00:00:00.000Z').toISOString(), last_delivered_message_id: 'delivered-message-id', - last_read: new Date('2026-01-02T00:00:00.000Z').toISOString(), + last_read: convertDateToTimestamp( + new Date('2026-01-02T00:00:00.000Z').toISOString(), + ), last_read_message_id: 'read-message-id', unread_messages: 0, user: newUser, @@ -3390,7 +3407,11 @@ describe('Channel.query', async () => { messages: Array.from( { length: DEFAULT_QUERY_CHANNEL_MESSAGE_LIST_PAGE_SIZE }, (_, i) => - generateMsg({ created_at: new Date(1700000000000 + i * 1000).toISOString() }), + generateMsg({ + created_at: convertDateToTimestamp( + new Date(1700000000000 + i * 1000).toISOString(), + ), + }), ), }; const stub = sinon @@ -4283,7 +4304,11 @@ describe('Channel active flag (mark-read stays UI-driven)', () => { channel.activate(); channel.messagePaginator.setViewingLive(true); channel.state.read = { - me: { last_read: new Date(0), unread_messages: 3, user: { id: 'me' } }, + me: { + last_read: convertDateToTimestamp(new Date(0)), + unread_messages: 3, + user: { id: 'me' }, + }, }; expect(spy).not.toHaveBeenCalled(); diff --git a/test/unit/channel_state.test.js b/test/unit/channel_state.test.js index bb5c3b02ed..008cd21990 100644 --- a/test/unit/channel_state.test.js +++ b/test/unit/channel_state.test.js @@ -6,6 +6,8 @@ import { ChannelState, StreamChat, Channel } from '../../src'; import { generateUUIDv4 as uuidv4 } from '../../src/utils'; import { vi, describe, beforeEach, afterEach, it, expect } from 'vitest'; +import { msToNs } from '../../src/utils/time'; +import { convertDateToTimestamp } from './test-utils/time'; const toISOString = (timestampMs) => new Date(timestampMs).toISOString(); @@ -19,13 +21,13 @@ describe('ChannelState clean', () => { client.activeChannels[channel.cid] = channel; }); - it('should remove any stale typing events with either string or Date received_at', async () => { - // string received_at + it('should remove any stale typing events', async () => { + // a wire timestamp, as every event carries client.dispatchEvent({ cid: channel.cid, type: 'typing.start', user: { id: 'other' }, - received_at: toISOString(Date.now() - 10000), + received_at: msToNs(Date.now() - 10000), }); expect(channel.state.typing['other']).not.to.be.undefined; @@ -37,7 +39,7 @@ describe('ChannelState clean', () => { cid: channel.cid, type: 'typing.start', user: { id: 'other' }, - received_at: new Date(Date.now() - 10000), + received_at: msToNs(Date.now() - 10000), }); expect(channel.state.typing['other']).not.to.be.undefined; @@ -167,7 +169,7 @@ describe('ChannelState read store', () => { const state = new ChannelState(); const read = { alice: { - last_read: new Date('2026-02-28T00:00:00.000Z'), + last_read: convertDateToTimestamp(new Date('2026-02-28T00:00:00.000Z')), unread_messages: 3, user: { id: 'alice' }, }, @@ -194,8 +196,16 @@ describe('ChannelState unreadCount', () => { expect(channel.state.unreadCount).to.equal(0); channel.state.read = { - me: { last_read: new Date(0), unread_messages: 7, user: { id: 'me' } }, - alice: { last_read: new Date(0), unread_messages: 3, user: { id: 'alice' } }, + me: { + last_read: convertDateToTimestamp(new Date(0)), + unread_messages: 7, + user: { id: 'me' }, + }, + alice: { + last_read: convertDateToTimestamp(new Date(0)), + unread_messages: 3, + user: { id: 'alice' }, + }, }; expect(channel.state.unreadCount).to.equal(7); @@ -204,7 +214,11 @@ describe('ChannelState unreadCount', () => { it('is 0 while the current user has no read row', () => { channel.state.read = { - alice: { last_read: new Date(0), unread_messages: 3, user: { id: 'alice' } }, + alice: { + last_read: convertDateToTimestamp(new Date(0)), + unread_messages: 3, + user: { id: 'alice' }, + }, }; expect(channel.state.unreadCount).to.equal(0); @@ -216,7 +230,11 @@ describe('ChannelState unreadCount', () => { client.user = { id: 'me' }; channel.state.read = { - me: { last_read: new Date(0), unread_messages: 4, user: { id: 'me' } }, + me: { + last_read: convertDateToTimestamp(new Date(0)), + unread_messages: 4, + user: { id: 'me' }, + }, }; channel.pendingDisposal = true; @@ -429,7 +447,11 @@ describe('ChannelState unified store', () => { const state = new ChannelState(); const members = { alice: { user: { id: 'alice' }, user_id: 'alice' } }; const read = { - alice: { last_read: new Date(0), unread_messages: 2, user: { id: 'alice' } }, + alice: { + last_read: convertDateToTimestamp(new Date(0)), + unread_messages: 2, + user: { id: 'alice' }, + }, }; const watchers = { bob: { id: 'bob' } }; @@ -463,7 +485,11 @@ describe('ChannelState unified store', () => { ); const read = { - alice: { last_read: new Date(0), unread_messages: 1, user: { id: 'alice' } }, + alice: { + last_read: convertDateToTimestamp(new Date(0)), + unread_messages: 1, + user: { id: 'alice' }, + }, }; state.read = read; // a non-read write must NOT emit to a read selector diff --git a/test/unit/client.test.js b/test/unit/client.test.js index 1e111fd3e0..b2e1cce197 100644 --- a/test/unit/client.test.js +++ b/test/unit/client.test.js @@ -26,6 +26,7 @@ import { } from 'vitest'; import { Channel } from '../../src'; import { MockOfflineDB } from './offline-support/MockOfflineDB'; +import { convertDateToTimestamp } from './test-utils/time'; describe('StreamChat getInstance', () => { beforeEach(() => { @@ -815,7 +816,7 @@ describe('message update', () => { id: 'msg-123', status: 'failed', text: 'edited', - message_text_updated_at: '2026-04-01T20:48:43.886269Z', + message_text_updated_at: convertDateToTimestamp('2026-04-01T20:48:43.886269Z'), }); const request = { id: failedEditedMessage.id, message: failedEditedMessage }; @@ -950,9 +951,18 @@ describe('StreamChat.queryChannels', async () => { it('does not weld a jumped/older window into the newest page when re-hydrating a shared channel on re-query', async () => { const client = await getClientWithUser(); const newest = [ - generateMsg({ id: 'm5', created_at: '2023-11-14T12:00:05.000Z' }), - generateMsg({ id: 'm6', created_at: '2023-11-14T12:00:06.000Z' }), - generateMsg({ id: 'm7', created_at: '2023-11-14T12:00:07.000Z' }), + generateMsg({ + id: 'm5', + created_at: convertDateToTimestamp('2023-11-14T12:00:05.000Z'), + }), + generateMsg({ + id: 'm6', + created_at: convertDateToTimestamp('2023-11-14T12:00:06.000Z'), + }), + generateMsg({ + id: 'm7', + created_at: convertDateToTimestamp('2023-11-14T12:00:07.000Z'), + }), ]; const stub = sinon.stub(client, 'queryChannels').resolves({ channels: [{ ...mockChannelQueryResponse, messages: newest }], @@ -967,10 +977,16 @@ describe('StreamChat.queryChannels', async () => { // active (visible) interval while the newest window stays loaded as a separate interval. const older = [ channel.state.formatMessage( - generateMsg({ id: 'm1', created_at: '2023-11-14T12:00:01.000Z' }), + generateMsg({ + id: 'm1', + created_at: convertDateToTimestamp('2023-11-14T12:00:01.000Z'), + }), ), channel.state.formatMessage( - generateMsg({ id: 'm2', created_at: '2023-11-14T12:00:02.000Z' }), + generateMsg({ + id: 'm2', + created_at: convertDateToTimestamp('2023-11-14T12:00:02.000Z'), + }), ), ]; channel.messagePaginator.ingestPage({ @@ -1000,9 +1016,18 @@ describe('StreamChat.queryChannels', async () => { it('reconciles a trailing offline hard-delete on channel-list re-hydrate (cold-boot path)', async () => { const client = await getClientWithUser(); const full = [ - generateMsg({ id: 'm5', created_at: '2023-11-14T12:00:05.000Z' }), - generateMsg({ id: 'm6', created_at: '2023-11-14T12:00:06.000Z' }), - generateMsg({ id: 'm7', created_at: '2023-11-14T12:00:07.000Z' }), + generateMsg({ + id: 'm5', + created_at: convertDateToTimestamp('2023-11-14T12:00:05.000Z'), + }), + generateMsg({ + id: 'm6', + created_at: convertDateToTimestamp('2023-11-14T12:00:06.000Z'), + }), + generateMsg({ + id: 'm7', + created_at: convertDateToTimestamp('2023-11-14T12:00:07.000Z'), + }), ]; const stub = sinon.stub(client, 'queryChannels').resolves({ channels: [{ ...mockChannelQueryResponse, messages: full }], @@ -1396,7 +1421,7 @@ describe('user.updated propagates to message + pinned paginators', () => { cid: channel.cid, user: author, pinned: true, - pinned_at: '2020-01-01T00:00:00.000Z', + pinned_at: convertDateToTimestamp('2020-01-01T00:00:00.000Z'), }); channel.messagePaginator.setItems({ @@ -1488,14 +1513,14 @@ describe('user.messages.deleted (client-level, cross-channel)', () => { cid: channel.cid, user: bannedUser, pinned: true, - pinned_at: '2020-01-01T00:00:00.000Z', + pinned_at: convertDateToTimestamp('2020-01-01T00:00:00.000Z'), }); const otherPinned = generateMsg({ id: `${id}-op`, cid: channel.cid, user: otherUser, pinned: true, - pinned_at: '2020-01-02T00:00:00.000Z', + pinned_at: convertDateToTimestamp('2020-01-02T00:00:00.000Z'), }); channel.messagePaginator.setItems({ valueOrFactory: [main], @@ -1519,7 +1544,7 @@ describe('user.messages.deleted (client-level, cross-channel)', () => { cid: channel.cid, user: bannedUser, hard_delete: true, - created_at: '2025-01-01T00:00:00.000Z', + created_at: convertDateToTimestamp('2025-01-01T00:00:00.000Z'), }); // cid present → the client-level cross-channel loop must be a no-op (no double-delete). @@ -1534,7 +1559,7 @@ describe('user.messages.deleted (client-level, cross-channel)', () => { type: 'user.messages.deleted', user: bannedUser, soft_delete: true, - created_at: '2025-01-01T00:00:00.000Z', + created_at: convertDateToTimestamp('2025-01-01T00:00:00.000Z'), }); channels.forEach((channel) => { @@ -1557,7 +1582,7 @@ describe('user.messages.deleted (client-level, cross-channel)', () => { type: 'user.messages.deleted', user: bannedUser, hard_delete: true, - created_at: '2025-01-01T00:00:00.000Z', + created_at: convertDateToTimestamp('2025-01-01T00:00:00.000Z'), }); channels.forEach((channel) => { @@ -1580,11 +1605,11 @@ describe('user.messages.deleted — quoted_message regression (#1736)', () => { const setupChannelWithSelfQuote = (type, id) => { const m1 = generateMsg({ - created_at: new Date('2020-01-01T00:00:01.000Z'), + created_at: convertDateToTimestamp(new Date('2020-01-01T00:00:01.000Z')), user: bannedUser, }); const m2 = generateMsg({ - created_at: new Date('2020-01-01T00:00:02.000Z'), + created_at: convertDateToTimestamp(new Date('2020-01-01T00:00:02.000Z')), user: bannedUser, quoted_message: m1, quoted_message_id: m1.id, @@ -1608,7 +1633,7 @@ describe('user.messages.deleted — quoted_message regression (#1736)', () => { type: 'user.messages.deleted', user: bannedUser, hard_delete: true, - created_at: '2025-02-01T14:01:30.000Z', + created_at: convertDateToTimestamp('2025-02-01T14:01:30.000Z'), }; expect(() => client._handleClientEvent(event)).not.toThrow(); @@ -1630,7 +1655,7 @@ describe('user.messages.deleted — quoted_message regression (#1736)', () => { type: 'user.messages.deleted', user: bannedUser, hard_delete: true, - created_at: '2025-02-01T14:01:30.000Z', + created_at: convertDateToTimestamp('2025-02-01T14:01:30.000Z'), }; expect(() => client.dispatchEvent(event)).not.toThrow(); @@ -1642,9 +1667,12 @@ describe('user.messages.deleted — quoted_message regression (#1736)', () => { const event = { type: 'user.deleted', - user: { ...bannedUser, deleted_at: '2025-02-01T14:01:30.000Z' }, + user: { + ...bannedUser, + deleted_at: convertDateToTimestamp('2025-02-01T14:01:30.000Z'), + }, hard_delete: true, - created_at: '2025-02-01T14:01:30.000Z', + created_at: convertDateToTimestamp('2025-02-01T14:01:30.000Z'), }; expect(() => client._handleClientEvent(event)).not.toThrow(); @@ -1938,3 +1966,39 @@ describe('activeChannels eviction when the current user is removed (#2599)', () expect(client.activeChannels[notifDeleted.cid]).to.be.undefined; }); }); + +describe('_normalizeExpiration', () => { + let client; + + beforeEach(async () => { + client = await getClientWithUser({ id: 'user' }); + }); + + it('reads a number as an offset in seconds', () => { + const before = Date.now(); + const iso = client._normalizeExpiration(3600); + const offsetMs = new Date(iso).getTime() - before; + expect(offsetMs).toBeGreaterThanOrEqual(3600 * 1000 - 1000); + expect(offsetMs).toBeLessThanOrEqual(3600 * 1000 + 1000); + }); + + it.each([ + ['a Date', new Date('2026-12-25T00:00:00.000Z'), '2026-12-25T00:00:00.000Z'], + ['a string', '2026-12-25T00:00:00.000Z', '2026-12-25T00:00:00.000Z'], + ['null', null, null], + ['undefined', undefined, null], + ])('passes %s through', (_label, input, expected) => { + expect(client._normalizeExpiration(input)).toBe(expected); + }); + + // A wire timestamp (~1.79e18) added as seconds leaves `Date`'s range. That used to throw an + // opaque `RangeError` from `toISOString()`, as an unhandled rejection inside `pinMessage`. + it.each([ + ['a wire nanosecond timestamp', 1798156800000000000], + ['NaN', Number.NaN], + ])('throws an actionable error for %s', (_label, input) => { + expect(() => client._normalizeExpiration(input)).toThrow( + /does not resolve to a valid date/, + ); + }); +}); diff --git a/test/unit/configuration/channel.config.test.ts b/test/unit/configuration/channel.config.test.ts index 4fa1d57272..f229b74a20 100644 --- a/test/unit/configuration/channel.config.test.ts +++ b/test/unit/configuration/channel.config.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { getClientWithUser } from '../test-utils/getClient'; import type { Channel } from '../../../src/channel'; import type { StreamChat } from '../../../src/client'; +import { convertDateToTimestamp } from '../test-utils/time'; describe("the 'channel' configuration key", () => { let client: StreamChat; @@ -210,8 +211,14 @@ describe("the 'channel' configuration key", () => { // stronger claim: the reset restored *the* comparator, not a lookalike. expect(channel.messagePaginator.config.itemOrderComparator).toBe(original); expect(typeof channel.messagePaginator.config.itemOrderComparator).toBe('function'); - const older = { id: 'a', created_at: new Date('2020-01-01') } as never; - const newer = { id: 'b', created_at: new Date('2021-01-01') } as never; + const older = { + id: 'a', + created_at: convertDateToTimestamp(new Date('2020-01-01')), + } as never; + const newer = { + id: 'b', + created_at: convertDateToTimestamp(new Date('2021-01-01')), + } as never; expect( channel.messagePaginator.config.itemOrderComparator?.(older, newer), ).toBeLessThan(0); diff --git a/test/unit/configuration/instanceConfiguration.integration.test.ts b/test/unit/configuration/instanceConfiguration.integration.test.ts index 1b5ae384e9..a74a106a0c 100644 --- a/test/unit/configuration/instanceConfiguration.integration.test.ts +++ b/test/unit/configuration/instanceConfiguration.integration.test.ts @@ -7,6 +7,7 @@ import { mockChannelQueryResponse } from '../test-utils/mockChannelQueryResponse import { StreamChat } from '../../../src/client'; import { Thread } from '../../../src/thread'; import type { Channel } from '../../../src/channel'; +import { convertDateToTimestamp } from '../test-utils/time'; /** * Cross-instance coverage: all four built-in keys, both tiers, both registration orders, reset, the @@ -595,8 +596,14 @@ describe('instance configuration — cross-instance', () => { // Re-derivation, not a snapshot: this is the property that makes reset trustworthy when // teardowns are integrator-written. expect(channel.messagePaginator.config.pageSize).toBe(100); - const older = { id: 'a', created_at: new Date('2020-01-01') } as never; - const newer = { id: 'b', created_at: new Date('2021-01-01') } as never; + const older = { + id: 'a', + created_at: convertDateToTimestamp(new Date('2020-01-01')), + } as never; + const newer = { + id: 'b', + created_at: convertDateToTimestamp(new Date('2021-01-01')), + } as never; expect( channel.messagePaginator.config.itemOrderComparator?.(older, newer), ).toBeLessThan(0); diff --git a/test/unit/connection.test.js b/test/unit/connection.test.js index bc5d039cb6..07da0c810d 100644 --- a/test/unit/connection.test.js +++ b/test/unit/connection.test.js @@ -13,12 +13,12 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; // the real Stream backend sends on the first message. Used with // `client.options.WebSocketImpl` so tests never touch the network. const HEALTH_CHECK_PAYLOAD = - '{"type":"health.check","connection_id":"61112366-0a15-3891-0000-000000000009","cid":"*","me":{"id":"amin","role":"user","created_at":"2021-07-27T13:18:23.293696Z","updated_at":"2021-07-27T13:20:08.047284Z","last_active":"2021-08-11T10:42:44.213510048Z","banned":false,"online":true,"invisible":false,"devices":[],"mutes":[],"channel_mutes":[],"unread_count":98,"total_unread_count":98,"unread_channels":18,"language":"","image":"https://cdn.fakercloud.com/avatars/Shriiiiimp_128.jpg","name":"amin"},"created_at":"2021-08-11T10:42:44.222203145Z"}'; + '{"type":"health.check","connection_id":"61112366-0a15-3891-0000-000000000009","cid":"*","me":{"id":"amin","role":"user","created_at":1627391903293696000,"updated_at":1627392008047284000,"last_active":1628678564213510048,"banned":false,"online":true,"invisible":false,"devices":[],"mutes":[],"channel_mutes":[],"unread_count":98,"total_unread_count":98,"unread_channels":18,"language":"","image":"https://cdn.fakercloud.com/avatars/Shriiiiimp_128.jpg","name":"amin"},"created_at":1628678564222203145}'; // The `/api/v2/connect` hello frame. Unlike `health.check` it has no `cid`, and it // carries the duplicate `chat` block alongside `me`. const CONNECTION_OK_PAYLOAD = - '{"type":"connection.ok","connection_id":"61112366-0a15-3891-0000-000000000009","me":{"id":"amin","role":"user","created_at":"2021-07-27T13:18:23.293696Z","updated_at":"2021-07-27T13:20:08.047284Z","last_active":"2021-08-11T10:42:44.213510048Z","banned":false,"online":true,"invisible":false,"devices":[],"mutes":[],"channel_mutes":[],"unread_count":98,"total_unread_count":98,"unread_channels":18,"language":"","image":"https://cdn.fakercloud.com/avatars/Shriiiiimp_128.jpg","name":"amin"},"chat":{"mutes":[],"channel_mutes":[],"total_unread_count":98,"unread_channels":18,"unread_threads":0,"latest_hidden_channels":null},"created_at":"2021-08-11T10:42:44.222203145Z"}'; + '{"type":"connection.ok","connection_id":"61112366-0a15-3891-0000-000000000009","me":{"id":"amin","role":"user","created_at":1627391903293696000,"updated_at":1627392008047284000,"last_active":1628678564213510048,"banned":false,"online":true,"invisible":false,"devices":[],"mutes":[],"channel_mutes":[],"unread_count":98,"total_unread_count":98,"unread_channels":18,"language":"","image":"https://cdn.fakercloud.com/avatars/Shriiiiimp_128.jpg","name":"amin"},"chat":{"mutes":[],"channel_mutes":[],"total_unread_count":98,"unread_channels":18,"unread_threads":0,"latest_hidden_channels":null},"created_at":1628678564222203145}'; class MockWebSocket { static CONNECTING = 0; @@ -325,14 +325,14 @@ describe('connection', function () { expect(c.isHealthy).to.be.true; }); - it('should decode dates on the event', async () => { + it('passes wire timestamps through untouched', async () => { const c = new StableWSConnection({ client: newStreamChat() }); const health = await c.connect(); - // connection.ok is absent from the generated decoders, so without the shim in - // connection.ts these would still be strings. - expect(health.created_at).to.be.instanceOf(Date); - expect(health.me.created_at).to.be.instanceOf(Date); + // Nothing decodes frames any more: every timestamp stays the unix-nanosecond number the + // API put on the wire, `connection.ok` included. + expect(health.created_at).to.be.a('number'); + expect(health.me.created_at).to.be.a('number'); }); it('should schedule the next ping', async () => { diff --git a/test/unit/messageDelivery/MessageDeliveryReporter.test.ts b/test/unit/messageDelivery/MessageDeliveryReporter.test.ts index 8417327c6a..0d8e703d30 100644 --- a/test/unit/messageDelivery/MessageDeliveryReporter.test.ts +++ b/test/unit/messageDelivery/MessageDeliveryReporter.test.ts @@ -16,6 +16,7 @@ import { } from '../../../src'; import type { AxiosResponse } from 'axios'; import { stubServerConfig } from '../test-utils/stubServerConfig'; +import { convertDateToTimestamp } from '../test-utils/time'; const channelType = 'messaging'; const channelId = 'channelId'; @@ -28,7 +29,7 @@ const otherUser = { id: 'otherUser', }; const mkMsg = (id: string, at: string | number | Date) => - ({ id, created_at: new Date(at) }) as any; + ({ id, created_at: convertDateToTimestamp(new Date(at)) }) as any; // The delivery reporter now derives the latest message from `channel.messagePaginator.headItems`, // so tests seed the paginator's latest (head) window instead of assigning `channel.state.latestMessages`. @@ -79,7 +80,9 @@ describe('MessageDeliveryReporter', () => { // last_read < last message setLatest(channel, [mkMsg('m1', '2025-01-01T10:00:00Z')]); - (channel.state as any).read['me'] = { last_read: new Date('2025-01-01T09:00:00Z') }; + (channel.state as any).read['me'] = { + last_read: convertDateToTimestamp(new Date('2025-01-01T09:00:00Z')), + }; client.syncDeliveredCandidates([channel]); expect(markDeliveredSpy).not.toHaveBeenCalled(); @@ -108,7 +111,9 @@ describe('MessageDeliveryReporter', () => { const channel = client.channel(channelType, i.toString()); channel.initialized = true; setLatest(channel, [mkMsg('m1', '2025-01-01T10:00:00Z')]); - (channel.state as any).read['me'] = { last_read: new Date('2025-01-01T09:00:00Z') }; + (channel.state as any).read['me'] = { + last_read: convertDateToTimestamp(new Date('2025-01-01T09:00:00Z')), + }; return channel; }); channels.forEach((ch) => { @@ -148,7 +153,9 @@ describe('MessageDeliveryReporter', () => { .mockResolvedValue({ ok: true } as any); setLatest(channel, [mkMsg('m1', '2025-01-01T10:00:00Z')]); - (channel.state as any).read['me'] = { last_read: new Date('2025-01-01T09:00:00Z') }; + (channel.state as any).read['me'] = { + last_read: convertDateToTimestamp(new Date('2025-01-01T09:00:00Z')), + }; client.syncDeliveredCandidates([channel]); vi.advanceTimersByTime(1000); @@ -172,7 +179,9 @@ describe('MessageDeliveryReporter', () => { .mockResolvedValue({ ok: true } as any); setLatest(channel, [mkMsg('m1', '2025-01-01T10:00:00Z')]); - (channel.state as any).read['me'] = { last_read: new Date('2025-01-01T09:00:00Z') }; + (channel.state as any).read['me'] = { + last_read: convertDateToTimestamp(new Date('2025-01-01T09:00:00Z')), + }; client.syncDeliveredCandidates([channel]); vi.advanceTimersByTime(1000); @@ -187,8 +196,8 @@ describe('MessageDeliveryReporter', () => { setLatest(channel, [mkMsg('m1', '2025-01-01T10:00:00Z')]); (channel.state as any).read['me'] = { - last_read: new Date('2025-01-01T09:00:00Z'), - last_delivered_at: new Date('2025-01-01T11:00:00Z'), + last_read: convertDateToTimestamp(new Date('2025-01-01T09:00:00Z')), + last_delivered_at: convertDateToTimestamp(new Date('2025-01-01T11:00:00Z')), }; client.syncDeliveredCandidates([channel]); @@ -244,7 +253,9 @@ describe('MessageDeliveryReporter', () => { .mockResolvedValue({} as any); setLatest(channel, [mkMsg('m1', 1000)]); - (channel.state as any).read['me'] = { last_read: new Date(0) }; + (channel.state as any).read['me'] = { + last_read: convertDateToTimestamp(new Date(0)), + }; client.syncDeliveredCandidates([channel]); @@ -261,7 +272,9 @@ describe('MessageDeliveryReporter', () => { .spyOn(client, 'markDelivered') .mockResolvedValue({} as any); - (channel.state as any).read['me'] = { last_read: new Date('2025-01-01T09:00:00Z') }; + (channel.state as any).read['me'] = { + last_read: convertDateToTimestamp(new Date('2025-01-01T09:00:00Z')), + }; setLatest(channel, [mkMsg('m1', '2025-01-01T10:00:00Z')]); client.syncDeliveredCandidates([channel]); @@ -300,7 +313,7 @@ describe('MessageDeliveryReporter', () => { const ch1 = client.channel('messaging', 'ch1'); ch1.initialized = true; - (ch1.state as any).read['me'] = { last_read: new Date(0) }; + (ch1.state as any).read['me'] = { last_read: convertDateToTimestamp(new Date(0)) }; setLatest(ch1, [mkMsg('m1', 1000)]); const ch2 = client.channel('messaging', 'ch2'); @@ -335,7 +348,7 @@ describe('MessageDeliveryReporter', () => { }); // While request is in-flight, a new candidate (different channel) arrives. - (ch2.state as any).read['me'] = { last_read: new Date(0) }; + (ch2.state as any).read['me'] = { last_read: convertDateToTimestamp(new Date(0)) }; setLatest(ch2, [mkMsg('n1', 2000)]); client.syncDeliveredCandidates([ch2]); @@ -380,7 +393,9 @@ describe('MessageDeliveryReporter', () => { .mockResolvedValue({} as any); vi.spyOn(channel, 'markRead').mockResolvedValue({} as any); - (channel.state as any).read['me'] = { last_read: new Date(0) }; + (channel.state as any).read['me'] = { + last_read: convertDateToTimestamp(new Date(0)), + }; setLatest(channel, [mkMsg('m1', 1000)]); client.syncDeliveredCandidates([channel]); @@ -400,7 +415,7 @@ describe('MessageDeliveryReporter', () => { channel_id: channelId, channel_type: channelType, cid: `${channelType}:${channelId}`, - created_at: new Date(), + created_at: convertDateToTimestamp(new Date()), type: 'message.read', }; @@ -453,7 +468,9 @@ describe('MessageDeliveryReporter', () => { const channel = client.channel(channelType, (i + startId).toString()); channel.initialized = true; setLatest(channel, [mkMsg('m1', '2025-01-01T10:00:00Z')]); - (channel.state as any).read['me'] = { last_read: new Date('2025-01-01T09:00:00Z') }; + (channel.state as any).read['me'] = { + last_read: convertDateToTimestamp(new Date('2025-01-01T09:00:00Z')), + }; return channel; }); channels.forEach((ch) => { @@ -635,7 +652,9 @@ describe('MessageDeliveryReporter', () => { const markDeliveredSpy = vi.spyOn(client, 'markDelivered'); vi.spyOn(channel, 'markRead').mockRejectedValue({} as any); - (channel.state as any).read['me'] = { last_read: new Date(0) }; + (channel.state as any).read['me'] = { + last_read: convertDateToTimestamp(new Date(0)), + }; setLatest(channel, [mkMsg('m1', 1000)]); client.syncDeliveredCandidates([channel]); @@ -676,13 +695,15 @@ describe('MessageDeliveryReporter', () => { .spyOn(client, 'markDelivered') .mockResolvedValue({} as any); - (channel.state as any).read['me'] = { last_read: new Date(0) }; + (channel.state as any).read['me'] = { + last_read: convertDateToTimestamp(new Date(0)), + }; setLatest(channel, []); // simulate incoming message.new event const ev: Event = { type: 'message.new', - created_at: new Date('2025-01-01T10:00:00Z'), + created_at: convertDateToTimestamp(new Date('2025-01-01T10:00:00Z')), user: otherUser, // cid must match the paginator filter so message.new ingests into an interval message: { ...mkMsg('m1', '2025-01-01T10:00:00Z'), cid: channel.cid } as any, @@ -708,13 +729,15 @@ describe('MessageDeliveryReporter', () => { .spyOn(client, 'markDelivered') .mockResolvedValue({} as any); - (channel.state as any).read['me'] = { last_read: new Date(0) }; + (channel.state as any).read['me'] = { + last_read: convertDateToTimestamp(new Date(0)), + }; setLatest(channel, []); // simulate incoming message.new event const ev: Event = { type: 'message.new', - created_at: new Date('2025-01-01T10:00:00Z'), + created_at: convertDateToTimestamp(new Date('2025-01-01T10:00:00Z')), user: ownUser, message: mkMsg('m1', '2025-01-01T10:00:00Z') as any, }; @@ -731,14 +754,16 @@ describe('MessageDeliveryReporter', () => { .spyOn(client, 'markDelivered') .mockResolvedValue({} as any); - (channel.state as any).read['me'] = { last_read: new Date(0) }; + (channel.state as any).read['me'] = { + last_read: convertDateToTimestamp(new Date(0)), + }; setLatest(channel, [mkMsg('m1', '2025-01-01T10:00:00Z')]); client.syncDeliveredCandidates([channel]); const ev: Event = { type: 'message.read', - created_at: new Date('2025-01-01T10:00:00Z'), + created_at: convertDateToTimestamp(new Date('2025-01-01T10:00:00Z')), last_read_message_id: 'm1', message: mkMsg('m1', '2025-01-01T10:00:00Z') as any, user: ownUser, @@ -756,14 +781,16 @@ describe('MessageDeliveryReporter', () => { .spyOn(client, 'markDelivered') .mockResolvedValue({} as any); - (channel.state as any).read['me'] = { last_read: new Date(0) }; + (channel.state as any).read['me'] = { + last_read: convertDateToTimestamp(new Date(0)), + }; setLatest(channel, [mkMsg('m1', '2025-01-01T10:00:00Z')]); client.syncDeliveredCandidates([channel]); const ev: Event = { type: 'message.read', - created_at: new Date('2025-01-01T10:00:00Z'), + created_at: convertDateToTimestamp(new Date('2025-01-01T10:00:00Z')), last_read_message_id: 'm1', message: mkMsg('m1', '2025-01-01T10:00:00Z') as any, user: otherUser, diff --git a/test/unit/messageDelivery/MessageReceiptsTracker.test.ts b/test/unit/messageDelivery/MessageReceiptsTracker.test.ts index e338b25ad5..caf90f4614 100644 --- a/test/unit/messageDelivery/MessageReceiptsTracker.test.ts +++ b/test/unit/messageDelivery/MessageReceiptsTracker.test.ts @@ -10,9 +10,8 @@ import type { Channel } from '../../../src/channel'; const ownUserId = 'author'; const U = (id: string): UserResponse => ({ id, name: id }); // matches UserResponse shape for the service -// Read/delivery timestamps are `Date` in the OpenAPI-aligned tracker API; this helper builds one -// from a millisecond value (the tracker no longer accepts ISO strings). -const iso = (ms: number): Date => new Date(ms); +// Read/delivery timestamps are plain wire numbers, in the same space as the message timeline below, +// so they are written inline — there is nothing left to convert. // Timeline: 4 messages with ascending timestamps const msgs = [ @@ -23,18 +22,18 @@ const msgs = [ ] as const; const byTs = new Map(msgs.map((m) => [m.ts, m])); -const ref = (ts: number): MsgRef => ({ timestampMs: ts, msgId: byTs.get(ts)!.id }); +const ref = (ts: number): MsgRef => ({ timestamp: ts, msgId: byTs.get(ts)!.id }); -const defaultFindMessageByTimestamp = (timestampMs?: number) => { - if (!timestampMs) return undefined; - const m = byTs.get(timestampMs); +const defaultFindMessageByTimestamp = (timestamp?: number) => { + if (!timestamp) return undefined; + const m = byTs.get(timestamp); return m ? { id: m.id } : undefined; }; const createChannelMock = ({ findMessageByTimestamp = defaultFindMessageByTimestamp, }: { - findMessageByTimestamp?: (timestampMs?: number) => { id: string } | undefined; + findMessageByTimestamp?: (timestamp?: number) => { id: string } | undefined; } = {}) => { const readStore = new StateStore({ read: {}, @@ -70,8 +69,8 @@ describe('MessageDeliveryReadTracker', () => { describe('constructor', () => { it('allows locateMessage constructor override while requiring channel', () => { - const customLocateMessage = vi.fn((timestampMs: number) => ({ - timestampMs, + const customLocateMessage = vi.fn((timestamp: number) => ({ + timestamp, msgId: 'custom', })); const trackerWithCustomLocator = new MessageReceiptsTracker({ @@ -81,7 +80,7 @@ describe('MessageDeliveryReadTracker', () => { trackerWithCustomLocator.onMessageRead({ user: U('compat-user'), - readAt: iso(2000), + readAt: 2000, }); expect(customLocateMessage).toHaveBeenCalledWith(2000); @@ -101,13 +100,13 @@ describe('MessageDeliveryReadTracker', () => { const snapshot: ReadStateResponse[] = [ { user: alice, - last_read: new Date(2000), - last_delivered_at: new Date(1000), + last_read: 2000, + last_delivered_at: 1000, }, { user: bob, - last_read: new Date(500), - last_delivered_at: new Date(3000), + last_read: 500, + last_delivered_at: 3000, }, ]; @@ -119,7 +118,7 @@ describe('MessageDeliveryReadTracker', () => { expect(pAlice.lastReadRef).toEqual(ref(2000)); expect(pAlice.lastDeliveredRef).toEqual(ref(2000)); // bumped up - expect(pBob.lastReadRef.timestampMs).toBe(Number.NEGATIVE_INFINITY); + expect(pBob.lastReadRef.timestamp).toBe(Number.NEGATIVE_INFINITY); expect(pBob.lastDeliveredRef).toEqual(ref(3000)); // Readers of m2: Alice only @@ -136,8 +135,8 @@ describe('MessageDeliveryReadTracker', () => { const snapshot: ReadStateResponse[] = [ { user: ownUser, - last_read: new Date(2000), - last_delivered_at: new Date(1000), + last_read: 2000, + last_delivered_at: 1000, }, ]; @@ -154,21 +153,21 @@ describe('MessageDeliveryReadTracker', () => { expect(p0).toBeNull(); // first read at m3 - tracker.onMessageRead({ user: carol, readAt: new Date(3000) }); + tracker.onMessageRead({ user: carol, readAt: 3000 }); const p1 = tracker.getUserProgress('carol')!; expect(p1.lastReadRef).toEqual(ref(3000)); expect(p1.lastDeliveredRef).toEqual(ref(3000)); // bumped // older/equal reads are no-ops - tracker.onMessageRead({ user: carol, readAt: new Date(2000) }); - tracker.onMessageRead({ user: carol, readAt: new Date(3000) }); + tracker.onMessageRead({ user: carol, readAt: 2000 }); + tracker.onMessageRead({ user: carol, readAt: 3000 }); const p2 = tracker.getUserProgress('carol')!; expect(p2.lastReadRef).toEqual(ref(3000)); expect(p2.lastDeliveredRef).toEqual(ref(3000)); // later read moves forward and bumps delivered - tracker.onMessageRead({ user: carol, readAt: new Date(4000) }); + tracker.onMessageRead({ user: carol, readAt: 4000 }); const p3 = tracker.getUserProgress('carol')!; expect(p3.lastReadRef).toEqual(ref(4000)); expect(p3.lastDeliveredRef).toEqual(ref(4000)); @@ -183,11 +182,11 @@ describe('MessageDeliveryReadTracker', () => { tracker = new MessageReceiptsTracker({ channel: channelMock.channel }); const dave = U('dave'); - tracker.onMessageRead({ user: dave, readAt: new Date(4000) }); // unknown -> ignored + tracker.onMessageRead({ user: dave, readAt: 4000 }); // unknown -> ignored expect(tracker.getUserProgress('dave')).toBeNull(); // but a known read creates progress - tracker.onMessageRead({ user: dave, readAt: new Date(2000) }); + tracker.onMessageRead({ user: dave, readAt: 2000 }); const pd = tracker.getUserProgress('dave')!; expect(pd.lastReadRef).toEqual(ref(2000)); expect(pd.lastDeliveredRef).toEqual(ref(2000)); @@ -200,18 +199,18 @@ describe('MessageDeliveryReadTracker', () => { const user = U('frank'); tracker.onMessageRead({ user, - readAt: new Date(3000), + readAt: 3000, lastReadMessageId: 'X', }); // unknown -> ignored expect(findMessageByTimestamp).not.toHaveBeenCalled(); expect(tracker.getUserProgress('frank')).toStrictEqual({ lastDeliveredRef: { msgId: 'X', - timestampMs: 3000, + timestamp: 3000, }, lastReadRef: { msgId: 'X', - timestampMs: 3000, + timestamp: 3000, }, user: { id: 'frank', @@ -222,7 +221,7 @@ describe('MessageDeliveryReadTracker', () => { it('does not ignore own message.read events', () => { const ownUser = U(ownUserId); - tracker.onMessageRead({ user: ownUser, readAt: new Date(2000) }); + tracker.onMessageRead({ user: ownUser, readAt: 2000 }); expect(tracker.getUserProgress(ownUserId)!.user).toStrictEqual(ownUser); }); }); @@ -231,26 +230,26 @@ describe('MessageDeliveryReadTracker', () => { it('creates user on first delivered; uses max(read, delivered)', () => { const eve = U('eve'); - tracker.onMessageDelivered({ user: eve, deliveredAt: new Date(2000) }); + tracker.onMessageDelivered({ user: eve, deliveredAt: 2000 }); let progressEve = tracker.getUserProgress('eve')!; expect(progressEve.lastDeliveredRef).toEqual(ref(2000)); - expect(progressEve.lastReadRef.timestampMs).toBe(Number.NEGATIVE_INFINITY); + expect(progressEve.lastReadRef.timestamp).toBe(Number.NEGATIVE_INFINITY); // deliver older/equal -> no-op - tracker.onMessageDelivered({ user: eve, deliveredAt: new Date(1000) }); - tracker.onMessageDelivered({ user: eve, deliveredAt: new Date(2000) }); + tracker.onMessageDelivered({ user: eve, deliveredAt: 1000 }); + tracker.onMessageDelivered({ user: eve, deliveredAt: 2000 }); progressEve = tracker.getUserProgress('eve')!; expect(progressEve.lastDeliveredRef).toEqual(ref(2000)); // if read goes ahead to m3, and a delivery arrives for m2, // newDelivered = max(read, deliveredEvent) = read (m3) - tracker.onMessageRead({ user: eve, readAt: new Date(3000) }); + tracker.onMessageRead({ user: eve, readAt: 3000 }); progressEve = tracker.getUserProgress('eve')!; expect(progressEve.lastReadRef).toEqual(ref(3000)); expect(progressEve.lastDeliveredRef).toEqual(ref(3000)); // bumped by read // deliver at m4 -> moves forward - tracker.onMessageDelivered({ user: eve, deliveredAt: new Date(4000) }); + tracker.onMessageDelivered({ user: eve, deliveredAt: 4000 }); progressEve = tracker.getUserProgress('eve')!; expect(progressEve.lastDeliveredRef).toEqual(ref(4000)); expect(progressEve.lastReadRef).toEqual(ref(3000)); @@ -264,10 +263,10 @@ describe('MessageDeliveryReadTracker', () => { tracker = new MessageReceiptsTracker({ channel: channelMock.channel }); const frank = U('frank'); - tracker.onMessageDelivered({ user: frank, deliveredAt: new Date(3000) }); // unknown -> ignored + tracker.onMessageDelivered({ user: frank, deliveredAt: 3000 }); // unknown -> ignored expect(tracker.getUserProgress('frank')).toBeNull(); - tracker.onMessageDelivered({ user: frank, deliveredAt: new Date(2000) }); // known -> creates + tracker.onMessageDelivered({ user: frank, deliveredAt: 2000 }); // known -> creates const pf = tracker.getUserProgress('frank')!; expect(pf.lastDeliveredRef).toEqual(ref(2000)); }); @@ -279,18 +278,18 @@ describe('MessageDeliveryReadTracker', () => { const user = U('frank'); tracker.onMessageDelivered({ user, - deliveredAt: new Date(3000), + deliveredAt: 3000, lastDeliveredMessageId: 'X', }); // unknown -> ignored expect(findMessageByTimestamp).not.toHaveBeenCalled(); expect(tracker.getUserProgress('frank')).toStrictEqual({ lastDeliveredRef: { msgId: 'X', - timestampMs: 3000, + timestamp: 3000, }, lastReadRef: { msgId: '', - timestampMs: Number.NEGATIVE_INFINITY, + timestamp: Number.NEGATIVE_INFINITY, }, user: { id: 'frank', @@ -301,7 +300,7 @@ describe('MessageDeliveryReadTracker', () => { it('does not ignore own message.delivered events', () => { const ownUser = U(ownUserId); - tracker.onMessageDelivered({ user: ownUser, deliveredAt: new Date(2000) }); + tracker.onMessageDelivered({ user: ownUser, deliveredAt: 2000 }); expect(tracker.getUserProgress(ownUserId)!.user).toStrictEqual(ownUser); }); }); @@ -311,13 +310,13 @@ describe('MessageDeliveryReadTracker', () => { it('moves lastRead backward to the event boundary and keeps delivered unchanged (no backward move)', () => { tracker.onMessageRead({ user, - readAt: new Date(3000), + readAt: 3000, lastReadMessageId: 'm3', }); tracker.onNotificationMarkUnread({ user, - lastReadAt: new Date(2000), + lastReadAt: 2000, lastReadMessageId: 'm2', }); @@ -337,12 +336,12 @@ describe('MessageDeliveryReadTracker', () => { // v delivered m4 and read m2 tracker.onMessageDelivered({ user, - deliveredAt: new Date(4000), + deliveredAt: 4000, lastDeliveredMessageId: 'm4', }); tracker.onMessageRead({ user, - readAt: new Date(2000), + readAt: 2000, lastReadMessageId: 'm2', }); @@ -356,19 +355,19 @@ describe('MessageDeliveryReadTracker', () => { }); userProgress = tracker.getUserProgress(user.id)!; - expect(userProgress.lastReadRef.timestampMs).toBe(Number.NEGATIVE_INFINITY); + expect(userProgress.lastReadRef.timestamp).toBe(Number.NEGATIVE_INFINITY); expect(userProgress.lastReadRef.msgId).toBe(''); // delivered remains ahead (not decreased) expect(userProgress.lastDeliveredRef).toEqual(ref(4000)); }); it('is a no-op when the provided last_read equals current lastReadRef', () => { - tracker.onMessageRead({ user, readAt: new Date(3000) }); + tracker.onMessageRead({ user, readAt: 3000 }); const before = structuredClone(tracker.getUserProgress(user.id)!); tracker.onNotificationMarkUnread({ user, - lastReadAt: new Date(3000), + lastReadAt: 3000, lastReadMessageId: 'm3', }); @@ -386,7 +385,7 @@ describe('MessageDeliveryReadTracker', () => { tracker.onNotificationMarkUnread({ user, - lastReadAt: new Date(2000), + lastReadAt: 2000, lastReadMessageId: 'm2', }); @@ -408,7 +407,7 @@ describe('MessageDeliveryReadTracker', () => { channelMock.readStore.next({ read: { [user.id]: { - last_read: new Date(2000), + last_read: 2000, user, unread_messages: 0, last_read_message_id: 'm2', @@ -421,7 +420,7 @@ describe('MessageDeliveryReadTracker', () => { channelMock.readStore.next({ read: { [user.id]: { - last_read: new Date(3000), + last_read: 3000, user, unread_messages: 0, last_read_message_id: 'm3', @@ -441,11 +440,11 @@ describe('MessageDeliveryReadTracker', () => { const c = U('c'); // a: read m3, delivered m3 - tracker.onMessageRead({ user: a, readAt: new Date(3000) }); + tracker.onMessageRead({ user: a, readAt: 3000 }); // b: delivered m3 only (not read) - tracker.onMessageDelivered({ user: b, deliveredAt: new Date(3000) }); + tracker.onMessageDelivered({ user: b, deliveredAt: 3000 }); // c: read m4, delivered m4 - tracker.onMessageRead({ user: c, readAt: new Date(4000) }); + tracker.onMessageRead({ user: c, readAt: 4000 }); // Readers of m2 => a, c expect(ids(tracker.readersForMessage(ref(2000)))).toEqual(['a', 'c']); @@ -461,8 +460,8 @@ describe('MessageDeliveryReadTracker', () => { const u1 = U('u1'); const u2 = U('u2'); - tracker.onMessageDelivered({ user: u1, deliveredAt: new Date(2000) }); // delivered m2 - tracker.onMessageRead({ user: u2, readAt: new Date(3000) }); // read m3 (delivered m3) + tracker.onMessageDelivered({ user: u1, deliveredAt: 2000 }); // delivered m2 + tracker.onMessageRead({ user: u2, readAt: 3000 }); // read m3 (delivered m3) // For m2: expect(tracker.hasUserDelivered(ref(2000), 'u1')).toBe(true); @@ -488,25 +487,25 @@ describe('MessageDeliveryReadTracker', () => { const e = U('e'); // same for delivered side // a: read m2 -> delivered m2 - tracker.onMessageRead({ user: a, readAt: new Date(2000) }); + tracker.onMessageRead({ user: a, readAt: 2000 }); // b: read m3 -> delivered m3 - tracker.onMessageRead({ user: b, readAt: new Date(3000) }); + tracker.onMessageRead({ user: b, readAt: 3000 }); // c: delivered m3 only - tracker.onMessageDelivered({ user: c, deliveredAt: new Date(3000) }); + tracker.onMessageDelivered({ user: c, deliveredAt: 3000 }); // d: read at ts=3000 but with a different msgId "X" (tests plateau filtering by msgId) tracker.onMessageRead({ user: d, - readAt: new Date(3000), + readAt: 3000, lastReadMessageId: 'X', }); // e: delivered at ts=3000 but with a different msgId "X" tracker.onMessageDelivered({ user: e, - deliveredAt: new Date(3000), + deliveredAt: 3000, lastDeliveredMessageId: 'X', }); @@ -527,12 +526,12 @@ describe('MessageDeliveryReadTracker', () => { const user = U('x'); // x reads m2 -> last read m2 (and delivered m2) - tracker.onMessageRead({ user, readAt: new Date(2000) }); + tracker.onMessageRead({ user, readAt: 2000 }); expect(ids(tracker.usersWhoseLastReadIs(ref(2000)))).toEqual(['x']); expect(ids(tracker.usersWhoseLastDeliveredIs(ref(2000)))).toEqual(['x']); // x later reads m4 -> moves out of m2 group and into m4 group - tracker.onMessageRead({ user, readAt: new Date(4000) }); + tracker.onMessageRead({ user, readAt: 4000 }); expect(ids(tracker.usersWhoseLastReadIs(ref(2000)))).toEqual([]); expect(ids(tracker.usersWhoseLastReadIs(ref(4000)))).toEqual(['x']); @@ -542,10 +541,10 @@ describe('MessageDeliveryReadTracker', () => { }); it('returns empty array for empty message id', () => { - expect(tracker.usersWhoseLastReadIs({ timestampMs: 123, msgId: '' })).toEqual([]); - expect( - tracker.usersWhoseLastDeliveredIs({ timestampMs: 123, msgId: '' }), - ).toEqual([]); + expect(tracker.usersWhoseLastReadIs({ timestamp: 123, msgId: '' })).toEqual([]); + expect(tracker.usersWhoseLastDeliveredIs({ timestamp: 123, msgId: '' })).toEqual( + [], + ); }); }); @@ -560,28 +559,28 @@ describe('MessageDeliveryReadTracker', () => { tracker.onMessageDelivered({ user: c, - deliveredAt: iso(2000), + deliveredAt: 2000, lastDeliveredMessageId: '2000', }); tracker.onMessageDelivered({ user: a, - deliveredAt: iso(2000), + deliveredAt: 2000, lastDeliveredMessageId: '2000', }); tracker.onMessageDelivered({ user: e, - deliveredAt: iso(3000), + deliveredAt: 3000, lastDeliveredMessageId: '3000', }); tracker.onMessageDelivered({ user: f, - deliveredAt: iso(3000), + deliveredAt: 3000, lastDeliveredMessageId: '3000', }); - tracker.onMessageRead({ user: a, readAt: iso(1000), lastReadMessageId: '1000' }); - tracker.onMessageRead({ user: d, readAt: iso(3000), lastReadMessageId: '3000' }); - tracker.onMessageRead({ user: b, readAt: iso(3000), lastReadMessageId: '3000' }); + tracker.onMessageRead({ user: a, readAt: 1000, lastReadMessageId: '1000' }); + tracker.onMessageRead({ user: d, readAt: 3000, lastReadMessageId: '3000' }); + tracker.onMessageRead({ user: b, readAt: 3000, lastReadMessageId: '3000' }); expect(tracker.groupUsersByLastDeliveredMessage()).toStrictEqual({ '2000': [c, a], @@ -601,14 +600,14 @@ describe('MessageDeliveryReadTracker', () => { const y = U('y'); // x reads m2, y reads m3 - tracker.onMessageRead({ user: x, readAt: new Date(2000) }); - tracker.onMessageRead({ user: y, readAt: new Date(3000) }); + tracker.onMessageRead({ user: x, readAt: 2000 }); + tracker.onMessageRead({ user: y, readAt: 3000 }); // Readers of m2 -> x, y expect(ids(tracker.readersForMessage(ref(2000)))).toEqual(['x', 'y']); // now x reads m4 (moves past y) - tracker.onMessageRead({ user: x, readAt: new Date(4000) }); + tracker.onMessageRead({ user: x, readAt: 4000 }); // Readers of m3 -> x, y? Actually only x (m4) and y (m3) both >= m3 expect(ids(tracker.readersForMessage(ref(3000)))).toEqual(['y', 'x']); // and of m4 -> x only @@ -618,9 +617,7 @@ describe('MessageDeliveryReadTracker', () => { describe('snapshotStore', () => { it('updates revision on every ingestInitial call', () => { - const snapshot = [ - { user: U('alice'), last_read: iso(2000), last_delivered_at: iso(2000) }, - ]; + const snapshot = [{ user: U('alice'), last_read: 2000, last_delivered_at: 2000 }]; tracker.ingestInitial(snapshot); expect(tracker.snapshotStore.getLatestValue().revision).toBe(1); @@ -634,7 +631,7 @@ describe('MessageDeliveryReadTracker', () => { // changed state -> new revision tracker.ingestInitial([ - { user: U('alice'), last_read: iso(3000), last_delivered_at: iso(3000) }, + { user: U('alice'), last_read: 3000, last_delivered_at: 3000 }, ]); expect(tracker.snapshotStore.getLatestValue().revision).toBe(3); }); @@ -642,42 +639,42 @@ describe('MessageDeliveryReadTracker', () => { it('updates revision for effective message.read changes only', () => { const user = U('reader'); - tracker.onMessageRead({ user, readAt: iso(2000) }); + tracker.onMessageRead({ user, readAt: 2000 }); expect(tracker.snapshotStore.getLatestValue().revision).toBe(1); // same/older read should be a no-op - tracker.onMessageRead({ user, readAt: iso(2000) }); - tracker.onMessageRead({ user, readAt: iso(1000) }); + tracker.onMessageRead({ user, readAt: 2000 }); + tracker.onMessageRead({ user, readAt: 1000 }); expect(tracker.snapshotStore.getLatestValue().revision).toBe(1); - tracker.onMessageRead({ user, readAt: iso(3000) }); + tracker.onMessageRead({ user, readAt: 3000 }); expect(tracker.snapshotStore.getLatestValue().revision).toBe(2); }); it('updates revision for effective message.delivered changes only', () => { const user = U('delivered-user'); - tracker.onMessageDelivered({ user, deliveredAt: iso(2000) }); + tracker.onMessageDelivered({ user, deliveredAt: 2000 }); expect(tracker.snapshotStore.getLatestValue().revision).toBe(1); // same/older delivery should be a no-op - tracker.onMessageDelivered({ user, deliveredAt: iso(2000) }); - tracker.onMessageDelivered({ user, deliveredAt: iso(1000) }); + tracker.onMessageDelivered({ user, deliveredAt: 2000 }); + tracker.onMessageDelivered({ user, deliveredAt: 1000 }); expect(tracker.snapshotStore.getLatestValue().revision).toBe(1); - tracker.onMessageDelivered({ user, deliveredAt: iso(3000) }); + tracker.onMessageDelivered({ user, deliveredAt: 3000 }); expect(tracker.snapshotStore.getLatestValue().revision).toBe(2); }); it('updates revision for effective notification.mark_unread changes only', () => { const user = U('mark-unread-user'); - tracker.onMessageRead({ user, readAt: iso(3000), lastReadMessageId: 'm3' }); + tracker.onMessageRead({ user, readAt: 3000, lastReadMessageId: 'm3' }); expect(tracker.snapshotStore.getLatestValue().revision).toBe(1); tracker.onNotificationMarkUnread({ user, - lastReadAt: iso(2000), + lastReadAt: 2000, lastReadMessageId: 'm2', }); expect(tracker.snapshotStore.getLatestValue().revision).toBe(2); @@ -685,7 +682,7 @@ describe('MessageDeliveryReadTracker', () => { // same boundary -> no-op tracker.onNotificationMarkUnread({ user, - lastReadAt: iso(2000), + lastReadAt: 2000, lastReadMessageId: 'm2', }); expect(tracker.snapshotStore.getLatestValue().revision).toBe(2); @@ -699,42 +696,42 @@ describe('MessageDeliveryReadTracker', () => { const carol = U('carol'); const previousReadState = { [alice.id]: { - last_read: new Date(2000), + last_read: 2000, unread_messages: 0, user: alice, last_read_message_id: 'm2', }, [bob.id]: { - last_read: new Date(3000), + last_read: 3000, unread_messages: 0, user: bob, last_read_message_id: 'm3', - last_delivered_at: new Date(3000), + last_delivered_at: 3000, last_delivered_message_id: 'm3', }, }; const nextReadState = { [bob.id]: { - last_read: new Date(4000), + last_read: 4000, unread_messages: 0, user: bob, last_read_message_id: 'm4', - last_delivered_at: new Date(4000), + last_delivered_at: 4000, last_delivered_message_id: 'm4', }, [carol.id]: { - last_read: new Date(2000), + last_read: 2000, unread_messages: 0, user: carol, last_read_message_id: 'm2', - last_delivered_at: new Date(2000), + last_delivered_at: 2000, last_delivered_message_id: 'm2', }, }; tracker.ingestInitial([ - { user: alice, last_read: iso(2000), last_delivered_at: iso(2000) }, - { user: bob, last_read: iso(3000), last_delivered_at: iso(3000) }, + { user: alice, last_read: 2000, last_delivered_at: 2000 }, + { user: bob, last_read: 3000, last_delivered_at: 3000 }, ]); tracker.reconcileFromReadStore({ @@ -751,6 +748,29 @@ describe('MessageDeliveryReadTracker', () => { expect(tracker.getUserProgress(carol.id)?.lastReadRef).toEqual(ref(2000)); }); + it('accepts a read state whose last_read is the epoch', () => { + // `0` is the epoch sentinel; a truthiness check would reject the state as invalid. + const newcomer = U('newcomer'); + + tracker.reconcileFromReadStore({ + previousReadState: {}, + nextReadState: { + [newcomer.id]: { + last_read: 0, + unread_messages: 3, + user: newcomer, + }, + }, + meta: { changedUserIds: [newcomer.id], removedUserIds: [] }, + }); + + // MIN_REF is correct — nothing at or below the epoch has been read. + const progress = tracker.getUserProgress(newcomer.id); + expect(progress).not.toBeNull(); + expect(progress?.user).toStrictEqual(newcomer); + expect(progress?.lastReadRef.timestamp).toBe(Number.NEGATIVE_INFINITY); + }); + it('ignores non-bootstrap reconcile when metadata is absent', () => { const user = U('missing-meta-user'); @@ -758,11 +778,11 @@ describe('MessageDeliveryReadTracker', () => { previousReadState: {}, nextReadState: { [user.id]: { - last_read: new Date(3000), + last_read: 3000, unread_messages: 0, user, last_read_message_id: 'm3', - last_delivered_at: new Date(3000), + last_delivered_at: 3000, last_delivered_message_id: 'm3', }, }, @@ -777,8 +797,8 @@ describe('MessageDeliveryReadTracker', () => { tracker.ingestInitial([ { user, - last_read: iso(2000), - last_delivered_at: iso(2000), + last_read: 2000, + last_delivered_at: 2000, last_read_message_id: 'm2', last_delivered_message_id: 'm2', }, @@ -788,21 +808,21 @@ describe('MessageDeliveryReadTracker', () => { tracker.reconcileFromReadStore({ previousReadState: { [user.id]: { - last_read: new Date(2000), + last_read: 2000, unread_messages: 0, user, last_read_message_id: 'm2', - last_delivered_at: new Date(2000), + last_delivered_at: 2000, last_delivered_message_id: 'm2', }, }, nextReadState: { [user.id]: { - last_read: new Date(4000), + last_read: 4000, unread_messages: 0, user, last_read_message_id: 'm4', - last_delivered_at: new Date(4000), + last_delivered_at: 4000, last_delivered_message_id: 'm4', }, }, @@ -814,4 +834,43 @@ describe('MessageDeliveryReadTracker', () => { expect(tracker.snapshotStore.getLatestValue().revision).toBe(1); }); }); + describe('read-store reconcile guards', () => { + // A non-finite `last_read` used to reach `locateMessage`, whose lower-bound search finds no + // index satisfying `t > NaN` and so resolves to the NEWEST loaded message — reporting the + // whole channel as read. The row is skipped instead. + it.each([ + ['NaN', Number.NaN], + ['an ISO string', '2026-09-03T00:00:00.000Z'], + ])('ignores a read row whose last_read is %s', (_label, lastRead) => { + // Resolves any timestamp to the newest message, so a leaked non-finite value is visible. + const { channel, readStore } = createChannelMock({ + findMessageByTimestamp: () => ({ id: 'm4' }), + }); + const localTracker = new MessageReceiptsTracker({ channel }); + localTracker.registerSubscriptions(); + localTracker.setPendingReadStoreReconcileMeta({ changedUserIds: ['u1'] }); + + readStore.next({ + read: { u1: { user: U('u1'), last_read: lastRead, unread_messages: 0 } }, + } as never); + + expect(localTracker.getUserProgress('u1')).toBeNull(); + expect(ids(localTracker.readersForMessage(ref(4000)))).toEqual([]); + }); + + it('still accepts the epoch, which means "nothing read"', () => { + const { channel, readStore } = createChannelMock(); + const localTracker = new MessageReceiptsTracker({ channel }); + localTracker.registerSubscriptions(); + localTracker.setPendingReadStoreReconcileMeta({ changedUserIds: ['u1'] }); + + readStore.next({ + read: { u1: { user: U('u1'), last_read: 0, unread_messages: 0 } }, + } as never); + + // Tracked, but ahead of nothing — the epoch resolves below every message. + expect(localTracker.getUserProgress('u1')).not.toBeNull(); + expect(ids(localTracker.readersForMessage(ref(1000)))).toEqual([]); + }); + }); }); diff --git a/test/unit/messageOperations/MessageOperations.test.ts b/test/unit/messageOperations/MessageOperations.test.ts index bd861ead29..942b7c2e61 100644 --- a/test/unit/messageOperations/MessageOperations.test.ts +++ b/test/unit/messageOperations/MessageOperations.test.ts @@ -1,13 +1,14 @@ import { describe, expect, it, vi } from 'vitest'; import { MessageOperations } from '../../../src/messageOperations/MessageOperations'; import type { LocalMessage, Message, MessageResponse } from '../../../src/types'; +import { msToNs, nowNs } from '../../../src/utils/time'; type Store = Map; const makeLocalMessage = (overrides?: Partial): LocalMessage => ({ attachments: [], - created_at: new Date(), + created_at: nowNs(), deleted_at: null, id: 'm1', mentioned_users: [], @@ -16,7 +17,7 @@ const makeLocalMessage = (overrides?: Partial): LocalMessage => status: 'failed', text: 'hi', type: 'regular', - updated_at: new Date(), + updated_at: nowNs(), ...overrides, }) as LocalMessage; @@ -25,8 +26,8 @@ const makeMessageResponse = (overrides?: Partial): MessageRespo id: 'm1', text: 'hi', type: 'regular', - created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), + created_at: nowNs(), + updated_at: nowNs(), ...overrides, }) as MessageResponse; @@ -378,7 +379,7 @@ describe('MessageOperations', () => { it('delete uses defaults.delete and ingests deleted message', async () => { const store: Store = new Map(); const defaultsDelete = vi.fn(async () => ({ - message: makeMessageResponse({ id: 'm1', deleted_at: new Date().toISOString() }), + message: makeMessageResponse({ id: 'm1', deleted_at: nowNs() }), })); const ops = new MessageOperations({ @@ -401,7 +402,7 @@ describe('MessageOperations', () => { await ops.delete({ localMessage }); expect(defaultsDelete).toHaveBeenCalledWith('m1', undefined); - expect(store.get('m1')?.deleted_at).toBeInstanceOf(Date); + expect(store.get('m1')?.deleted_at).toEqual(expect.any(Number)); }); it('delete uses per-call requestFn override', async () => { @@ -425,13 +426,13 @@ describe('MessageOperations', () => { await ops.delete({ localMessage }, async () => ({ message: makeMessageResponse({ id: 'm1', - deleted_at: new Date().toISOString(), + deleted_at: nowNs(), text: 'deleted via override', }), })); expect(store.get('m1')?.text).toBe('deleted via override'); - expect(store.get('m1')?.deleted_at).toBeInstanceOf(Date); + expect(store.get('m1')?.deleted_at).toEqual(expect.any(Number)); }); it('delete uses configured handlers.delete when provided', async () => { @@ -439,7 +440,7 @@ describe('MessageOperations', () => { const configuredDelete = vi.fn(async () => ({ message: makeMessageResponse({ id: 'm1', - deleted_at: new Date().toISOString(), + deleted_at: nowNs(), text: 'deleted via configured handler', }), })); @@ -547,7 +548,7 @@ describe('MessageOperations — optimistic lifecycle', () => { const { lastPersisted, ops, store } = harness({ seed }); // A server timestamp a minute BEHIND the optimistic stamp: the device's clock is fast. - const serverUpdatedAt = new Date(Date.now() - 60_000).toISOString(); + const serverUpdatedAt = nowNs() - msToNs(60_000); await ops.update({ localMessage: { ...seed, text: 'after' } }, async () => ({ message: makeMessageResponse({ @@ -574,7 +575,7 @@ describe('MessageOperations — optimistic lifecycle', () => { id: 'm1', status: 'received', text: 'from a websocket event', - updated_at: new Date(Date.now() + 60_000), + updated_at: nowNs() + msToNs(60_000), }); await ops.update({ localMessage: { ...seed, text: 'after' } }, async () => { @@ -594,7 +595,7 @@ describe('MessageOperations — optimistic lifecycle', () => { // Asserted on the optimistic write specifically: the server echo would supply its own value, so // reading the final state could pass without the optimistic stamp ever existing. - expect(persisted[0].message_text_updated_at).toBeInstanceOf(Date); + expect(persisted[0].message_text_updated_at).toEqual(expect.any(Number)); }); it('does not stamp message_text_updated_at when editing a failed message', async () => { @@ -671,13 +672,13 @@ describe('MessageOperations — optimistic lifecycle', () => { return { message: makeMessageResponse({ id: 'm1', - deleted_at: new Date().toISOString(), + deleted_at: nowNs(), }), }; }); expect(duringRequest?.type).toBe('deleted'); - expect(duringRequest?.deleted_at).toBeInstanceOf(Date); + expect(duringRequest?.deleted_at).toEqual(expect.any(Number)); }); it('sets deleted_for_me for a delete_for_me delete', async () => { @@ -726,7 +727,7 @@ describe('MessageOperations — optimistic lifecycle', () => { }); expect(duringRequest?.type).toBe('deleted'); - expect(duringRequest?.deleted_at).toBeInstanceOf(Date); + expect(duringRequest?.deleted_at).toEqual(expect.any(Number)); }); it('carries delete_for_me into the mirrored row', async () => { @@ -900,11 +901,11 @@ describe('MessageOperations — optimistic lifecycle', () => { const localMessage = makeLocalMessage({ id: 'm1', status: 'sending', - updated_at: new Date(Date.now() + 60_000), + updated_at: nowNs() + msToNs(60_000), }); await ops.send({ localMessage }, async () => ({ - message: makeMessageResponse({ id: 'm1', updated_at: new Date().toISOString() }), + message: makeMessageResponse({ id: 'm1', updated_at: nowNs() }), })); expect(store.get('m1')?.status).toBe('received'); @@ -918,7 +919,7 @@ describe('MessageOperations — optimistic lifecycle', () => { const localMessage = makeLocalMessage({ id: 'm1', status: 'sending', - updated_at: new Date(Date.now() + 60_000), + updated_at: nowNs() + msToNs(60_000), }); await ops.send({ localMessage }, async () => { @@ -929,13 +930,13 @@ describe('MessageOperations — optimistic lifecycle', () => { id: 'm1', status: 'received', text: 'from the echo', - updated_at: new Date(Date.now() + 120_000), + updated_at: nowNs() + msToNs(120_000), }), ); return { message: makeMessageResponse({ id: 'm1', - updated_at: new Date().toISOString(), + updated_at: nowNs(), }), }; }); diff --git a/test/unit/messageOperations/optimistic.test.ts b/test/unit/messageOperations/optimistic.test.ts index ee520b0e4a..4b9c0f77ed 100644 --- a/test/unit/messageOperations/optimistic.test.ts +++ b/test/unit/messageOperations/optimistic.test.ts @@ -10,14 +10,15 @@ import type { LocalMessageAccessor, } from '../../../src/messageOperations/optimistic'; import type { LocalMessage } from '../../../src/types'; +import { convertDateToTimestamp } from '../test-utils/time'; const message = (overrides?: Partial): LocalMessage => ({ - created_at: new Date(), + created_at: convertDateToTimestamp(new Date()), id: 'm1', text: 'hi', type: 'regular', - updated_at: new Date(), + updated_at: convertDateToTimestamp(new Date()), ...overrides, }) as LocalMessage; diff --git a/test/unit/messageOperations/optimisticRouting.test.ts b/test/unit/messageOperations/optimisticRouting.test.ts index e3066c086a..5021fa42d2 100644 --- a/test/unit/messageOperations/optimisticRouting.test.ts +++ b/test/unit/messageOperations/optimisticRouting.test.ts @@ -229,7 +229,7 @@ describe('optimistic edit/delete routing', () => { }); expect(parentProjection(thread)?.type).toBe('deleted'); - expect(parentProjection(thread)?.deleted_at).toBeInstanceOf(Date); + expect(parentProjection(thread)?.deleted_at).toEqual(expect.any(Number)); // `deletedAt` sits on the thread state root, derived from the parent by the store projection — // so this also proves the write went through the store rather than the reply paginator. expect(thread.state.getLatestValue().deletedAt).toBeTruthy(); @@ -298,8 +298,8 @@ describe('optimistic edit/delete routing', () => { }); expect(channel.messagePaginator.getItem(message.id)?.type).toBe('deleted'); - expect(channel.messagePaginator.getItem(message.id)?.deleted_at).toBeInstanceOf( - Date, + expect(channel.messagePaginator.getItem(message.id)?.deleted_at).toEqual( + expect.any(Number), ); }); diff --git a/test/unit/offline-support/offline_support_api.test.ts b/test/unit/offline-support/offline_support_api.test.ts index 87771c928c..1d4d160e23 100644 --- a/test/unit/offline-support/offline_support_api.test.ts +++ b/test/unit/offline-support/offline_support_api.test.ts @@ -26,6 +26,8 @@ import { getClientWithUser } from '../test-utils/getClient'; import * as utils from '../../../src/utils'; import { AxiosError, CanceledError } from 'axios'; import { MockOfflineDB } from './MockOfflineDB'; +import { nsToDate, nsToMs } from '../../../src/utils/time'; +import { convertDateToTimestamp } from '../test-utils/time'; describe('OfflineSupportApi', () => { let client: StreamChat; @@ -1123,10 +1125,21 @@ describe('OfflineSupportApi', () => { offlineDb.getChannels.mockResolvedValue([ { messages: [ - { id: 'sent', status: 'received', updated_at: new Date().toISOString() }, - { id: 'unsent', status: 'failed', updated_at: new Date().toISOString() }, + { + id: 'sent', + status: 'received', + updated_at: convertDateToTimestamp(new Date().toISOString()), + }, + { + id: 'unsent', + status: 'failed', + updated_at: convertDateToTimestamp(new Date().toISOString()), + }, // No status at all — a plain server message, which must not be mistaken for unsent. - { id: 'plain', updated_at: new Date().toISOString() }, + { + id: 'plain', + updated_at: convertDateToTimestamp(new Date().toISOString()), + }, ], }, ]); @@ -1146,10 +1159,10 @@ describe('OfflineSupportApi', () => { { messages: [ { - created_at: '2024-01-01T00:00:00.000Z', + created_at: convertDateToTimestamp('2024-01-01T00:00:00.000Z'), id: 'unsent', status: 'failed', - updated_at: '2024-01-01T00:00:00.000Z', + updated_at: convertDateToTimestamp('2024-01-01T00:00:00.000Z'), }, ], }, @@ -1157,9 +1170,9 @@ describe('OfflineSupportApi', () => { const [failed] = await offlineDb.getFailedMessages({ cid: 'messaging:c1' }); - // The paginator compares `updated_at` as a Date, so a raw storable row cannot be re-ingested. - expect(failed.created_at).toBeInstanceOf(Date); - expect(failed.updated_at).toBeInstanceOf(Date); + // The paginator compares `updated_at` as a wire number, so a raw storable row cannot be re-ingested. + expect(failed.created_at).toEqual(expect.any(Number)); + expect(failed.updated_at).toEqual(expect.any(Number)); }); it('returns nothing when the channel is not in the database', async () => { @@ -1605,7 +1618,7 @@ describe('OfflineSupportApi', () => { ...baseEvent, channel: { cid: 'messaging:to-truncate', - truncated_at: '2025-05-20T10:00:00Z', + truncated_at: convertDateToTimestamp('2025-05-20T10:00:00Z'), } as ChannelResponse, }; @@ -1676,7 +1689,7 @@ describe('OfflineSupportApi', () => { expect(countUnreadSpy).toHaveBeenCalled(); expect(countUnreadSpy).toHaveBeenCalledWith( - new Date(truncatedEvent.channel!.truncated_at as unknown as string), + truncatedEvent.channel!.truncated_at, ); expect(offlineDb.upsertReads).toHaveBeenCalledWith({ @@ -1784,7 +1797,7 @@ describe('OfflineSupportApi', () => { ...truncatedEvent, channel: { cid: localChannelResponse.channel.cid, - truncated_at: '2025-05-20T10:00:00Z', + truncated_at: convertDateToTimestamp('2025-05-20T10:00:00Z'), } as ChannelResponse, }; vi.spyOn( @@ -1821,7 +1834,7 @@ describe('OfflineSupportApi', () => { ...truncatedEvent, channel: { cid: localChannelResponse.channel.cid, - truncated_at: '2025-05-20T10:00:00Z', + truncated_at: convertDateToTimestamp('2025-05-20T10:00:00Z'), } as ChannelResponse, }; @@ -2371,7 +2384,9 @@ describe('OfflineSupportApi', () => { id: 'msg-123', status: 'failed', text: 'edited', - message_text_updated_at: '2026-04-01T20:48:43.886269Z', + message_text_updated_at: convertDateToTimestamp( + '2026-04-01T20:48:43.886269Z', + ), }, }, ) as PendingTask; @@ -2425,7 +2440,9 @@ describe('OfflineSupportApi', () => { id: 'msg-123', status: 'failed', text: 'edited', - message_text_updated_at: '2026-04-01T20:48:43.886269Z', + message_text_updated_at: convertDateToTimestamp( + '2026-04-01T20:48:43.886269Z', + ), }, }, ) as PendingTask; @@ -3249,12 +3266,13 @@ describe('OfflineDBSyncManager', () => { expect(upsertUserSyncStatusSpy).toHaveBeenCalled(); }); - // `/sync` hands back the `WSEvent` union with its timestamps still raw, because the codegen - // emits no decoder for a model whose only decodable field is a union. They arrive as - // nanosecond integers, and `new Date(1786219962651957000)` overflows the Date range, so - // handing one to the mappers threw `RangeError: Date value out of bounds` — which the catch - // in `sync()` reads as the "too many events" API error and answers with `resetDB()`. - describe('decoding the events the sync API returns', () => { + // `/sync` hands back the `WSEvent` union with the timestamps the API put on the wire: unix + // nanosecond integers. Nothing decodes them any more, which is the point — this suite pins + // the pass-through, and pins that consumers convert with `nsToMs`/`nsToDate` rather than + // handing a raw wire value to `new Date`. `new Date(1786219962651957000)` overflows the Date + // range, and the resulting `RangeError` used to be misread by the catch in `sync()` as the + // "too many events" API error, which answered with `resetDB()`. + describe('the events the sync API returns', () => { const NANOS = 1786219962651957000; const EXPECTED_MS = Math.floor(NANOS / 1000000); @@ -3269,7 +3287,7 @@ describe('OfflineDBSyncManager', () => { return handleEventSpy.mock.calls.map(([{ event }]) => event); }; - it('turns nanosecond timestamps into dates before anything persists them', async () => { + it('passes nanosecond timestamps through untouched, nested ones included', async () => { const [event] = await syncWithEvents([ { type: 'message.new', @@ -3279,21 +3297,24 @@ describe('OfflineDBSyncManager', () => { }, ]); - expect(event.created_at).toBeInstanceOf(Date); - expect(event.created_at.getTime()).toBe(EXPECTED_MS); - // The nested message is the part that actually gets written, so the decode has to recurse. - expect(event.message.created_at).toBeInstanceOf(Date); - expect(event.message.created_at.getTime()).toBe(EXPECTED_MS); - expect(event.message.updated_at).toBeInstanceOf(Date); + expect(event.created_at).toBe(NANOS); + // The nested message is the part that actually gets written, so it matters just as much. + expect(event.message.created_at).toBe(NANOS); + expect(event.message.updated_at).toBe(NANOS); }); - it('yields dates the mappers can serialize', async () => { + it('yields timestamps a consumer can convert without overflowing Date', async () => { const [event] = await syncWithEvents([ { type: 'message.new', created_at: NANOS }, ]); - // Undecoded, this is the exact call that threw. - expect(() => new Date(event.created_at).toISOString()).not.toThrow(); + // Handing the raw wire value to `new Date` is the call that threw `RangeError`; going + // through the conversion helpers is what every consumer must do instead. + expect(() => new Date(event.created_at).toISOString()).toThrow(RangeError); + expect(nsToMs(event.created_at)).toBe(EXPECTED_MS); + expect(nsToDate(event.created_at).toISOString()).toBe( + new Date(EXPECTED_MS).toISOString(), + ); }); it('passes an event type the spec does not know through rather than dropping it', async () => { diff --git a/test/unit/pagination/UserGroupPaginator.test.ts b/test/unit/pagination/UserGroupPaginator.test.ts index 3dc250426f..957603e236 100644 --- a/test/unit/pagination/UserGroupPaginator.test.ts +++ b/test/unit/pagination/UserGroupPaginator.test.ts @@ -3,14 +3,16 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { StreamChat } from '../../../src/client'; import { UserGroupPaginator } from '../../../src/pagination'; import type { UserGroupResponse } from '../../../src/types'; +import { convertDateToTimestamp } from '../test-utils/time'; +import { nsToRfc3339 } from '../../../src/utils/time'; const createUserGroup = ( overrides: Partial = {}, ): UserGroupResponse => ({ id: 'group-1', name: 'Backend Support', - created_at: new Date('2026-01-01T00:00:00.000Z'), - updated_at: new Date('2026-01-01T00:00:00.000Z'), + created_at: convertDateToTimestamp('2026-01-01T00:00:00.000Z'), + updated_at: convertDateToTimestamp(new Date('2026-01-01T00:00:00.000Z')), ...overrides, }); @@ -34,21 +36,21 @@ describe('UserGroupPaginator', () => { const firstPage = [ createUserGroup({ id: 'group-1', - created_at: new Date('2026-01-01T00:00:00.000Z'), + created_at: convertDateToTimestamp('2026-01-01T00:00:00.000Z'), }), createUserGroup({ id: 'group-2', name: 'Frontend Support', - created_at: new Date('2026-01-02T00:00:00.000Z'), - updated_at: new Date('2026-01-02T00:00:00.000Z'), + created_at: convertDateToTimestamp('2026-01-02T00:00:00.000Z'), + updated_at: convertDateToTimestamp(new Date('2026-01-02T00:00:00.000Z')), }), ]; const secondPage = [ createUserGroup({ id: 'group-3', name: 'QA Support', - created_at: new Date('2026-01-03T00:00:00.000Z'), - updated_at: new Date('2026-01-03T00:00:00.000Z'), + created_at: convertDateToTimestamp('2026-01-03T00:00:00.000Z'), + updated_at: convertDateToTimestamp(new Date('2026-01-03T00:00:00.000Z')), }), ]; @@ -66,7 +68,7 @@ describe('UserGroupPaginator', () => { expect(paginator.hasMoreTail).toBe(true); expect(paginator.hasMoreHead).toBe(false); expect(JSON.parse(paginator.cursor?.tailward ?? '{}')).toEqual({ - created_at_gt: firstPage[1].created_at.toISOString(), + created_at_gt: nsToRfc3339(firstPage[1].created_at), id_gt: firstPage[1].id, }); @@ -74,7 +76,7 @@ describe('UserGroupPaginator', () => { expect(querySpy).toHaveBeenNthCalledWith(2, { limit: 2, - created_at_gt: firstPage[1].created_at.toISOString(), + created_at_gt: nsToRfc3339(firstPage[1].created_at), id_gt: firstPage[1].id, }); expect(paginator.items).toEqual([...firstPage, ...secondPage]); diff --git a/test/unit/pagination/paginator.initializeConfig.test.ts b/test/unit/pagination/paginator.initializeConfig.test.ts index f113c8caef..6dd5c44b56 100644 --- a/test/unit/pagination/paginator.initializeConfig.test.ts +++ b/test/unit/pagination/paginator.initializeConfig.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { MessagePaginator } from '../../../src/pagination/paginators/MessagePaginator'; import { PinnedMessagePaginator } from '../../../src/pagination/paginators/PinnedMessagePaginator'; import type { Channel } from '../../../src/channel'; +import { convertDateToTimestamp } from '../test-utils/time'; /** Matches the lightweight channel stub the sibling paginator suites use. */ const stubChannel = () => @@ -102,7 +103,10 @@ describe('paginator initializeConfig', () => { // including one with the preservation branch deleted. The field is gone from the type now. const paginator = new MessagePaginator({ channel }); const before = paginator._itemIndex; - paginator.ingestItem({ id: 'm1', created_at: new Date() } as never); + paginator.ingestItem({ + id: 'm1', + created_at: convertDateToTimestamp(new Date()), + } as never); paginator.initializeConfig({ pageSize: 50 }); @@ -186,8 +190,14 @@ describe('paginator initializeConfig', () => { paginator.initializeConfig(); - const older = { id: 'a', pinned_at: new Date('2020-01-01') } as never; - const newer = { id: 'b', pinned_at: new Date('2021-01-01') } as never; + const older = { + id: 'a', + pinned_at: convertDateToTimestamp(new Date('2020-01-01')), + } as never; + const newer = { + id: 'b', + pinned_at: convertDateToTimestamp(new Date('2021-01-01')), + } as never; expect(paginator.config.itemOrderComparator?.(older, newer)).toBeLessThan(0); }); diff --git a/test/unit/pagination/paginators/ChannelPaginator.test.ts b/test/unit/pagination/paginators/ChannelPaginator.test.ts index ad30873ddf..3c2718b320 100644 --- a/test/unit/pagination/paginators/ChannelPaginator.test.ts +++ b/test/unit/pagination/paginators/ChannelPaginator.test.ts @@ -16,6 +16,7 @@ import { generateMsg } from '../../test-utils/generateMessage'; import type { FieldToDataResolver } from '../../../../src/pagination/types.normalization'; import { MockOfflineDB } from '../../offline-support/MockOfflineDB'; import * as utils from '../../../../src/utils'; +import { convertDateToTimestamp } from '../../test-utils/time'; import { DEFAULT_QUERY_CHANNELS_MS_BETWEEN_RETRIES, DEFAULT_QUERY_CHANNELS_RETRY_COUNT, @@ -45,11 +46,11 @@ describe('ChannelPaginator', () => { channel1 = new Channel(client, 'type', 'id1', {}); setLastMessageAt(channel1, new Date('1972-01-01T08:39:35.235Z')); - channel1.data!.updated_at = '1972-01-01T08:39:35.235Z'; + channel1.data!.updated_at = convertDateToTimestamp('1972-01-01T08:39:35.235Z'); channel2 = new Channel(client, 'type', 'id1', {}); setLastMessageAt(channel2, new Date('1971-01-01T08:39:35.235Z')); - channel2.data!.updated_at = '1971-01-01T08:39:35.235Z'; + channel2.data!.updated_at = convertDateToTimestamp('1971-01-01T08:39:35.235Z'); }); describe('constructor()', () => { @@ -69,10 +70,10 @@ describe('ChannelPaginator', () => { expect(paginator.sortComparator).toBeDefined(); setLastMessageAt(channel1, new Date('1970-01-01T08:39:35.235Z')); - channel1.data!.updated_at = '1970-01-01T08:39:35.235Z'; + channel1.data!.updated_at = convertDateToTimestamp('1970-01-01T08:39:35.235Z'); setLastMessageAt(channel2, new Date('1971-01-01T08:39:35.235Z')); - channel2.data!.updated_at = '1971-01-01T08:39:35.235Z'; + channel2.data!.updated_at = convertDateToTimestamp('1971-01-01T08:39:35.235Z'); expect(paginator.sortComparator(channel1, channel2)).toBe(1); // channel2 comes before channel1 expect(paginator.filterBuilder.buildFilters()).toStrictEqual({}); @@ -172,10 +173,10 @@ describe('ChannelPaginator', () => { expect(paginator.sortComparator(channel1, channel2)).toBe(keepOrder); setLastMessageAt(channel1, new Date('1970-01-01T08:39:35.235Z')); - channel1.data!.updated_at = '1970-01-01T08:39:35.235Z'; + channel1.data!.updated_at = convertDateToTimestamp('1970-01-01T08:39:35.235Z'); setLastMessageAt(channel2, new Date('1971-01-01T08:39:35.235Z')); - channel2.data!.updated_at = '1971-01-01T08:39:35.235Z'; + channel2.data!.updated_at = convertDateToTimestamp('1971-01-01T08:39:35.235Z'); expect(paginator.sortComparator(channel1, channel2)).toBe(changeOrder); }); @@ -213,12 +214,12 @@ describe('ChannelPaginator', () => { sort: [{ field: 'has_unread', direction: 1 }], }); channel1.state.read[user.id] = { - last_read: new Date('1972-01-01T08:39:35.235Z'), + last_read: convertDateToTimestamp(new Date('1972-01-01T08:39:35.235Z')), unread_messages: 10, user, }; channel2.state.read[user.id] = { - last_read: new Date('1972-01-01T08:39:35.235Z'), + last_read: convertDateToTimestamp(new Date('1972-01-01T08:39:35.235Z')), unread_messages: 0, user, }; @@ -239,16 +240,16 @@ describe('ChannelPaginator', () => { // compares channel1.state.last_message_at with channel2.data!.updated_at setLastMessageAt(channel1, new Date('1975-01-01T08:39:35.235Z')); - channel1.data!.updated_at = '1970-01-01T08:39:35.235Z'; + channel1.data!.updated_at = convertDateToTimestamp('1970-01-01T08:39:35.235Z'); setLastMessageAt(channel2, new Date('1971-01-01T08:39:35.235Z')); - channel2.data!.updated_at = '1973-01-01T08:39:35.235Z'; + channel2.data!.updated_at = convertDateToTimestamp('1973-01-01T08:39:35.235Z'); expect(paginator.sortComparator(channel1, channel2)).toBe(changeOrder); // compares channel2.state.last_message_at with channel1.data!.updated_at setLastMessageAt(channel1, new Date('1975-01-01T08:39:35.235Z')); - channel1.data!.updated_at = '1976-01-01T08:39:35.235Z'; + channel1.data!.updated_at = convertDateToTimestamp('1976-01-01T08:39:35.235Z'); setLastMessageAt(channel2, new Date('1978-01-01T08:39:35.235Z')); - channel2.data!.updated_at = '1973-01-01T08:39:35.235Z'; + channel2.data!.updated_at = convertDateToTimestamp('1973-01-01T08:39:35.235Z'); expect(paginator.sortComparator(channel1, channel2)).toBe(keepOrder); }); it('should sort by member_count', () => { @@ -265,12 +266,20 @@ describe('ChannelPaginator', () => { client, sort: [{ field: 'pinned_at', direction: 1 }], }); - channel1.state.membership = { pinned_at: '1972-01-01T08:39:35.235Z' }; - channel2.state.membership = { pinned_at: '1971-01-01T08:39:35.235Z' }; + channel1.state.membership = { + pinned_at: convertDateToTimestamp('1972-01-01T08:39:35.235Z'), + }; + channel2.state.membership = { + pinned_at: convertDateToTimestamp('1971-01-01T08:39:35.235Z'), + }; expect(paginator.sortComparator(channel1, channel2)).toBe(changeOrder); - channel1.state.membership = { pinned_at: '1970-01-01T08:39:35.235Z' }; - channel2.state.membership = { pinned_at: '1971-01-01T08:39:35.235Z' }; + channel1.state.membership = { + pinned_at: convertDateToTimestamp('1970-01-01T08:39:35.235Z'), + }; + channel2.state.membership = { + pinned_at: convertDateToTimestamp('1971-01-01T08:39:35.235Z'), + }; expect(paginator.sortComparator(channel1, channel2)).toBe(keepOrder); }); it('should sort by unread_count', () => { @@ -279,24 +288,24 @@ describe('ChannelPaginator', () => { sort: [{ field: 'unread_count', direction: 1 }], }); channel1.state.read[user.id] = { - last_read: new Date(), + last_read: convertDateToTimestamp(new Date()), unread_messages: 10, user, }; channel2.state.read[user.id] = { - last_read: new Date(), + last_read: convertDateToTimestamp(new Date()), unread_messages: 0, user, }; expect(paginator.sortComparator(channel1, channel2)).toBe(changeOrder); channel1.state.read[user.id] = { - last_read: new Date(), + last_read: convertDateToTimestamp(new Date()), unread_messages: 10, user, }; channel2.state.read[user.id] = { - last_read: new Date(), + last_read: convertDateToTimestamp(new Date()), unread_messages: 11, user, }; @@ -308,12 +317,12 @@ describe('ChannelPaginator', () => { sort: [{ field: 'updated_at', direction: 1 }], }); - channel1.data!.updated_at = '1972-01-01T08:39:35.235Z'; - channel2.data!.updated_at = '1971-01-01T08:39:35.235Z'; + channel1.data!.updated_at = convertDateToTimestamp('1972-01-01T08:39:35.235Z'); + channel2.data!.updated_at = convertDateToTimestamp('1971-01-01T08:39:35.235Z'); expect(paginator.sortComparator(channel1, channel2)).toBe(changeOrder); - channel1.data!.updated_at = '1970-01-01T08:39:35.235Z'; - channel2.data!.updated_at = '1971-01-01T08:39:35.235Z'; + channel1.data!.updated_at = convertDateToTimestamp('1970-01-01T08:39:35.235Z'); + channel2.data!.updated_at = convertDateToTimestamp('1971-01-01T08:39:35.235Z'); expect(paginator.sortComparator(channel1, channel2)).toBe(keepOrder); }); it('should sort by custom field', () => { @@ -470,9 +479,13 @@ describe('ChannelPaginator', () => { }); channel1.state.read = { - [user.id]: { last_read: new Date(2000), unread_messages: 0, user }, + [user.id]: { + last_read: convertDateToTimestamp(new Date(2000)), + unread_messages: 0, + user, + }, [otherUserId]: { - last_read: new Date(1000), + last_read: convertDateToTimestamp(new Date(1000)), unread_messages: 1, user: { id: otherUserId }, }, @@ -495,7 +508,9 @@ describe('ChannelPaginator', () => { expect(paginator.matchesFilter(channel1)).toBeTruthy(); - channel1.data = { updated_at: new Date(1000).toISOString() }; + channel1.data = { + updated_at: convertDateToTimestamp(new Date(1000).toISOString()), + }; setLastMessageAt(channel1, null); expect(paginator.matchesFilter(channel1)).toBeTruthy(); @@ -561,7 +576,9 @@ describe('ChannelPaginator', () => { setLastMessageAt(channel1, null); scenarios.forEach(({ val, expected }) => { - channel1.data = { updated_at: new Date(val).toISOString() }; + channel1.data = { + updated_at: convertDateToTimestamp(new Date(val).toISOString()), + }; expect(paginator.matchesFilter(channel1)).toBe(expected); }); @@ -584,7 +601,7 @@ describe('ChannelPaginator', () => { channel1.state.membership = { user, - pinned_at: '2025-09-03T12:19:39.101089Z', + pinned_at: convertDateToTimestamp('2025-09-03T12:19:39.101089Z'), }; expect(paginator.matchesFilter(channel1)).toBeTruthy(); @@ -1610,7 +1627,9 @@ describe('ChannelPaginator', () => { const pinned = new Channel(client, 'type', 'pinned', {}); const plainA = new Channel(client, 'type', 'plainA', {}); const plainB = new Channel(client, 'type', 'plainB', {}); - pinned.state.membership = { pinned_at: '2020-01-01T00:00:00.000Z' }; + pinned.state.membership = { + pinned_at: convertDateToTimestamp('2020-01-01T00:00:00.000Z'), + }; plainA.state.membership = {}; plainB.state.membership = {}; setLastMessageAt(pinned, new Date('2020-01-01T00:00:00.000Z')); // old, but pinned → stays on top @@ -1664,7 +1683,11 @@ describe('ChannelPaginator', () => { ); setLastMessageAt(channel, new Date(CHANNEL_COUNT - index)); channel.state.read = { - [user.id]: { last_read: new Date(0), unread_messages: 1, user }, + [user.id]: { + last_read: convertDateToTimestamp(new Date(0)), + unread_messages: 1, + user, + }, }; return channel; }); diff --git a/test/unit/pagination/paginators/MessagePaginator.test.ts b/test/unit/pagination/paginators/MessagePaginator.test.ts index 979a08ffd8..c411f23b35 100644 --- a/test/unit/pagination/paginators/MessagePaginator.test.ts +++ b/test/unit/pagination/paginators/MessagePaginator.test.ts @@ -13,6 +13,8 @@ import { generateMessageDraft } from '../../test-utils/generateMessageDraft'; import { generateMsg } from '../../test-utils/generateMessage'; import { formatMessage } from '../../../../src'; import { DEFAULT_QUERY_CHANNELS_MESSAGE_LIST_PAGE_SIZE } from '../../../../src/constants'; +import { convertDateToTimestamp } from '../../test-utils/time'; +import { nsToDate } from '../../../../src/utils/time'; const createMessage = (overrides: Partial): LocalMessage => formatMessage( @@ -62,18 +64,24 @@ describe('MessagePaginator', () => { // @ts-expect-error accessing protected property expect(paginator._filterFieldToDataResolvers).toHaveLength(1); - const newer = createMessage({ id: 'b', created_at: '2021-01-01T00:00:00.000Z' }); - const older = createMessage({ id: 'a', created_at: '2020-01-01T00:00:00.000Z' }); + const newer = createMessage({ + id: 'b', + created_at: convertDateToTimestamp('2021-01-01T00:00:00.000Z'), + }); + const older = createMessage({ + id: 'a', + created_at: convertDateToTimestamp('2020-01-01T00:00:00.000Z'), + }); expect(paginator.sortComparator(older, newer)).toBeLessThan(0); expect(paginator.sortComparator(newer, older)).toBeGreaterThan(0); const sameDateA = createMessage({ id: 'a', - created_at: '2021-01-01T00:00:00.000Z', + created_at: convertDateToTimestamp('2021-01-01T00:00:00.000Z'), }); const sameDateB = createMessage({ id: 'b', - created_at: '2021-01-01T00:00:00.000Z', + created_at: convertDateToTimestamp('2021-01-01T00:00:00.000Z'), }); expect(paginator.sortComparator(sameDateA, sameDateB)).toBeLessThan(0); // because of the same date, the tiebreaker kicks in }); @@ -103,8 +111,14 @@ describe('MessagePaginator', () => { expect(paginator.requestSort).toEqual([{ field: 'created_at', direction: -1 }]); expect(paginator.itemOrder).toEqual([{ field: 'created_at', direction: -1 }]); - const newer = createMessage({ id: 'b', created_at: '2021-01-01T00:00:00.000Z' }); - const older = createMessage({ id: 'a', created_at: '2020-01-01T00:00:00.000Z' }); + const newer = createMessage({ + id: 'b', + created_at: convertDateToTimestamp('2021-01-01T00:00:00.000Z'), + }); + const older = createMessage({ + id: 'a', + created_at: convertDateToTimestamp('2020-01-01T00:00:00.000Z'), + }); expect(paginator.sortComparator(older, newer)).toBeGreaterThan(0); }); @@ -215,8 +229,8 @@ describe('MessagePaginator', () => { it('formats channel query results and sets cursors based on direction', async () => { const messages = [ - { id: 'first', created_at: '2022-01-01T00:00:00.000Z' }, - { id: 'last', created_at: '2022-01-02T00:00:00.000Z' }, + { id: 'first', created_at: convertDateToTimestamp('2022-01-01T00:00:00.000Z') }, + { id: 'last', created_at: convertDateToTimestamp('2022-01-02T00:00:00.000Z') }, ]; (channel.query as unknown as ReturnType).mockResolvedValue({ messages, @@ -232,14 +246,21 @@ describe('MessagePaginator', () => { }); expect(result.tailward).toBe('first'); expect(result.headward).toBe('last'); - expect(result.items[0].created_at).toBeInstanceOf(Date); - expect(result.items[1].created_at).toBeInstanceOf(Date); + // `query()` formats results but must not transform timestamps: they stay the wire numbers. + expect(result.items[0].created_at).toBe(messages[0].created_at); + expect(result.items[1].created_at).toBe(messages[1].created_at); }); it('queries replies endpoint when parentMessageId is provided', async () => { const messages = [ - { id: 'first-reply', created_at: '2022-01-01T00:00:00.000Z' }, - { id: 'last-reply', created_at: '2022-01-02T00:00:00.000Z' }, + { + id: 'first-reply', + created_at: convertDateToTimestamp('2022-01-01T00:00:00.000Z'), + }, + { + id: 'last-reply', + created_at: convertDateToTimestamp('2022-01-02T00:00:00.000Z'), + }, ]; (channel.getReplies as unknown as ReturnType).mockResolvedValue({ messages, @@ -263,15 +284,25 @@ describe('MessagePaginator', () => { expect(channel.query).not.toHaveBeenCalled(); expect(result.tailward).toBe('first-reply'); expect(result.headward).toBe('last-reply'); - expect(result.items[0].created_at).toBeInstanceOf(Date); - expect(result.items[1].created_at).toBeInstanceOf(Date); + // `query()` formats results but must not transform timestamps: they stay the wire numbers. + expect(result.items[0].created_at).toBe(messages[0].created_at); + expect(result.items[1].created_at).toBe(messages[1].created_at); }); it('keeps items ordered chronologically when itemOrder is ascending and request sort is descending', async () => { const messages = [ - { id: 'newest-reply', created_at: '2022-01-03T00:00:00.000Z' }, - { id: 'middle-reply', created_at: '2022-01-02T00:00:00.000Z' }, - { id: 'oldest-reply', created_at: '2022-01-01T00:00:00.000Z' }, + { + id: 'newest-reply', + created_at: convertDateToTimestamp('2022-01-03T00:00:00.000Z'), + }, + { + id: 'middle-reply', + created_at: convertDateToTimestamp('2022-01-02T00:00:00.000Z'), + }, + { + id: 'oldest-reply', + created_at: convertDateToTimestamp('2022-01-01T00:00:00.000Z'), + }, ]; (channel.getReplies as unknown as ReturnType).mockResolvedValue({ messages, @@ -308,7 +339,10 @@ describe('MessagePaginator', () => { it('delegates to executeQuery with id_around payload', async () => { const paginator = new MessagePaginator({ channel, itemIndex }); itemIndex.setOne( - createMessage({ id: 'target-message', created_at: '2020-01-01T00:00:00.000Z' }), + createMessage({ + id: 'target-message', + created_at: convertDateToTimestamp('2020-01-01T00:00:00.000Z'), + }), ); const targetInterval: Interval = { id: 'interval-1', @@ -337,22 +371,22 @@ describe('MessagePaginator', () => { const m4 = createMessage({ cid: 'channel-id', id: 'm4', - created_at: '2020-01-04T00:00:00.000Z', + created_at: convertDateToTimestamp('2020-01-04T00:00:00.000Z'), }); const m5 = createMessage({ cid: 'channel-id', id: 'm5', - created_at: '2020-01-05T00:00:00.000Z', + created_at: convertDateToTimestamp('2020-01-05T00:00:00.000Z'), }); const m8 = createMessage({ cid: 'channel-id', id: 'm8', - created_at: '2020-01-08T00:00:00.000Z', + created_at: convertDateToTimestamp('2020-01-08T00:00:00.000Z'), }); const m9 = createMessage({ cid: 'channel-id', id: 'm9', - created_at: '2020-01-09T00:00:00.000Z', + created_at: convertDateToTimestamp('2020-01-09T00:00:00.000Z'), }); // two disjoint anchored intervals @@ -372,12 +406,12 @@ describe('MessagePaginator', () => { const existing = createMessage({ cid: 'channel-id', id: 'm-existing', - created_at: '2020-01-01T00:00:00.000Z', + created_at: convertDateToTimestamp('2020-01-01T00:00:00.000Z'), }); const target = createMessage({ cid: 'channel-id', id: 'm-target', - created_at: '2020-01-02T00:00:00.000Z', + created_at: convertDateToTimestamp('2020-01-02T00:00:00.000Z'), }); const activeInterval = paginator.ingestPage({ @@ -423,7 +457,7 @@ describe('MessagePaginator', () => { createMessage({ cid: 'channel-id', id, - created_at: `2020-01-${day}T00:00:00.000Z`, + created_at: convertDateToTimestamp(`2020-01-${day}T00:00:00.000Z`), }); const paginator = new MessagePaginator({ channel, itemIndex }); // Head loaded; older messages still available (isTail:false) → a real gap exists below it. @@ -442,9 +476,18 @@ describe('MessagePaginator', () => { }); (channel.query as unknown as ReturnType).mockResolvedValue({ messages: [ - generateMsg({ id: 'm1', created_at: '2020-01-01T00:00:00.000Z' }), - generateMsg({ id: 'm2', created_at: '2020-01-02T00:00:00.000Z' }), - generateMsg({ id: 'm3', created_at: '2020-01-03T00:00:00.000Z' }), + generateMsg({ + id: 'm1', + created_at: convertDateToTimestamp('2020-01-01T00:00:00.000Z'), + }), + generateMsg({ + id: 'm2', + created_at: convertDateToTimestamp('2020-01-02T00:00:00.000Z'), + }), + generateMsg({ + id: 'm3', + created_at: convertDateToTimestamp('2020-01-03T00:00:00.000Z'), + }), ], }); @@ -460,7 +503,7 @@ describe('MessagePaginator', () => { createMessage({ cid: 'channel-id', id, - created_at: `2020-01-${day}T00:00:00.000Z`, + created_at: convertDateToTimestamp(`2020-01-${day}T00:00:00.000Z`), }); const paginator = new MessagePaginator({ channel, itemIndex }); paginator.ingestPage({ @@ -476,9 +519,18 @@ describe('MessagePaginator', () => { }); (channel.query as unknown as ReturnType).mockResolvedValue({ messages: [ - generateMsg({ id: 'm1', created_at: '2020-01-01T00:00:00.000Z' }), - generateMsg({ id: 'm2', created_at: '2020-01-02T00:00:00.000Z' }), - generateMsg({ id: 'm3', created_at: '2020-01-03T00:00:00.000Z' }), + generateMsg({ + id: 'm1', + created_at: convertDateToTimestamp('2020-01-01T00:00:00.000Z'), + }), + generateMsg({ + id: 'm2', + created_at: convertDateToTimestamp('2020-01-02T00:00:00.000Z'), + }), + generateMsg({ + id: 'm3', + created_at: convertDateToTimestamp('2020-01-03T00:00:00.000Z'), + }), ], }); @@ -503,8 +555,14 @@ describe('MessagePaginator', () => { itemIndex, parentMessageId: 'parent-1', }); - const m1 = createMessage({ id: 'm1', created_at: '2020-01-01T00:00:00.000Z' }); - const m2 = createMessage({ id: 'm2', created_at: '2020-01-02T00:00:00.000Z' }); + const m1 = createMessage({ + id: 'm1', + created_at: convertDateToTimestamp('2020-01-01T00:00:00.000Z'), + }); + const m2 = createMessage({ + id: 'm2', + created_at: convertDateToTimestamp('2020-01-02T00:00:00.000Z'), + }); paginator.ingestPage({ page: [m1, m2], isHead: true, @@ -531,7 +589,7 @@ describe('MessagePaginator', () => { id: 'mine', cid: 'channel-id', parent_id: 'parent-1', - created_at: '2020-01-05T00:00:00.000Z', + created_at: convertDateToTimestamp('2020-01-05T00:00:00.000Z'), }), ); (channel as unknown as { getClient: () => unknown }).getClient = () => ({ @@ -544,13 +602,13 @@ describe('MessagePaginator', () => { id: 'mine', cid: 'channel-id', parent_id: 'parent-1', - created_at: '2020-01-05T00:00:00.000Z', + created_at: convertDateToTimestamp('2020-01-05T00:00:00.000Z'), }), createMessage({ id: 'peer', cid: 'channel-id', parent_id: 'parent-1', - created_at: '2020-01-06T00:00:00.000Z', + created_at: convertDateToTimestamp('2020-01-06T00:00:00.000Z'), }), ], }); @@ -570,8 +628,14 @@ describe('MessagePaginator', () => { itemIndex, parentMessageId: 'parent-1', }); - const m4 = createMessage({ id: 'm4', created_at: '2020-01-04T00:00:00.000Z' }); - const m5 = createMessage({ id: 'm5', created_at: '2020-01-05T00:00:00.000Z' }); + const m4 = createMessage({ + id: 'm4', + created_at: convertDateToTimestamp('2020-01-04T00:00:00.000Z'), + }); + const m5 = createMessage({ + id: 'm5', + created_at: convertDateToTimestamp('2020-01-05T00:00:00.000Z'), + }); // A non-head window is active (as after jumping to an older message) with a headward cursor, // so the "load newer" query is cursor-based (an incremental load, not a first-page reset). paginator.ingestPage({ @@ -682,8 +746,101 @@ describe('MessagePaginator', () => { expect(jumpSpy).not.toHaveBeenCalled(); }); + it('still infers the unread boundary when lastReadAt is the epoch', async () => { + // A truthiness guard would skip the inference branch for the epoch sentinel entirely. + const channelWithReadState = { + cid: 'channel-id', + query: vi.fn(), + state: { + read: { + user1: { + first_unread_message_id: null, + last_read: 0, + last_read_message_id: null, + }, + }, + }, + getClient: () => ({ user: { id: 'user1' } }), + } as unknown as Channel; + + const paginator = new MessagePaginator({ + channel: channelWithReadState, + itemIndex, + }); + const executeQuerySpy = vi.spyOn(paginator, 'executeQuery').mockResolvedValue({ + stateCandidate: { + items: [ + createMessage({ + created_at: convertDateToTimestamp('2021-01-01T00:00:00.000Z'), + id: 'm-first', + }), + createMessage({ + created_at: convertDateToTimestamp('2021-01-03T00:00:00.000Z'), + id: 'm-second', + }), + ], + }, + targetInterval: null, + }); + const jumpSpy = vi.spyOn(paginator, 'jumpToMessage').mockResolvedValue(true); + + const ok = await paginator.jumpToTheFirstUnreadMessage({ pageSize: 25 }); + + expect(ok).toBe(true); + expect(executeQuerySpy).toHaveBeenCalledWith({ + queryShape: { created_at_around: nsToDate(0), limit: 25 }, + updateState: false, + }); + expect(jumpSpy).toHaveBeenCalledWith( + 'm-first', + expect.objectContaining({ focusReason: 'jump-to-first-unread' }), + ); + }); + + // `nsToDate(NaN)` is an `Invalid Date`, which `JSON.stringify` emits as `null` — so the query + // used to go out with `created_at_around: null`. The row is not a usable boundary; fall through. + it.each([ + ['NaN', Number.NaN], + ['an ISO string', '2021-01-02T00:00:00.000Z'], + ])( + 'does not query created_at_around when lastReadAt is %s', + async (_label, lastRead) => { + const channelWithReadState = { + cid: 'channel-id', + query: vi.fn(), + state: { + read: { + user1: { + first_unread_message_id: null, + last_read: lastRead, + last_read_message_id: 'm-last-read', + }, + }, + }, + getClient: () => ({ user: { id: 'user1' } }), + } as unknown as Channel; + + const paginator = new MessagePaginator({ + channel: channelWithReadState, + itemIndex, + }); + const executeQuerySpy = vi.spyOn(paginator, 'executeQuery'); + const jumpSpy = vi.spyOn(paginator, 'jumpToMessage').mockResolvedValue(true); + + const ok = await paginator.jumpToTheFirstUnreadMessage({ pageSize: 25 }); + + expect(executeQuerySpy).not.toHaveBeenCalled(); + // Falls through to the last-read id we do have. + expect(ok).toBe(true); + expect(jumpSpy).toHaveBeenCalledWith( + 'm-last-read', + expect.objectContaining({ focusReason: 'jump-to-first-unread' }), + ); + }, + ); + it('falls back to created_at_around query when unread ids are missing and lastReadAt exists', async () => { - const lastReadAt = new Date('2021-01-02T00:00:00.000Z'); + const lastReadAt = convertDateToTimestamp('2021-01-02T00:00:00.000Z'); const channelWithReadState = { cid: 'channel-id', query: vi.fn(), @@ -691,7 +848,7 @@ describe('MessagePaginator', () => { read: { user1: { first_unread_message_id: null, - last_read: lastReadAt, + last_read: convertDateToTimestamp(lastReadAt), last_read_message_id: null, }, }, @@ -708,8 +865,14 @@ describe('MessagePaginator', () => { const executeQuerySpy = vi.spyOn(paginator, 'executeQuery').mockResolvedValue({ stateCandidate: { items: [ - createMessage({ created_at: '2021-01-01T00:00:00.000Z', id: 'm-read' }), - createMessage({ created_at: '2021-01-03T00:00:00.000Z', id: 'm-unread' }), + createMessage({ + created_at: convertDateToTimestamp('2021-01-01T00:00:00.000Z'), + id: 'm-read', + }), + createMessage({ + created_at: convertDateToTimestamp('2021-01-03T00:00:00.000Z'), + id: 'm-unread', + }), ], }, targetInterval: null, @@ -720,7 +883,8 @@ describe('MessagePaginator', () => { expect(ok).toBe(true); expect(executeQuerySpy).toHaveBeenCalledWith({ - queryShape: { created_at_around: lastReadAt, limit: 25 }, + // `created_at_around` is a request field, so the paginator converts back to a `Date`. + queryShape: { created_at_around: nsToDate(lastReadAt), limit: 25 }, updateState: false, }); expect(jumpSpy).toHaveBeenCalledWith( @@ -738,7 +902,7 @@ describe('MessagePaginator', () => { }); it('jumps to the first unread message when the queried page starts after lastReadAt', async () => { - const lastReadAt = new Date('2021-01-01T00:00:00.000Z'); + const lastReadAt = convertDateToTimestamp('2021-01-01T00:00:00.000Z'); const channelWithReadState = { cid: 'channel-id', query: vi.fn(), @@ -746,7 +910,7 @@ describe('MessagePaginator', () => { read: { user1: { first_unread_message_id: null, - last_read: lastReadAt, + last_read: convertDateToTimestamp(lastReadAt), last_read_message_id: null, }, }, @@ -764,11 +928,11 @@ describe('MessagePaginator', () => { stateCandidate: { items: [ createMessage({ - created_at: '2021-01-02T00:00:00.000Z', + created_at: convertDateToTimestamp('2021-01-02T00:00:00.000Z'), id: 'm-first-unread', }), createMessage({ - created_at: '2021-01-03T00:00:00.000Z', + created_at: convertDateToTimestamp('2021-01-03T00:00:00.000Z'), id: 'm-newer-unread', }), ], @@ -794,7 +958,7 @@ describe('MessagePaginator', () => { }); it('infers the first unread from the already-loaded window (no query) and jumps to it, not the last read message', async () => { - const lastReadAt = new Date('2021-01-02T00:00:00.000Z'); + const lastReadAt = convertDateToTimestamp('2021-01-02T00:00:00.000Z'); const channelWithReadState = { cid: 'channel-id', query: vi.fn(), @@ -802,7 +966,7 @@ describe('MessagePaginator', () => { read: { user1: { first_unread_message_id: null, - last_read: lastReadAt, + last_read: convertDateToTimestamp(lastReadAt), last_read_message_id: 'm-read', }, }, @@ -820,8 +984,14 @@ describe('MessagePaginator', () => { // "a few unreads at the bottom" case where no extra request is needed. paginator.state.partialNext({ items: [ - createMessage({ created_at: '2021-01-01T00:00:00.000Z', id: 'm-read' }), - createMessage({ created_at: '2021-01-03T00:00:00.000Z', id: 'm-unread' }), + createMessage({ + created_at: convertDateToTimestamp('2021-01-01T00:00:00.000Z'), + id: 'm-read', + }), + createMessage({ + created_at: convertDateToTimestamp('2021-01-03T00:00:00.000Z'), + id: 'm-unread', + }), ], }); const executeQuerySpy = vi.spyOn(paginator, 'executeQuery'); @@ -847,7 +1017,7 @@ describe('MessagePaginator', () => { }); it('re-seeds the unread snapshot from the current read state on demand (reopen from cache)', () => { - const lastReadAt = new Date('2021-05-01T00:00:00.000Z'); + const lastReadAt = convertDateToTimestamp('2021-05-01T00:00:00.000Z'); const channelWithReadState = { cid: 'channel-id', query: vi.fn(), @@ -855,7 +1025,7 @@ describe('MessagePaginator', () => { read: { user1: { first_unread_message_id: null, - last_read: lastReadAt, + last_read: convertDateToTimestamp(lastReadAt), last_read_message_id: 'm-42', unread_messages: 3, }, @@ -1136,17 +1306,17 @@ describe('MessagePaginator', () => { const byA1 = createMessage({ id: 'a1', user: { id: 'A' }, - created_at: '2021-01-01T00:00:00.000Z', + created_at: convertDateToTimestamp('2021-01-01T00:00:00.000Z'), }); const byB = createMessage({ id: 'b1', user: { id: 'B' }, - created_at: '2021-01-02T00:00:00.000Z', + created_at: convertDateToTimestamp('2021-01-02T00:00:00.000Z'), }); const byA2 = createMessage({ id: 'a2', user: { id: 'A' }, - created_at: '2021-01-03T00:00:00.000Z', + created_at: convertDateToTimestamp('2021-01-03T00:00:00.000Z'), }); paginator.setItems({ @@ -1168,7 +1338,7 @@ describe('MessagePaginator', () => { describe('reflectReaction()', () => { const currentUserId = 'me'; const reaction = (type: string, userId: string) => ({ - created_at: '2021-01-01T00:00:00.000Z', + created_at: convertDateToTimestamp('2021-01-01T00:00:00.000Z'), message_id: 'r1', type, user_id: userId, @@ -1188,7 +1358,7 @@ describe('MessagePaginator', () => { paginator.setItems({ valueOrFactory: [ createMessage({ - created_at: '2021-01-01T00:00:00.000Z', + created_at: convertDateToTimestamp('2021-01-01T00:00:00.000Z'), id: 'r1', latest_reactions: ownReactions, own_reactions: ownReactions, @@ -1318,12 +1488,12 @@ describe('MessagePaginator', () => { const older = createMessage({ cid: 'channel-id', id: 'm1', - created_at: '2020-01-01T00:00:00.000Z', + created_at: convertDateToTimestamp('2020-01-01T00:00:00.000Z'), }); const newer = createMessage({ cid: 'channel-id', id: 'm2', - created_at: '2020-01-02T00:00:00.000Z', + created_at: convertDateToTimestamp('2020-01-02T00:00:00.000Z'), }); itemIndex.setMany([older, newer]); @@ -1342,12 +1512,12 @@ describe('MessagePaginator', () => { const m1 = createMessage({ cid: 'channel-id', id: 'm1', - created_at: '2020-01-01T00:00:00.000Z', + created_at: convertDateToTimestamp('2020-01-01T00:00:00.000Z'), }); const m2 = createMessage({ cid: 'channel-id', id: 'm2', - created_at: '2020-01-02T00:00:00.000Z', + created_at: convertDateToTimestamp('2020-01-02T00:00:00.000Z'), }); paginator.setItems({ valueOrFactory: [m1, m2], @@ -1358,7 +1528,7 @@ describe('MessagePaginator', () => { const m3 = createMessage({ cid: 'channel-id', id: 'm3', - created_at: '2020-01-03T00:00:00.000Z', + created_at: convertDateToTimestamp('2020-01-03T00:00:00.000Z'), }); paginator.ingestItem(m3); @@ -1388,28 +1558,28 @@ describe('MessagePaginator', () => { const m1 = createMessage({ cid: 'channel-id', id: 'm1', - created_at: '2020-01-01T00:00:00.000Z', + created_at: convertDateToTimestamp('2020-01-01T00:00:00.000Z'), }); const m2 = createMessage({ cid: 'channel-id', id: 'm2', - created_at: '2020-01-02T00:00:00.000Z', + created_at: convertDateToTimestamp('2020-01-02T00:00:00.000Z'), }); const m3 = createMessage({ cid: 'channel-id', id: 'm3', - created_at: '2020-01-03T00:00:00.000Z', + created_at: convertDateToTimestamp('2020-01-03T00:00:00.000Z'), }); const around = createMessage({ cid: 'channel-id', id: 'm4', - created_at: '2020-01-04T00:00:00.000Z', + created_at: convertDateToTimestamp('2020-01-04T00:00:00.000Z'), }); // newest message is shadowed -> filtered out before interval ingestion const newestShadowed = createMessage({ cid: 'channel-id', id: 'm5', - created_at: '2020-01-05T00:00:00.000Z', + created_at: convertDateToTimestamp('2020-01-05T00:00:00.000Z'), shadowed: true, }); @@ -1432,7 +1602,7 @@ describe('MessagePaginator', () => { createMessage({ cid: 'channel-id', id, - created_at: `2020-01-${day}T00:00:00.000Z`, + created_at: convertDateToTimestamp(`2020-01-${day}T00:00:00.000Z`), }); it('seeds a latest page as the head window (nothing newer to load)', () => { @@ -1481,7 +1651,7 @@ describe('MessagePaginator', () => { createMessage({ cid: 'channel-id', id, - created_at: `2020-01-${day}T00:00:00.000Z`, + created_at: convertDateToTimestamp(`2020-01-${day}T00:00:00.000Z`), }); describe('headItems / headmostItem', () => { @@ -1547,7 +1717,9 @@ describe('MessagePaginator', () => { setActive: true, }); - paginator.truncate({ truncatedAt: new Date('2020-01-05T00:00:00.000Z') }); + paginator.truncate({ + truncatedAt: convertDateToTimestamp('2020-01-05T00:00:00.000Z'), + }); // m1 dropped; m5 kept (equal, not strictly older); m9 kept expect(paginator.items?.map((m) => m.id)).toEqual(['m5', 'm9']); @@ -1565,7 +1737,9 @@ describe('MessagePaginator', () => { }); expect(paginator.hasMoreTail).toBe(true); - paginator.truncate({ truncatedAt: new Date('2020-01-05T00:00:00.000Z') }); + paginator.truncate({ + truncatedAt: convertDateToTimestamp('2020-01-05T00:00:00.000Z'), + }); expect(paginator.items?.map((m) => m.id)).toEqual(['m5', 'm9']); // it lost its oldest member → nothing older remains → it is now the tail @@ -1583,7 +1757,9 @@ describe('MessagePaginator', () => { // a separate, older, disjoint window paginator.ingestPage({ page: [msg('m2', '02'), msg('m3', '03')] }); - paginator.truncate({ truncatedAt: new Date('2020-01-05T00:00:00.000Z') }); + paginator.truncate({ + truncatedAt: convertDateToTimestamp('2020-01-05T00:00:00.000Z'), + }); // the older window was entirely older than the cutoff → dropped expect(paginator.getItem('m2')).toBeUndefined(); @@ -1603,7 +1779,9 @@ describe('MessagePaginator', () => { }); const partialNextSpy = vi.spyOn(paginator.state, 'partialNext'); - paginator.truncate({ truncatedAt: new Date('2020-01-05T00:00:00.000Z') }); + paginator.truncate({ + truncatedAt: convertDateToTimestamp('2020-01-05T00:00:00.000Z'), + }); // three messages removed, but a single state emission expect(paginator.items?.map((m) => m.id)).toEqual(['m9']); @@ -1625,7 +1803,9 @@ describe('MessagePaginator', () => { }); expect(paginator.items?.map((m) => m.id)).toEqual(['m1', 'm2']); - paginator.truncate({ truncatedAt: new Date('2020-01-05T00:00:00.000Z') }); + paginator.truncate({ + truncatedAt: convertDateToTimestamp('2020-01-05T00:00:00.000Z'), + }); // active window removed entirely, but we show the surviving window — NOT an empty list expect(paginator.getItem('m1')).toBeUndefined(); @@ -1649,7 +1829,9 @@ describe('MessagePaginator', () => { setActive: true, }); - paginator.truncate({ truncatedAt: new Date('2020-01-04T00:00:00.000Z') }); + paginator.truncate({ + truncatedAt: convertDateToTimestamp('2020-01-04T00:00:00.000Z'), + }); // active [m1,m2] removed; nearest survivor to where it was = the oldest survivor [m5,m6] expect(paginator.items?.map((m) => m.id)).toEqual(['m5', 'm6']); @@ -1671,7 +1853,9 @@ describe('MessagePaginator', () => { }); // cutoff 04: everything strictly older (m1, both m3*) dropped; m5, m7 kept - paginator.truncate({ truncatedAt: new Date('2020-01-04T00:00:00.000Z') }); + paginator.truncate({ + truncatedAt: convertDateToTimestamp('2020-01-04T00:00:00.000Z'), + }); expect(paginator.items?.map((m) => m.id)).toEqual(['m5', 'm7']); expect(paginator.getItem('m3b')).toBeUndefined(); @@ -1687,7 +1871,7 @@ describe('MessagePaginator', () => { }); const partialNextSpy = vi.spyOn(paginator.state, 'partialNext'); - paginator.truncate({ truncatedAt: new Date('not-a-date') }); + paginator.truncate({ truncatedAt: Number.NaN }); expect(paginator.items?.map((m) => m.id)).toEqual(['m1']); expect(partialNextSpy).not.toHaveBeenCalled(); @@ -1735,7 +1919,7 @@ describe('MessagePaginator', () => { createMessage({ cid: 'channel-id', id, - created_at: `2020-01-${day}T00:00:00.000Z`, + created_at: convertDateToTimestamp(`2020-01-${day}T00:00:00.000Z`), ...overrides, }); @@ -2036,7 +2220,9 @@ describe('MessagePaginator', () => { createMessage({ cid: 'channel-id', id, - created_at: new Date(Date.UTC(2020, 0, 1, 0, minute, 0)).toISOString(), + created_at: convertDateToTimestamp( + new Date(Date.UTC(2020, 0, 1, 0, minute, 0)).toISOString(), + ), ...overrides, }); @@ -2111,7 +2297,10 @@ describe('MessagePaginator', () => { const paginator = loadHead([msg('m1', 1), msg('m2', 2), msg('m3', 3)]); paginator.mergeNewestPage([ msg('m1', 1), - msg('m2', 2, { type: 'deleted', deleted_at: '2020-01-01T00:10:00.000Z' }), + msg('m2', 2, { + type: 'deleted', + deleted_at: convertDateToTimestamp('2020-01-01T00:10:00.000Z'), + }), msg('m3', 3), ]); expect(paginator.getItem('m2')).toBeDefined(); @@ -2912,7 +3101,9 @@ describe('MessagePaginator', () => { createMessage({ cid: 'channel-id', id, - created_at: new Date(Date.UTC(2020, 0, 1, 0, minute, 0)).toISOString(), + created_at: convertDateToTimestamp( + new Date(Date.UTC(2020, 0, 1, 0, minute, 0)).toISOString(), + ), ...overrides, }); @@ -3374,7 +3565,7 @@ describe('MessagePaginator', () => { }); }; - const at = (iso: string) => new Date(iso).getTime(); + const at = (iso: string) => convertDateToTimestamp(iso); beforeEach(() => { skipSystemMessages = false; @@ -3394,10 +3585,13 @@ describe('MessagePaginator', () => { const paginator = buildPaginator(); paginator.trackLastMessage( - createMessage({ id: 'a', created_at: '2020-01-01T00:00:00.000Z' }), + createMessage({ + id: 'a', + created_at: convertDateToTimestamp('2020-01-01T00:00:00.000Z'), + }), ); - expect(paginator.lastMessageAt?.getTime()).toBe(at('2020-01-01T00:00:00.000Z')); + expect(paginator.lastMessageAt).toBe(at('2020-01-01T00:00:00.000Z')); // The display message is tracked on aggregateState (reactive off-window), not the visible list. expect(paginator.lastMessage?.id).toBe('a'); expect(paginator.items).toBeUndefined(); @@ -3405,8 +3599,8 @@ describe('MessagePaginator', () => { it('seed advances only the timestamp, leaving lastMessage null', () => { const paginator = buildPaginator(); - paginator.seedLastMessageAt('2023-05-03T11:12:53.993Z'); - expect(paginator.lastMessageAt?.getTime()).toBe(at('2023-05-03T11:12:53.993Z')); + paginator.seedLastMessageAt(convertDateToTimestamp('2023-05-03T11:12:53.993Z')); + expect(paginator.lastMessageAt).toBe(at('2023-05-03T11:12:53.993Z')); // The server seed has a timestamp but not the message itself. expect(paginator.lastMessage).toBeNull(); }); @@ -3414,25 +3608,31 @@ describe('MessagePaginator', () => { it('lastMessageAt is the max of the loaded message and the seed; a seed never blocks the display message', () => { const paginator = buildPaginator(); // Server says the newest message is far in the future (not yet loaded). - paginator.seedLastMessageAt('2030-01-01T00:00:00.000Z'); - expect(paginator.lastMessageAt?.getTime()).toBe(at('2030-01-01T00:00:00.000Z')); + paginator.seedLastMessageAt(convertDateToTimestamp('2030-01-01T00:00:00.000Z')); + expect(paginator.lastMessageAt).toBe(at('2030-01-01T00:00:00.000Z')); expect(paginator.lastMessage).toBeNull(); // A real (older-than-seed) message must still become the display message — the guard is against // the display message's own timestamp, not the seed-inflated lastMessageAt. paginator.trackLastMessage( - createMessage({ id: 'a', created_at: '2020-01-01T00:00:00.000Z' }), + createMessage({ + id: 'a', + created_at: convertDateToTimestamp('2020-01-01T00:00:00.000Z'), + }), ); expect(paginator.lastMessage?.id).toBe('a'); // Sort key stays the max (the seed), so it can never drift below the display message. - expect(paginator.lastMessageAt?.getTime()).toBe(at('2030-01-01T00:00:00.000Z')); + expect(paginator.lastMessageAt).toBe(at('2030-01-01T00:00:00.000Z')); // Once a message newer than the seed arrives, lastMessageAt follows it. paginator.trackLastMessage( - createMessage({ id: 'b', created_at: '2031-01-01T00:00:00.000Z' }), + createMessage({ + id: 'b', + created_at: convertDateToTimestamp('2031-01-01T00:00:00.000Z'), + }), ); expect(paginator.lastMessage?.id).toBe('b'); - expect(paginator.lastMessageAt?.getTime()).toBe(at('2031-01-01T00:00:00.000Z')); + expect(paginator.lastMessageAt).toBe(at('2031-01-01T00:00:00.000Z')); }); it('does not emit on the pagination state (writes the separate aggregateState store)', () => { @@ -3444,29 +3644,41 @@ describe('MessagePaginator', () => { stateEmissions = 0; // ignore the synchronous initial subscribe call paginator.trackLastMessage( - createMessage({ id: 'a', created_at: '2020-01-01T00:00:00.000Z' }), + createMessage({ + id: 'a', + created_at: convertDateToTimestamp('2020-01-01T00:00:00.000Z'), + }), ); unsubscribe(); expect(stateEmissions).toBe(0); - expect(paginator.lastMessageAt?.getTime()).toBe(at('2020-01-01T00:00:00.000Z')); + expect(paginator.lastMessageAt).toBe(at('2020-01-01T00:00:00.000Z')); }); it('advances monotonically by created_at', () => { const paginator = buildPaginator(); paginator.trackLastMessage( - createMessage({ id: 'a', created_at: '2020-01-01T00:00:00.000Z' }), + createMessage({ + id: 'a', + created_at: convertDateToTimestamp('2020-01-01T00:00:00.000Z'), + }), ); paginator.trackLastMessage( - createMessage({ id: 'b', created_at: '2019-01-01T00:00:00.000Z' }), + createMessage({ + id: 'b', + created_at: convertDateToTimestamp('2019-01-01T00:00:00.000Z'), + }), ); - expect(paginator.lastMessageAt?.getTime()).toBe(at('2020-01-01T00:00:00.000Z')); + expect(paginator.lastMessageAt).toBe(at('2020-01-01T00:00:00.000Z')); paginator.trackLastMessage( - createMessage({ id: 'c', created_at: '2021-01-01T00:00:00.000Z' }), + createMessage({ + id: 'c', + created_at: convertDateToTimestamp('2021-01-01T00:00:00.000Z'), + }), ); - expect(paginator.lastMessageAt?.getTime()).toBe(at('2021-01-01T00:00:00.000Z')); + expect(paginator.lastMessageAt).toBe(at('2021-01-01T00:00:00.000Z')); }); it('never advances for a shadowed message', () => { @@ -3475,7 +3687,7 @@ describe('MessagePaginator', () => { paginator.trackLastMessage( createMessage({ id: 'a', - created_at: '2020-01-01T00:00:00.000Z', + created_at: convertDateToTimestamp('2020-01-01T00:00:00.000Z'), shadowed: true, }), ); @@ -3490,7 +3702,7 @@ describe('MessagePaginator', () => { createMessage({ id: 'reply', parent_id: 'parent', - created_at: '2020-01-01T00:00:00.000Z', + created_at: convertDateToTimestamp('2020-01-01T00:00:00.000Z'), }), ); expect(paginator.lastMessageAt).toBeNull(); @@ -3500,10 +3712,10 @@ describe('MessagePaginator', () => { id: 'reply-shown', parent_id: 'parent', show_in_channel: true, - created_at: '2021-01-01T00:00:00.000Z', + created_at: convertDateToTimestamp('2021-01-01T00:00:00.000Z'), }), ); - expect(paginator.lastMessageAt?.getTime()).toBe(at('2021-01-01T00:00:00.000Z')); + expect(paginator.lastMessageAt).toBe(at('2021-01-01T00:00:00.000Z')); }); it('skips system messages only when skip_last_msg_update_for_system_msgs is set', () => { @@ -3512,7 +3724,7 @@ describe('MessagePaginator', () => { const systemMessage = createMessage({ id: 'sys', type: 'system', - created_at: '2020-01-01T00:00:00.000Z', + created_at: convertDateToTimestamp('2020-01-01T00:00:00.000Z'), }); skipping.trackLastMessage(systemMessage); expect(skipping.lastMessageAt).toBeNull(); @@ -3520,7 +3732,7 @@ describe('MessagePaginator', () => { skipSystemMessages = false; const tracking = buildPaginator(); tracking.trackLastMessage(systemMessage); - expect(tracking.lastMessageAt?.getTime()).toBe(at('2020-01-01T00:00:00.000Z')); + expect(tracking.lastMessageAt).toBe(at('2020-01-01T00:00:00.000Z')); }); it('auto-tracks on ingestion for the main channel list too', () => { @@ -3529,33 +3741,33 @@ describe('MessagePaginator', () => { createMessage({ id: 'a', cid: 'channel-id', - created_at: '2020-01-01T00:00:00.000Z', + created_at: convertDateToTimestamp('2020-01-01T00:00:00.000Z'), }), ); // The main list no longer relies on an explicit channel-level call: ingestion advances the // lastMessageAt aggregate directly. - expect(paginator.lastMessageAt?.getTime()).toBe(at('2020-01-01T00:00:00.000Z')); + expect(paginator.lastMessageAt).toBe(at('2020-01-01T00:00:00.000Z')); }); it('seeds lastMessageAt from the server value (monotonic)', () => { const paginator = buildPaginator(); - paginator.seedLastMessageAt('2020-06-01T00:00:00.000Z'); - expect(paginator.lastMessageAt?.getTime()).toBe(at('2020-06-01T00:00:00.000Z')); + paginator.seedLastMessageAt(convertDateToTimestamp('2020-06-01T00:00:00.000Z')); + expect(paginator.lastMessageAt).toBe(at('2020-06-01T00:00:00.000Z')); // an older server value does not move it back - paginator.seedLastMessageAt('2020-01-01T00:00:00.000Z'); - expect(paginator.lastMessageAt?.getTime()).toBe(at('2020-06-01T00:00:00.000Z')); + paginator.seedLastMessageAt(convertDateToTimestamp('2020-01-01T00:00:00.000Z')); + expect(paginator.lastMessageAt).toBe(at('2020-06-01T00:00:00.000Z')); // a newer ingested message advances past the seed paginator.ingestItem( createMessage({ id: 'a', cid: 'channel-id', - created_at: '2021-01-01T00:00:00.000Z', + created_at: convertDateToTimestamp('2021-01-01T00:00:00.000Z'), }), ); - expect(paginator.lastMessageAt?.getTime()).toBe(at('2021-01-01T00:00:00.000Z')); + expect(paginator.lastMessageAt).toBe(at('2021-01-01T00:00:00.000Z')); }); describe('reply list (parentMessageId) auto-tracks on ingestion', () => { @@ -3564,21 +3776,21 @@ describe('MessagePaginator', () => { id, cid: 'channel-id', parent_id: 'parent', - created_at: createdAt, + created_at: convertDateToTimestamp(createdAt), }); it('advances to the newest reply on ingestItem, regardless of ingestion order', () => { const paginator = buildPaginator('parent'); paginator.ingestItem(reply('r2', '2020-01-01T00:00:02.000Z')); - expect(paginator.lastMessageAt?.getTime()).toBe(at('2020-01-01T00:00:02.000Z')); + expect(paginator.lastMessageAt).toBe(at('2020-01-01T00:00:02.000Z')); // an older reply arriving later must not move the value back paginator.ingestItem(reply('r1', '2020-01-01T00:00:01.000Z')); - expect(paginator.lastMessageAt?.getTime()).toBe(at('2020-01-01T00:00:02.000Z')); + expect(paginator.lastMessageAt).toBe(at('2020-01-01T00:00:02.000Z')); paginator.ingestItem(reply('r3', '2020-01-01T00:00:03.000Z')); - expect(paginator.lastMessageAt?.getTime()).toBe(at('2020-01-01T00:00:03.000Z')); + expect(paginator.lastMessageAt).toBe(at('2020-01-01T00:00:03.000Z')); }); it('advances to the newest reply when a page is seeded via setItems', () => { @@ -3593,16 +3805,19 @@ describe('MessagePaginator', () => { isFirstPage: true, }); - expect(paginator.lastMessageAt?.getTime()).toBe(at('2020-01-01T00:00:03.000Z')); + expect(paginator.lastMessageAt).toBe(at('2020-01-01T00:00:03.000Z')); }); }); it('resets lastMessageAt on clearStateAndCache()', () => { const paginator = buildPaginator(); paginator.trackLastMessage( - createMessage({ id: 'a', created_at: '2020-01-01T00:00:00.000Z' }), + createMessage({ + id: 'a', + created_at: convertDateToTimestamp('2020-01-01T00:00:00.000Z'), + }), ); - expect(paginator.lastMessageAt?.getTime()).toBe(at('2020-01-01T00:00:00.000Z')); + expect(paginator.lastMessageAt).toBe(at('2020-01-01T00:00:00.000Z')); paginator.clearStateAndCache(); @@ -3615,7 +3830,7 @@ describe('MessagePaginator', () => { createMessage({ id: 'a', cid: 'channel-id', - created_at: '2020-01-01T00:00:00.000Z', + created_at: convertDateToTimestamp('2020-01-01T00:00:00.000Z'), text: 'hello', }), ); @@ -3632,7 +3847,7 @@ describe('MessagePaginator', () => { createMessage({ id: 'a', cid: 'channel-id', - created_at: '2020-01-01T00:00:00.000Z', + created_at: convertDateToTimestamp('2020-01-01T00:00:00.000Z'), text: 'edited', }), ); @@ -3648,16 +3863,16 @@ describe('MessagePaginator', () => { createMessage({ id: 'a', cid: 'channel-id', - created_at: '2020-01-01T00:00:00.000Z', + created_at: convertDateToTimestamp('2020-01-01T00:00:00.000Z'), }), ); paginator.ingestItem( createMessage({ id: 'a', cid: 'channel-id', - created_at: '2020-01-01T00:00:00.000Z', + created_at: convertDateToTimestamp('2020-01-01T00:00:00.000Z'), type: 'deleted', - deleted_at: '2020-01-02T00:00:00.000Z', + deleted_at: convertDateToTimestamp('2020-01-02T00:00:00.000Z'), }), ); expect(paginator.lastMessage?.type).toBe('deleted'); @@ -3669,14 +3884,14 @@ describe('MessagePaginator', () => { createMessage({ id: 'a', cid: 'channel-id', - created_at: '2020-01-01T00:00:00.000Z', + created_at: convertDateToTimestamp('2020-01-01T00:00:00.000Z'), }), ); paginator.ingestItem( createMessage({ id: 'b', cid: 'channel-id', - created_at: '2020-01-02T00:00:00.000Z', + created_at: convertDateToTimestamp('2020-01-02T00:00:00.000Z'), }), ); expect(paginator.lastMessage?.id).toBe('b'); @@ -3695,14 +3910,14 @@ describe('MessagePaginator', () => { createMessage({ id: 'm0', cid: 'channel-id', - created_at: '2020-01-01T00:00:01.000Z', + created_at: convertDateToTimestamp('2020-01-01T00:00:01.000Z'), }), ); paginator.ingestItem( createMessage({ id: 'm1', cid: 'channel-id', - created_at: '2020-01-01T00:00:02.000Z', + created_at: convertDateToTimestamp('2020-01-01T00:00:02.000Z'), }), ); // A system message is the newest LOADED item, but the config keeps it from becoming the latest. @@ -3711,7 +3926,7 @@ describe('MessagePaginator', () => { id: 'sys', cid: 'channel-id', type: 'system', - created_at: '2020-01-01T00:00:03.000Z', + created_at: convertDateToTimestamp('2020-01-01T00:00:03.000Z'), }), ); expect(paginator.lastMessage?.id).toBe('m1'); diff --git a/test/unit/pagination/paginators/MessagePaginatorThrottle.test.ts b/test/unit/pagination/paginators/MessagePaginatorThrottle.test.ts index aee8f12932..df0d2a4825 100644 --- a/test/unit/pagination/paginators/MessagePaginatorThrottle.test.ts +++ b/test/unit/pagination/paginators/MessagePaginatorThrottle.test.ts @@ -5,6 +5,7 @@ import { EntityStore } from '../../../../src/entityStore/EntityStore'; import { applyReactionLocally } from '../../../../src/entityStore/applyReactionLocally'; import { formatMessage } from '../../../../src'; import { generateMsg } from '../../test-utils/generateMessage'; +import { convertDateToTimestamp } from '../../test-utils/time'; import type { Channel } from '../../../../src/channel'; import type { StreamChat } from '../../../../src/client'; import type { LocalMessage, MessageResponse, Reaction } from '../../../../src/types'; @@ -14,7 +15,9 @@ const msg = (id: string, day: number): LocalMessage => generateMsg({ id, cid: 'channel-id', - created_at: `2020-01-${String(day).padStart(2, '0')}T00:00:00.000Z`, + created_at: convertDateToTimestamp( + `2020-01-${String(day).padStart(2, '0')}T00:00:00.000Z`, + ), }) as MessageResponse, ); diff --git a/test/unit/pagination/paginators/PinnedMessagePaginator.test.ts b/test/unit/pagination/paginators/PinnedMessagePaginator.test.ts index cc4445c45e..3289c7c6bd 100644 --- a/test/unit/pagination/paginators/PinnedMessagePaginator.test.ts +++ b/test/unit/pagination/paginators/PinnedMessagePaginator.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from 'vitest'; import { PinnedMessagePaginator } from '../../../../src/pagination/paginators/PinnedMessagePaginator'; import type { LocalMessage, MessageResponse } from '../../../../src/types'; +import { convertDateToTimestamp } from '../../test-utils/time'; const CID = 'messaging:cid'; @@ -12,15 +13,15 @@ const makePinned = ( ({ attachments: [], cid: CID, - created_at: new Date(pinnedAtMs).toISOString(), + created_at: convertDateToTimestamp(new Date(pinnedAtMs).toISOString()), id, mentioned_users: [], pinned: true, - pinned_at: new Date(pinnedAtMs).toISOString(), + pinned_at: convertDateToTimestamp(new Date(pinnedAtMs).toISOString()), status: 'received', text: id, type: 'regular', - updated_at: new Date(pinnedAtMs).toISOString(), + updated_at: convertDateToTimestamp(new Date(pinnedAtMs).toISOString()), ...overrides, }) as MessageResponse; @@ -84,7 +85,7 @@ describe('PinnedMessagePaginator', () => { // Same message, now unpinned → matchesFilter({ pinned: true }) fails → removed from the list. paginator.ingestItem({ ...makePinned('p', 1000), - created_at: new Date(1000), + created_at: convertDateToTimestamp(new Date(1000)), pinned: false, pinned_at: null, } as unknown as LocalMessage); diff --git a/test/unit/pagination/paginators/UserGroupPaginator.test.ts b/test/unit/pagination/paginators/UserGroupPaginator.test.ts index bc263f232e..945bd0eda6 100644 --- a/test/unit/pagination/paginators/UserGroupPaginator.test.ts +++ b/test/unit/pagination/paginators/UserGroupPaginator.test.ts @@ -3,12 +3,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { StreamChat, UserGroupResponse } from '../../../../src'; import { UserGroupPaginator } from '../../../../src/pagination/paginators/UserGroupPaginator'; import { getClientWithUser } from '../../test-utils/getClient'; +import { convertDateToTimestamp } from '../../test-utils/time'; const makeGroup = (id: string, createdAt: string): UserGroupResponse => ({ id, name: id, - created_at: new Date(createdAt), - updated_at: new Date(createdAt), + created_at: convertDateToTimestamp(createdAt), + updated_at: convertDateToTimestamp(new Date(createdAt)), }); const response = (groups: UserGroupResponse[]) => ({ duration: '', user_groups: groups }); @@ -63,7 +64,32 @@ describe('UserGroupPaginator', () => { expect(spy).toHaveBeenLastCalledWith( expect.objectContaining({ id_gt: 'b', - created_at_gt: '2020-01-02T00:00:00.000Z', + created_at_gt: '2020-01-02T00:00:00.000000000Z', + }), + ); + }); + + it('carries sub-millisecond precision so the boundary item cannot repeat', async () => { + const paginator = new UserGroupPaginator(client, { pageSize: 2 }); + const spy = vi.spyOn(client, 'listUserGroups'); + const boundary = makeGroup('b', '2020-01-02T00:00:00.000Z'); + // 123392ns past the millisecond (a multiple of the ~256ns quantum a double holds at this + // magnitude, so it survives the round trip exactly). + boundary.created_at += 123392; + spy.mockResolvedValueOnce( + response([makeGroup('a', '2020-01-01T00:00:00.000Z'), boundary]), + ); + await paginator.executeQuery({}); + + spy.mockResolvedValueOnce(response([makeGroup('c', '2020-01-03T00:00:00.000Z')])); + await paginator.toTail(); + + // Flooring to milliseconds would emit '2020-01-02T00:00:00.000Z', which is strictly less + // than `boundary.created_at` — so a strict `created_at_gt` would return it a second time. + expect(spy).toHaveBeenLastCalledWith( + expect.objectContaining({ + created_at_gt: '2020-01-02T00:00:00.000123392Z', + id_gt: 'b', }), ); }); diff --git a/test/unit/poll.test.js b/test/unit/poll.test.js index f2af85c32e..3978e337a7 100644 --- a/test/unit/poll.test.js +++ b/test/unit/poll.test.js @@ -2,14 +2,15 @@ import sinon from 'sinon'; import { Poll, StreamChat } from '../../src'; import { describe, it, afterEach, expect, vi } from 'vitest'; +import { convertDateToTimestamp } from './test-utils/time'; const pollId = 'WD4SBRJvLoGwB4oAoCQGM'; const user1 = { id: 'admin', role: 'admin', - created_at: '2022-03-08T09:46:56.840739Z', - updated_at: '2024-09-13T13:53:32.883409Z', + created_at: convertDateToTimestamp('2022-03-08T09:46:56.840739Z'), + updated_at: convertDateToTimestamp('2024-09-13T13:53:32.883409Z'), last_active: '2024-10-23T08:14:23.299448386Z', banned: false, online: true, @@ -24,8 +25,8 @@ const user1Votes = [ option_id: '85610252-7d50-429c-8183-51a7eba46246', user_id: user1.id, user: user1, - created_at: '2024-10-22T15:58:27.756166Z', - updated_at: '2024-10-22T15:58:27.756166Z', + created_at: convertDateToTimestamp('2024-10-22T15:58:27.756166Z'), + updated_at: convertDateToTimestamp('2024-10-22T15:58:27.756166Z'), }, { poll_id: pollId, @@ -33,16 +34,16 @@ const user1Votes = [ option_id: 'dc22dcd6-4fc8-4c92-92c2-bfd63245724c', user_id: user1.id, user: user1, - created_at: '2024-10-22T15:58:25.886491Z', - updated_at: '2024-10-22T15:58:25.886491Z', + created_at: convertDateToTimestamp('2024-10-22T15:58:25.886491Z'), + updated_at: convertDateToTimestamp('2024-10-22T15:58:25.886491Z'), }, ]; const user2 = { id: 'SmithAnne', role: 'user', - created_at: '2022-01-27T08:28:28.412254Z', - updated_at: '2024-09-26T10:12:23.427141Z', + created_at: convertDateToTimestamp('2022-01-27T08:28:28.412254Z'), + updated_at: convertDateToTimestamp('2024-09-26T10:12:23.427141Z'), last_active: '2024-10-23T08:01:43.157632831Z', banned: false, online: true, @@ -58,8 +59,8 @@ const user2Votes = [ option_id: '7312e983-b042-4596-b5ce-f9e82deb363f', user_id: user2.id, user: user2, - created_at: '2024-10-22T16:00:50.2493Z', - updated_at: '2024-10-22T16:00:50.2493Z', + created_at: convertDateToTimestamp('2024-10-22T16:00:50.2493Z'), + updated_at: convertDateToTimestamp('2024-10-22T16:00:50.2493Z'), }, { poll_id: pollId, @@ -67,8 +68,8 @@ const user2Votes = [ option_id: '85610252-7d50-429c-8183-51a7eba46246', user_id: user2.id, user: user2, - created_at: '2024-10-22T16:00:54.410474Z', - updated_at: '2024-10-22T16:00:54.410474Z', + created_at: convertDateToTimestamp('2024-10-22T16:00:54.410474Z'), + updated_at: convertDateToTimestamp('2024-10-22T16:00:54.410474Z'), }, ]; @@ -80,8 +81,8 @@ const user1Answer = { answer_text: 'comment1', user_id: user1.id, user: user1, - created_at: '2024-10-23T13:12:57.944913Z', - updated_at: '2024-10-23T13:12:57.944913Z', + created_at: convertDateToTimestamp('2024-10-23T13:12:57.944913Z'), + updated_at: convertDateToTimestamp('2024-10-23T13:12:57.944913Z'), }; const user2Answer = { @@ -92,8 +93,8 @@ const user2Answer = { answer_text: 'comment2', user_id: user2.id, user: user2, - created_at: '2024-10-23T13:12:57.944913Z', - updated_at: '2024-10-23T13:12:57.944913Z', + created_at: convertDateToTimestamp('2024-10-23T13:12:57.944913Z'), + updated_at: convertDateToTimestamp('2024-10-23T13:12:57.944913Z'), }; const pollResponse = { @@ -139,8 +140,8 @@ const pollResponse = { own_votes: [...user1Votes, user1Answer], created_by_id: user1.id, created_by: user1, - created_at: '2024-10-22T15:28:20.580523Z', - updated_at: '2024-10-22T15:28:20.580523Z', + created_at: convertDateToTimestamp('2024-10-22T15:28:20.580523Z'), + updated_at: convertDateToTimestamp('2024-10-22T15:28:20.580523Z'), }; // const client = sinon.createStubInstance(StreamChat); @@ -261,8 +262,8 @@ describe('Poll', () => { option_id: 'dc22dcd6-4fc8-4c92-92c2-bfd63245724c', user_id: user2.id, user: user2, - created_at: '2024-10-23T15:58:27.756166Z', - updated_at: '2024-10-23T15:58:27.756166Z', + created_at: convertDateToTimestamp('2024-10-23T15:58:27.756166Z'), + updated_at: convertDateToTimestamp('2024-10-23T15:58:27.756166Z'), }; const vote_count = originalState.vote_count + 1; @@ -312,8 +313,8 @@ describe('Poll', () => { option_id: 'ba933470-c0da-4b6f-a4d2-d2176ac0d4a8', user_id: user1.id, user: user1, - created_at: '2024-10-23T15:58:27.756166Z', - updated_at: '2024-10-23T15:58:27.756166Z', + created_at: convertDateToTimestamp('2024-10-23T15:58:27.756166Z'), + updated_at: convertDateToTimestamp('2024-10-23T15:58:27.756166Z'), }; const vote_count = originalState.vote_count + 1; @@ -362,8 +363,8 @@ describe('Poll', () => { is_answer: true, user_id: user2.id, user: user2, - created_at: '2024-10-23T15:58:27.756166Z', - updated_at: '2024-10-23T15:58:27.756166Z', + created_at: convertDateToTimestamp('2024-10-23T15:58:27.756166Z'), + updated_at: convertDateToTimestamp('2024-10-23T15:58:27.756166Z'), }; poll.handleVoteCasted({ @@ -393,8 +394,8 @@ describe('Poll', () => { is_answer: true, user_id: user1.id, user: user1, - created_at: '2024-10-23T15:58:27.756166Z', - updated_at: '2024-10-23T15:58:27.756166Z', + created_at: convertDateToTimestamp('2024-10-23T15:58:27.756166Z'), + updated_at: convertDateToTimestamp('2024-10-23T15:58:27.756166Z'), }; poll.handleVoteCasted({ @@ -423,7 +424,7 @@ describe('Poll', () => { option_id: changedToOptionId, user_id: user2.id, user: user2, - created_at: '2024-10-23T15:58:27.756166Z', + created_at: convertDateToTimestamp('2024-10-23T15:58:27.756166Z'), }; const vote_counts_by_option = { @@ -467,7 +468,7 @@ describe('Poll', () => { option_id: changedToOptionId, user_id: user1.id, user: user1, - created_at: '2024-10-23T15:58:27.756166Z', + created_at: convertDateToTimestamp('2024-10-23T15:58:27.756166Z'), }; const vote_counts_by_option = { diff --git a/test/unit/poll_manager.test.ts b/test/unit/poll_manager.test.ts index 12c1978208..c9fc8db42d 100644 --- a/test/unit/poll_manager.test.ts +++ b/test/unit/poll_manager.test.ts @@ -15,6 +15,7 @@ import { } from '../../src'; import { describe, beforeEach, afterEach, it, expect } from 'vitest'; +import { convertDateToTimestamp } from './test-utils/time'; const TEST_USER_ID = 'observer'; @@ -30,8 +31,8 @@ const generatePollMessage = ( const user1 = { id: 'admin', role: 'admin', - created_at: '2022-03-08T09:46:56.840739Z', - updated_at: '2024-09-13T13:53:32.883409Z', + created_at: convertDateToTimestamp('2022-03-08T09:46:56.840739Z'), + updated_at: convertDateToTimestamp('2024-09-13T13:53:32.883409Z'), last_active: '2024-10-23T08:14:23.299448386Z', banned: false, online: true, @@ -46,8 +47,8 @@ const generatePollMessage = ( option_id: '85610252-7d50-429c-8183-51a7eba46246', user_id: user1.id, user: user1, - created_at: '2024-10-22T15:58:27.756166Z', - updated_at: '2024-10-22T15:58:27.756166Z', + created_at: convertDateToTimestamp('2024-10-22T15:58:27.756166Z'), + updated_at: convertDateToTimestamp('2024-10-22T15:58:27.756166Z'), }, { poll_id: pollId, @@ -55,16 +56,16 @@ const generatePollMessage = ( option_id: 'dc22dcd6-4fc8-4c92-92c2-bfd63245724c', user_id: user1.id, user: user1, - created_at: '2024-10-22T15:58:25.886491Z', - updated_at: '2024-10-22T15:58:25.886491Z', + created_at: convertDateToTimestamp('2024-10-22T15:58:25.886491Z'), + updated_at: convertDateToTimestamp('2024-10-22T15:58:25.886491Z'), }, ]; const user2 = { id: 'SmithAnne', role: 'user', - created_at: '2022-01-27T08:28:28.412254Z', - updated_at: '2024-09-26T10:12:23.427141Z', + created_at: convertDateToTimestamp('2022-01-27T08:28:28.412254Z'), + updated_at: convertDateToTimestamp('2024-09-26T10:12:23.427141Z'), last_active: '2024-10-23T08:01:43.157632831Z', banned: false, online: true, @@ -80,8 +81,8 @@ const generatePollMessage = ( option_id: '7312e983-b042-4596-b5ce-f9e82deb363f', user_id: user2.id, user: user2, - created_at: '2024-10-22T16:00:50.2493Z', - updated_at: '2024-10-22T16:00:50.2493Z', + created_at: convertDateToTimestamp('2024-10-22T16:00:50.2493Z'), + updated_at: convertDateToTimestamp('2024-10-22T16:00:50.2493Z'), }, { poll_id: pollId, @@ -89,8 +90,8 @@ const generatePollMessage = ( option_id: 'ba933470-c0da-4b6f-a4d2-d2176ac0d4a8', user_id: user2.id, user: user2, - created_at: '2024-10-22T16:00:54.410474Z', - updated_at: '2024-10-22T16:00:54.410474Z', + created_at: convertDateToTimestamp('2024-10-22T16:00:54.410474Z'), + updated_at: convertDateToTimestamp('2024-10-22T16:00:54.410474Z'), }, ]; @@ -102,8 +103,8 @@ const generatePollMessage = ( answer_text: 'comment1', user_id: user1.id, user: user1, - created_at: '2024-10-23T13:12:57.944913Z', - updated_at: '2024-10-23T13:12:57.944913Z', + created_at: convertDateToTimestamp('2024-10-23T13:12:57.944913Z'), + updated_at: convertDateToTimestamp('2024-10-23T13:12:57.944913Z'), }; const pollResponse = { @@ -149,8 +150,8 @@ const generatePollMessage = ( own_votes: [...user1Votes, user1Answer], created_by_id: user1.id, created_by: user1, - created_at: '2024-10-22T15:28:20.580523Z', - updated_at: '2024-10-22T15:28:20.580523Z', + created_at: convertDateToTimestamp('2024-10-22T15:28:20.580523Z'), + updated_at: convertDateToTimestamp('2024-10-22T15:28:20.580523Z'), ...extraData, }; diff --git a/test/unit/reactions.optimistic.test.ts b/test/unit/reactions.optimistic.test.ts index 7b5b6d575e..e280b92c70 100644 --- a/test/unit/reactions.optimistic.test.ts +++ b/test/unit/reactions.optimistic.test.ts @@ -13,6 +13,7 @@ import { generateUUIDv4 as uuidv4 } from '../../src/utils'; import { MockOfflineDB } from './offline-support/MockOfflineDB'; import { generateChannel } from './test-utils/generateChannel'; import { generateMsg } from './test-utils/generateMessage'; +import { convertDateToTimestamp } from './test-utils/time'; const CURRENT_USER = { id: 'me' }; @@ -45,10 +46,10 @@ const enableOfflineDb = (client: StreamChat) => { }; const ownReaction = (type: string, messageId: string): ReactionResponse => ({ - created_at: '2020-01-01T00:00:00.000Z', + created_at: convertDateToTimestamp('2020-01-01T00:00:00.000Z'), message_id: messageId, type, - updated_at: '2020-01-01T00:00:00.000Z', + updated_at: convertDateToTimestamp('2020-01-01T00:00:00.000Z'), user: CURRENT_USER, user_id: CURRENT_USER.id, }); @@ -487,7 +488,7 @@ describe('optimistic reactions', () => { const message = generateMsg({ cid: channel.cid, pinned: true, - pinned_at: '2020-01-01T00:00:00.000Z', + pinned_at: convertDateToTimestamp('2020-01-01T00:00:00.000Z'), }); // The main list holds it... seed(channel, message); diff --git a/test/unit/reminders/Reminder.test.ts b/test/unit/reminders/Reminder.test.ts index 62b14f97bc..99fd2e7e1e 100644 --- a/test/unit/reminders/Reminder.test.ts +++ b/test/unit/reminders/Reminder.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest'; import { DEFAULT_STOP_REFRESH_BOUNDARY_MS, Reminder, ReminderTimer } from '../../../src'; import { sleep } from '../../../src/utils'; import { generateReminderResponse } from './ReminderManager.test'; +import { msToNs, nowNs, nsToMs } from '../../../src/utils/time'; describe('Reminder', () => { it('constructor sets up state for bookmark reminder', () => { @@ -9,9 +10,7 @@ describe('Reminder', () => { const reminder = new Reminder({ data }); expect(reminder.state.getLatestValue()).toEqual({ ...data, - created_at: new Date(data.created_at), remind_at: null, - updated_at: new Date(data.updated_at), timeLeftMs: null, }); expect(reminder.timer).toBeInstanceOf(ReminderTimer); @@ -22,26 +21,22 @@ describe('Reminder', () => { const scheduleOffsetMs = 62 * 1000; const data = generateReminderResponse({ scheduleOffsetMs }); const reminder = new Reminder({ data }); - const now = new Date(); - const remindAtDate = new Date(data.remind_at!); + const now = nowNs(); + const remindAt = data.remind_at!; const reminderState = reminder!.state.getLatestValue(); expect({ ...reminderState, timeLeftMs: Math.round(reminderState.timeLeftMs! / 1000) * 1000, }).toEqual({ ...data, - created_at: new Date(data.created_at), - remind_at: remindAtDate, - updated_at: new Date(data.updated_at), + remind_at: remindAt, timeLeftMs: scheduleOffsetMs, }); // Compared with a tolerance, not floored-and-equal: `timeLeftMs` is sampled inside the constructor // and `now` after it, so two independent clock reads straddling a whole-second boundary made this // fail intermittently under full-suite load. expect( - Math.abs( - (reminderState!.timeLeftMs as number) - (remindAtDate.getTime() - now.getTime()), - ), + Math.abs((reminderState!.timeLeftMs as number) - nsToMs(remindAt - now)), ).toBeLessThan(1000); expect(reminder.timer).toBeInstanceOf(ReminderTimer); expect(reminder.timer.timeout).toEqual(expect.any(Object)); @@ -52,7 +47,7 @@ describe('Reminder', () => { const data = generateReminderResponse({ scheduleOffsetMs }); const reminder = new Reminder({ data }); const timerInitSpy = vi.spyOn(reminder.timer, 'init'); - reminder.setState({ ...data, remind_at: new Date() }); + reminder.setState({ ...data, remind_at: nowNs() }); expect(reminder.timeLeftMs).toBe(0); expect(timerInitSpy).toHaveBeenCalledTimes(1); }); @@ -66,7 +61,7 @@ describe('Reminder', () => { vi.advanceTimersByTime(scheduleOffsetMs + DEFAULT_STOP_REFRESH_BOUNDARY_MS); reminder.setState({ ...data, - remind_at: new Date(orignalRemindAt!.getTime() - 1000), + remind_at: orignalRemindAt! - msToNs(1000), }); expect(reminder.timer.timeout).toBeNull(); expect(reminder.timeLeftMs).toBe(-1 * (DEFAULT_STOP_REFRESH_BOUNDARY_MS + 1000)); diff --git a/test/unit/reminders/ReminderManager.test.ts b/test/unit/reminders/ReminderManager.test.ts index 2da41191e6..859d01545f 100644 --- a/test/unit/reminders/ReminderManager.test.ts +++ b/test/unit/reminders/ReminderManager.test.ts @@ -13,6 +13,8 @@ import { } from '../../../src'; import { describe, expect, it, vi } from 'vitest'; import { PaginationQueryReturnValue } from '../../../src/pagination'; +import { msToNs, nowNs, nsToMs } from '../../../src/utils/time'; +import { convertDateToTimestamp } from '../test-utils/time'; const baseData = { channel_cid: 'channel_cid', @@ -27,7 +29,7 @@ export const generateReminderResponse = ({ data?: Partial; scheduleOffsetMs?: number; } = {}): ReminderResponseData => { - const created_at = new Date(); + const created_at = nowNs(); const basePayload = { ...baseData, created_at, @@ -36,7 +38,7 @@ export const generateReminderResponse = ({ user: { id: baseData.user_id }, } as ReminderResponseData; if (typeof scheduleOffsetMs === 'number') { - basePayload.remind_at = new Date(created_at.getTime() + scheduleOffsetMs); + basePayload.remind_at = created_at + msToNs(scheduleOffsetMs); } return { ...basePayload, @@ -48,7 +50,7 @@ const generateReminderEvent = (type: ListenerKeys, reminder: ReminderResponseDat ({ ...baseData, cid: baseData.channel_cid, - created_at: new Date(), + created_at: nowNs(), reminder, type, }) as EventPayload; @@ -121,9 +123,7 @@ describe('ReminderManager', () => { manager.reminders.get(reminderResponse.message_id)?.state.getLatestValue(), ).toEqual({ ...reminderResponse, - created_at: new Date(reminderResponse.created_at), remind_at: null, - updated_at: new Date(reminderResponse.updated_at), timeLeftMs: null, }); }); @@ -132,7 +132,7 @@ describe('ReminderManager', () => { const client = new StreamChat('api-key'); const manager = new ReminderManager({ client }); const scheduleOffsetMs = 62 * 1000; - const now = new Date().getTime(); + const now = nowNs(); const reminderResponse = generateReminderResponse({ scheduleOffsetMs }); manager.upsertToState({ data: reminderResponse }); @@ -141,19 +141,15 @@ describe('ReminderManager', () => { const reminder = manager.getFromState(reminderResponse.message_id); expect(reminder).toBeInstanceOf(Reminder); - const remindAtDate = new Date( - new Date(reminderResponse.created_at).getTime() + scheduleOffsetMs, - ); + const remindAt = reminderResponse.created_at + msToNs(scheduleOffsetMs); const reminderState = reminder!.state.getLatestValue(); expect(reminderState).toEqual({ ...reminderResponse, - created_at: new Date(reminderResponse.created_at), - remind_at: remindAtDate, - updated_at: new Date(reminderResponse.updated_at), + remind_at: remindAt, timeLeftMs: expect.any(Number), }); expect(Math.floor((reminderState!.timeLeftMs as number) / 10000)).toBe( - Math.floor((remindAtDate.getTime() - now) / 10000), + Math.floor(nsToMs(remindAt - now) / 10000), ); }); @@ -179,9 +175,7 @@ describe('ReminderManager', () => { manager.reminders.get(reminderResponse.message_id)?.state.getLatestValue(), ).toEqual({ ...reminderResponse, - created_at: new Date(reminderResponse.created_at), remind_at: null, - updated_at: new Date(reminderResponse.updated_at), timeLeftMs: null, }); }); @@ -202,9 +196,7 @@ describe('ReminderManager', () => { manager.reminders.get(reminderResponse.message_id)?.state.getLatestValue(), ).toEqual({ ...reminderResponse, - created_at: new Date(reminderResponse.created_at), remind_at: null, - updated_at: new Date(reminderResponse.updated_at), timeLeftMs: null, }); }); @@ -275,9 +267,7 @@ describe('ReminderManager', () => { manager.reminders.get(reminderResponse.message_id)?.state.getLatestValue(), ).toEqual({ ...reminderResponse, - created_at: new Date(reminderResponse.created_at), remind_at: null, - updated_at: new Date(reminderResponse.updated_at), timeLeftMs: null, }); }); @@ -288,26 +278,22 @@ describe('ReminderManager', () => { manager.registerSubscriptions(); const scheduleOffsetMs = 62 * 1000; - const now = new Date().getTime(); + const now = nowNs(); const reminderResponse = generateReminderResponse({ scheduleOffsetMs }); const type: ListenerKeys = 'reminder.created'; client.dispatchEvent(generateReminderEvent(type, reminderResponse)); const reminder = manager.getFromState(reminderResponse.message_id); expect(reminder).toBeInstanceOf(Reminder); - const remindAtDate = new Date( - new Date(reminderResponse.created_at).getTime() + scheduleOffsetMs, - ); + const remindAt = reminderResponse.created_at + msToNs(scheduleOffsetMs); const reminderState = reminder!.state.getLatestValue(); expect(reminderState).toEqual({ ...reminderResponse, - created_at: new Date(reminderResponse.created_at), - remind_at: remindAtDate, - updated_at: new Date(reminderResponse.updated_at), + remind_at: remindAt, timeLeftMs: expect.any(Number), }); expect(Math.floor((reminderState!.timeLeftMs as number) / 10000)).toBe( - Math.floor((remindAtDate.getTime() - now) / 10000), + Math.floor(nsToMs(remindAt - now) / 10000), ); }); @@ -318,12 +304,11 @@ describe('ReminderManager', () => { const reminderResponse = generateReminderResponse(); manager.upsertToState({ data: reminderResponse }); - reminderResponse.remind_at = new Date('1970-01-01'); + reminderResponse.remind_at = convertDateToTimestamp('1970-01-01'); const type: ListenerKeys = 'reminder.updated'; - const now = new Date(); client.dispatchEvent(generateReminderEvent(type, reminderResponse)); expect(manager.reminders.size).toBe(1); - const remindAtDate = new Date('1970-01-01'); + const remindAt = convertDateToTimestamp('1970-01-01'); const { timeLeftMs, ...state } = manager.reminders .get(reminderResponse.message_id) ?.state.getLatestValue() as ReminderState; @@ -332,10 +317,8 @@ describe('ReminderManager', () => { timeLeftMs: Math.round((timeLeftMs ?? 0) / 1000), }).toEqual({ ...reminderResponse, - created_at: new Date(reminderResponse.created_at), - remind_at: remindAtDate, - updated_at: new Date(reminderResponse.updated_at), - timeLeftMs: Math.round((remindAtDate.getTime() - now.getTime()) / 1000), + remind_at: remindAt, + timeLeftMs: Math.round(nsToMs(remindAt - nowNs()) / 1000), }); }); it('removes reminder from state on reminder.deleted event', () => { @@ -370,11 +353,14 @@ describe('ReminderManager', () => { it('creates a reminder server-side and updates the state', async () => { const client = new StreamChat('api-key'); const manager = new ReminderManager({ client }); - const reminderResponse = { - ...generateReminderResponse(), + const reminderResponse = generateReminderResponse(); + // The response wraps the reminder, like updateReminder's below: the spec used to + // declare this endpoint as returning ReminderResponseData bare (CHA-4993). + vi.spyOn(client, 'createReminder').mockResolvedValueOnce({ + duration: '0ms', + reminder: reminderResponse, metadata: {} as RequestMetadata, - }; - vi.spyOn(client, 'createReminder').mockResolvedValueOnce(reminderResponse); + }); const stateUpdateSpy = vi .spyOn(manager, 'upsertToState') .mockReturnValueOnce(undefined); diff --git a/test/unit/search/ChannelMemberSearchSource.test.ts b/test/unit/search/ChannelMemberSearchSource.test.ts index 9c8ddc3e9d..233826390e 100644 --- a/test/unit/search/ChannelMemberSearchSource.test.ts +++ b/test/unit/search/ChannelMemberSearchSource.test.ts @@ -7,12 +7,13 @@ import type { MemberFilters, SortParamRequest, } from '../../../src/types'; +import { convertDateToTimestamp } from '../test-utils/time'; const createChannelMember = ( overrides: Partial = {}, ): ChannelMemberResponse => ({ - created_at: '2026-01-01T00:00:00.000000000Z', - updated_at: '2026-01-01T00:00:00.000000000Z', + created_at: convertDateToTimestamp('2026-01-01T00:00:00.000000000Z'), + updated_at: convertDateToTimestamp('2026-01-01T00:00:00.000000000Z'), user_id: 'user-1', ...overrides, }); diff --git a/test/unit/search/searchDebounce.test.ts b/test/unit/search/searchDebounce.test.ts index 5319602eae..cf968afed2 100644 --- a/test/unit/search/searchDebounce.test.ts +++ b/test/unit/search/searchDebounce.test.ts @@ -3,13 +3,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { Channel } from '../../../src/channel'; import { ChannelMemberSearchSource } from '../../../src/search/ChannelMemberSearchSource'; import type { ChannelMemberResponse } from '../../../src/types'; +import { convertDateToTimestamp } from '../test-utils/time'; const SHORT_QUERY_DEBOUNCE_MS = 500; const LONG_QUERY_DEBOUNCE_MS = 300; const createChannelMember = (userId: string): ChannelMemberResponse => ({ - created_at: '2026-01-01T00:00:00.000000000Z', - updated_at: '2026-01-01T00:00:00.000000000Z', + created_at: convertDateToTimestamp('2026-01-01T00:00:00.000000000Z'), + updated_at: convertDateToTimestamp('2026-01-01T00:00:00.000000000Z'), user_id: userId, }); diff --git a/test/unit/test-utils/generateChannel.ts b/test/unit/test-utils/generateChannel.ts index f7f2fce676..566fc3d2da 100644 --- a/test/unit/test-utils/generateChannel.ts +++ b/test/unit/test-utils/generateChannel.ts @@ -1,4 +1,5 @@ import { generateUUIDv4 as uuidv4 } from '../../../src/utils'; +import { convertDateToTimestamp } from './time'; import { ChannelStateResponseFields, ChannelConfigWithInfo, @@ -30,22 +31,22 @@ export const generateChannel = ( id, type, cid: `${type}:${id}`, - created_at: new Date('2020-04-28T11:20:48.578147Z'), - updated_at: new Date('2020-04-28T11:20:48.578147Z'), + created_at: convertDateToTimestamp('2020-04-28T11:20:48.578147Z'), + updated_at: convertDateToTimestamp('2020-04-28T11:20:48.578147Z'), created_by: { id: 'vishal', role: 'user', - created_at: new Date('2020-04-27T13:05:13.847572Z'), - updated_at: new Date('2020-04-28T11:21:08.357468Z'), - last_active: new Date('2020-04-28T11:21:08.353026Z'), + created_at: convertDateToTimestamp('2020-04-27T13:05:13.847572Z'), + updated_at: convertDateToTimestamp('2020-04-28T11:21:08.357468Z'), + last_active: convertDateToTimestamp('2020-04-28T11:21:08.353026Z'), banned: false, online: false, }, frozen: false, disabled: false, config: { - created_at: new Date('2020-04-24T11:36:43.859020368Z'), - updated_at: new Date('2020-04-24T11:36:43.859022903Z'), + created_at: convertDateToTimestamp('2020-04-24T11:36:43.859020368Z'), + updated_at: convertDateToTimestamp('2020-04-24T11:36:43.859022903Z'), name: 'messaging', typing_events: true, read_events: true, diff --git a/test/unit/test-utils/generateMessage.ts b/test/unit/test-utils/generateMessage.ts index e22c026953..1f10d44502 100644 --- a/test/unit/test-utils/generateMessage.ts +++ b/test/unit/test-utils/generateMessage.ts @@ -1,11 +1,12 @@ import { generateUUIDv4 as uuidv4 } from '../../../src/utils'; import type { MessageResponse, UserResponse } from '../../../src'; +import { convertDateToTimestamp } from './time'; export const generateMsg = ( - msg: Partial & { date?: Date } = {}, + msg: Partial & { date?: Date | number | string } = {}, ): MessageResponse => { - const date = msg?.date ?? new Date(); - return { + const date = convertDateToTimestamp(msg?.date); + const message = { cid: 'messaging:general', pinned: false, id: uuidv4(), @@ -25,5 +26,7 @@ export const generateMsg = ( silent: false, status: 'received', ...msg, - }; + } as MessageResponse; + + return message; }; diff --git a/test/unit/test-utils/generateMessageDraft.ts b/test/unit/test-utils/generateMessageDraft.ts index 57151ea125..c852140230 100644 --- a/test/unit/test-utils/generateMessageDraft.ts +++ b/test/unit/test-utils/generateMessageDraft.ts @@ -1,5 +1,6 @@ import { generateChannel } from './generateChannel'; import { generateMsg } from './generateMessage'; +import { convertDateToTimestamp } from './time'; import type { ChannelResponse, DraftResponse } from '../../../src'; export const generateMessageDraft = ({ @@ -12,7 +13,7 @@ export const generateMessageDraft = ({ return { channel, channel_cid: channel.cid, - created_at: new Date(), + created_at: convertDateToTimestamp(), message: generateMsg(), ...customMsgDraft, } as DraftResponse; diff --git a/test/unit/test-utils/generateReadResponse.js b/test/unit/test-utils/generateReadResponse.js index 92cdc7ee1d..05cd2c04d2 100644 --- a/test/unit/test-utils/generateReadResponse.js +++ b/test/unit/test-utils/generateReadResponse.js @@ -1,9 +1,10 @@ import { generateUser } from './generateUser'; +import { convertDateToTimestamp } from './time'; export const generateReadResponse = (options = {}) => { const userResponse = options.user ?? generateUser(); return { - last_read: new Date(), + last_read: convertDateToTimestamp(), user: userResponse, last_read_message_id: '123321', unread_messages: 0, diff --git a/test/unit/test-utils/generateThreadResponse.js b/test/unit/test-utils/generateThreadResponse.js index 108a16bad9..918cd3cd24 100644 --- a/test/unit/test-utils/generateThreadResponse.js +++ b/test/unit/test-utils/generateThreadResponse.js @@ -1,13 +1,15 @@ +import { convertDateToTimestamp } from './time'; + export const generateThreadResponse = (channel, parent, opts = {}) => { return { parent_message_id: parent.id, parent_message: parent, channel, title: 'title', - created_at: new Date(), - updated_at: new Date(), + created_at: convertDateToTimestamp(), + updated_at: convertDateToTimestamp(), channel_cid: channel.cid, - last_message_at: new Date(), + last_message_at: convertDateToTimestamp(), deleted_at: undefined, read: [], reply_count: 0, diff --git a/test/unit/test-utils/generateUser.js b/test/unit/test-utils/generateUser.js index bbaaa9c783..f46cf5644a 100644 --- a/test/unit/test-utils/generateUser.js +++ b/test/unit/test-utils/generateUser.js @@ -1,4 +1,5 @@ import { generateUUIDv4 as uuidv4 } from '../../../src/utils'; +import { convertDateToTimestamp } from './time'; export const generateUser = (options = {}) => { return { @@ -6,8 +7,8 @@ export const generateUser = (options = {}) => { name: uuidv4(), image: uuidv4(), role: 'user', - created_at: new Date('2020-04-27T13:39:49.331742Z'), - updated_at: new Date('2020-04-27T13:39:49.332087Z'), + created_at: convertDateToTimestamp('2020-04-27T13:39:49.331742Z'), + updated_at: convertDateToTimestamp('2020-04-27T13:39:49.332087Z'), banned: false, online: false, ...options, diff --git a/test/unit/test-utils/mockChannelQueryResponse.js b/test/unit/test-utils/mockChannelQueryResponse.js index 554dea5046..db6cf21b0b 100644 --- a/test/unit/test-utils/mockChannelQueryResponse.js +++ b/test/unit/test-utils/mockChannelQueryResponse.js @@ -1,17 +1,18 @@ +import { convertDateToTimestamp } from './time'; export const mockChannelQueryResponse = { channel: { id: '!members-VTb-DIRK8V2pHHguhdYktHnmOGCfRzphbD2BDcXjItE', type: 'messaging', cid: 'messaging:!members-VTb-DIRK8V2pHHguhdYktHnmOGCfRzphbD2BDcXjItE', - last_message_at: '2023-11-14T12:39:29.396443Z', - created_at: '2023-08-18T09:06:45.421888Z', - updated_at: '2023-11-15T08:26:11.595394Z', + last_message_at: convertDateToTimestamp('2023-11-14T12:39:29.396443Z'), + created_at: convertDateToTimestamp('2023-08-18T09:06:45.421888Z'), + updated_at: convertDateToTimestamp('2023-11-15T08:26:11.595394Z'), created_by: { id: 'zitaszuperagetstreamio', role: 'admin', - created_at: '2021-09-14T13:07:45.764353Z', - updated_at: '2023-11-02T10:43:16.258959Z', - last_active: '2022-07-15T07:08:03.807199Z', + created_at: convertDateToTimestamp('2021-09-14T13:07:45.764353Z'), + updated_at: convertDateToTimestamp('2023-11-02T10:43:16.258959Z'), + last_active: convertDateToTimestamp('2022-07-15T07:08:03.807199Z'), banned: false, online: false, first_name: 'Zita', @@ -26,8 +27,8 @@ export const mockChannelQueryResponse = { disabled: false, member_count: 2, config: { - created_at: '2021-11-10T13:31:08.527632Z', - updated_at: '2023-10-25T11:22:29.482779Z', + created_at: convertDateToTimestamp('2021-11-10T13:31:08.527632Z'), + updated_at: convertDateToTimestamp('2023-10-25T11:22:29.482779Z'), name: 'messaging', typing_events: true, read_events: true, @@ -120,16 +121,16 @@ export const mockChannelQueryResponse = { user: { id: 'sara-angular-test', role: 'user', - created_at: '2023-06-27T10:13:12.796304Z', - updated_at: '2023-11-03T09:48:35.748166Z', - last_active: '2023-11-14T07:47:29.732473872Z', + created_at: convertDateToTimestamp('2023-06-27T10:13:12.796304Z'), + updated_at: convertDateToTimestamp('2023-11-03T09:48:35.748166Z'), + last_active: convertDateToTimestamp('2023-11-14T07:47:29.732473872Z'), banned: false, online: false, name: 'Jack', email: '', }, - created_at: '2023-08-18T09:06:45.431978Z', - updated_at: '2023-08-18T09:06:45.431978Z', + created_at: convertDateToTimestamp('2023-08-18T09:06:45.431978Z'), + updated_at: convertDateToTimestamp('2023-08-18T09:06:45.431978Z'), banned: false, shadow_banned: false, role: 'member', @@ -141,9 +142,9 @@ export const mockChannelQueryResponse = { user: { id: 'zitaszuperagetstreamio', role: 'admin', - created_at: '2021-09-14T13:07:45.764353Z', - updated_at: '2023-11-02T10:43:16.258959Z', - last_active: '2022-07-15T07:08:03.807199Z', + created_at: convertDateToTimestamp('2021-09-14T13:07:45.764353Z'), + updated_at: convertDateToTimestamp('2023-11-02T10:43:16.258959Z'), + last_active: convertDateToTimestamp('2022-07-15T07:08:03.807199Z'), banned: false, online: false, last_name: 'Szupera', @@ -154,8 +155,8 @@ export const mockChannelQueryResponse = { email: 'zita.szupera@getstream.io', image: 'https://getstream.io/random_png/?id=little-wood-9\u0026name=little-wood-9', }, - created_at: '2023-08-18T09:06:45.431978Z', - updated_at: '2023-08-18T09:06:45.431978Z', + created_at: convertDateToTimestamp('2023-08-18T09:06:45.431978Z'), + updated_at: convertDateToTimestamp('2023-08-18T09:06:45.431978Z'), banned: false, shadow_banned: false, role: 'owner', diff --git a/test/unit/test-utils/time.ts b/test/unit/test-utils/time.ts new file mode 100644 index 0000000000..91d188a098 --- /dev/null +++ b/test/unit/test-utils/time.ts @@ -0,0 +1,19 @@ +import { dateToNs, msToNs, nowNs } from '../../../src/utils/time'; + +/** + * 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 cannot catch the bugs this + * unit exists to prevent — but a test is far more readable 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. Pass + * a `Date` or an ISO string if you mean wall-clock time. + */ +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/test/unit/threads.test.ts b/test/unit/threads.test.ts index c284578cc7..964b81353a 100644 --- a/test/unit/threads.test.ts +++ b/test/unit/threads.test.ts @@ -19,6 +19,8 @@ import { } from '../../src'; import { describe, it, beforeEach, expect, afterEach } from 'vitest'; +import { dateToNs, msToNs, nowNs } from '../../src/utils/time'; +import { convertDateToTimestamp } from './test-utils/time'; const TEST_USER_ID = 'observer'; @@ -177,11 +179,11 @@ describe('Threads 2.0', () => { const thread = createTestThread({ latest_replies: [], reply_count: 0, - last_message_at: '2030-01-01T00:00:00.000Z', + last_message_at: convertDateToTimestamp('2030-01-01T00:00:00.000Z'), }); // The server floor seeds the sort key even with no replies loaded to display. - expect(thread.messagePaginator.lastMessageAt?.getTime()).to.equal( - new Date('2030-01-01T00:00:00.000Z').getTime(), + expect(thread.messagePaginator.lastMessageAt).to.equal( + convertDateToTimestamp('2030-01-01T00:00:00.000Z'), ); expect(thread.messagePaginator.lastMessage).to.be.null; }); @@ -222,7 +224,7 @@ describe('Threads 2.0', () => { const thread = createMinimalThread({ draft: { channel_cid: channel.cid, - created_at: new Date().toISOString(), + created_at: nowNs(), message: { id: draftId, text: 'draft text', @@ -275,12 +277,12 @@ describe('Threads 2.0', () => { it('updates optimistically added message', () => { const optimisticMessage = makeReply({ text: 'aaa', - created_at: new Date('2020-01-01T00:00:00Z'), + created_at: convertDateToTimestamp(new Date('2020-01-01T00:00:00Z')), }) as MessageResponse; const message = makeReply({ text: 'bbb', - created_at: new Date('2020-01-01T00:00:10Z'), + created_at: convertDateToTimestamp(new Date('2020-01-01T00:00:10Z')), }) as MessageResponse; const thread = createTestThread({ @@ -290,7 +292,7 @@ describe('Threads 2.0', () => { const updatedMessage: MessageResponse = { ...optimisticMessage, text: 'ccc', - created_at: new Date('2020-01-01T00:00:20Z'), + created_at: convertDateToTimestamp(new Date('2020-01-01T00:00:20Z')), }; const repliesBefore = repliesOf(thread); @@ -332,7 +334,7 @@ describe('Threads 2.0', () => { { id: 'participant-1' }, ] as unknown as ThreadResponse['thread_participants']; const updatedMessage = generateMsg({ - deleted_at: new Date(), + deleted_at: convertDateToTimestamp(new Date()), id: parentMessageResponse.id, reply_count: 10, text: 'aaa', @@ -343,9 +345,7 @@ describe('Threads 2.0', () => { const stateAfter = thread.state.getLatestValue(); expect(stateAfter.deletedAt).to.be.not.null; - expect(stateAfter.deletedAt!.toISOString()).to.equal( - updatedMessage.deleted_at!.toISOString(), - ); + expect(stateAfter.deletedAt).to.equal(updatedMessage.deleted_at); expect(stateAfter.replyCount).to.equal(updatedMessage.reply_count); expect(stateAfter.participants).to.have.lengthOf(1); expect(stateAfter.participants?.[0].user_id).to.equal('participant-1'); @@ -428,7 +428,11 @@ describe('Threads 2.0', () => { it('retains failed replies after hydration', () => { const thread = createTestThread(); const hydrationThread = createTestThread({ - latest_replies: [makeReply({ created_at: '2020-01-01T00:00:01.000Z' })], + latest_replies: [ + makeReply({ + created_at: convertDateToTimestamp('2020-01-01T00:00:01.000Z'), + }), + ], reply_count: 1, }); @@ -436,7 +440,7 @@ describe('Threads 2.0', () => { // timestamps landed in random order and an older-than-window reply sits below it, not in // view — ~50% flaky. A just-attempted send is the newest thing anyway. const failedMessage = makeReply({ - created_at: '2020-01-01T00:00:09.000Z', + created_at: convertDateToTimestamp('2020-01-01T00:00:09.000Z'), status: 'failed', }); thread.upsertReplyLocally({ message: failedMessage }); @@ -450,7 +454,7 @@ describe('Threads 2.0', () => { it('re-derives a paginatable reply cursor over a stale window (Thread.reload stays paginatable offline)', () => { const existingReply = generateMsg({ parent_id: parentMessageResponse.id, - created_at: '2020-01-01T00:00:00.000Z', + created_at: convertDateToTimestamp('2020-01-01T00:00:00.000Z'), }) as MessageResponse; // Head-anchored, older replies still to load (reply_count > loaded). const thread = createTestThread({ @@ -479,7 +483,7 @@ describe('Threads 2.0', () => { it('merges the incoming newest reply window into the reply paginator', () => { const existingReply = generateMsg({ parent_id: parentMessageResponse.id, - created_at: '2020-01-01T00:00:00.000Z', + created_at: convertDateToTimestamp('2020-01-01T00:00:00.000Z'), text: 'original', }) as MessageResponse; // Head-anchored, with older replies still to load (reply_count > loaded). @@ -494,12 +498,12 @@ describe('Threads 2.0', () => { const editedReply = generateMsg({ id: existingReply.id, parent_id: parentMessageResponse.id, - created_at: '2020-01-01T00:00:00.000Z', + created_at: convertDateToTimestamp('2020-01-01T00:00:00.000Z'), text: 'edited', }) as MessageResponse; const newReply = generateMsg({ parent_id: parentMessageResponse.id, - created_at: '2020-01-02T00:00:00.000Z', + created_at: convertDateToTimestamp('2020-01-02T00:00:00.000Z'), }) as MessageResponse; const hydrationThread = createTestThread({ latest_replies: [editedReply, newReply], @@ -643,7 +647,10 @@ describe('Threads 2.0', () => { // The sent reply — live-ingested, no page behind it. const mine = formatMessage( - makeReply({ id: 'mine', created_at: '2020-01-01T00:00:01.000Z' }), + makeReply({ + id: 'mine', + created_at: convertDateToTimestamp('2020-01-01T00:00:01.000Z'), + }), ); thread.messagePaginator.ingestItem(mine); expect(repliesOf(thread).map((r) => r.id)).to.eql(['mine']); @@ -651,16 +658,19 @@ describe('Threads 2.0', () => { // Two replies from someone else while offline; the server returns all three. const peer1 = makeReply({ id: 'peer1', - created_at: '2020-01-01T00:00:02.000Z', + created_at: convertDateToTimestamp('2020-01-01T00:00:02.000Z'), }); const peer2 = makeReply({ id: 'peer2', - created_at: '2020-01-01T00:00:03.000Z', + created_at: convertDateToTimestamp('2020-01-01T00:00:03.000Z'), }); sinon.stub(client, 'getThreadAndHydrate').resolves( createTestThread({ latest_replies: [ - makeReply({ id: 'mine', created_at: '2020-01-01T00:00:01.000Z' }), + makeReply({ + id: 'mine', + created_at: convertDateToTimestamp('2020-01-01T00:00:01.000Z'), + }), peer1, peer2, ], @@ -678,14 +688,23 @@ describe('Threads 2.0', () => { // out from under someone reading an older island. const thread = createMinimalThread(); const mine = formatMessage( - makeReply({ id: 'mine', created_at: '2020-01-01T00:00:05.000Z' }), + makeReply({ + id: 'mine', + created_at: convertDateToTimestamp('2020-01-01T00:00:05.000Z'), + }), ); thread.messagePaginator.ingestItem(mine); // Simulate a jump: an older, separately-anchored island that is the active window. const older = [ - makeReply({ id: 'old1', created_at: '2019-01-01T00:00:01.000Z' }), - makeReply({ id: 'old2', created_at: '2019-01-01T00:00:02.000Z' }), + makeReply({ + id: 'old1', + created_at: convertDateToTimestamp('2019-01-01T00:00:01.000Z'), + }), + makeReply({ + id: 'old2', + created_at: convertDateToTimestamp('2019-01-01T00:00:02.000Z'), + }), ].map((r) => formatMessage(r)); const jumped = thread.messagePaginator.ingestPage({ page: older, @@ -699,8 +718,14 @@ describe('Threads 2.0', () => { sinon.stub(client, 'getThreadAndHydrate').resolves( createTestThread({ latest_replies: [ - makeReply({ id: 'mine', created_at: '2020-01-01T00:00:05.000Z' }), - makeReply({ id: 'peer1', created_at: '2020-01-01T00:00:06.000Z' }), + makeReply({ + id: 'mine', + created_at: convertDateToTimestamp('2020-01-01T00:00:05.000Z'), + }), + makeReply({ + id: 'peer1', + created_at: convertDateToTimestamp('2020-01-01T00:00:06.000Z'), + }), ], reply_count: 2, }), @@ -751,7 +776,11 @@ describe('Threads 2.0', () => { // still null and the 404 is the only signal we get. `replyCount > 0` says we had something, // so this is a real failure to refresh, not a thread that never existed. const thread = createTestThread({ - latest_replies: [makeReply({ created_at: '2020-01-01T00:00:01.000Z' })], + latest_replies: [ + makeReply({ + created_at: convertDateToTimestamp('2020-01-01T00:00:01.000Z'), + }), + ], // `replyCount` is read off the PARENT message, not the thread response's own count. parentMessageOverrides: { reply_count: 4 }, }); @@ -800,10 +829,19 @@ describe('Threads 2.0', () => { it('removes a reply hard-deleted while offline and keeps one that arrived during the fetch', async () => { // End-to-end through the REAL reload orchestration (not a hand-built snapshot): this is what // proves the snapshot-before-await guarantee — the thing the paginator-level tests assume. - const r1 = makeReply({ id: 'r1', created_at: '2020-01-01T00:00:01.000Z' }); - const r2 = makeReply({ id: 'r2', created_at: '2020-01-01T00:00:02.000Z' }); + const r1 = makeReply({ + id: 'r1', + created_at: convertDateToTimestamp('2020-01-01T00:00:01.000Z'), + }); + const r2 = makeReply({ + id: 'r2', + created_at: convertDateToTimestamp('2020-01-01T00:00:02.000Z'), + }); // r3 is the newest loaded reply — hard-deleted by someone else while we were offline. - const r3 = makeReply({ id: 'r3', created_at: '2020-01-01T00:00:03.000Z' }); + const r3 = makeReply({ + id: 'r3', + created_at: convertDateToTimestamp('2020-01-01T00:00:03.000Z'), + }); const thread = createTestThread({ latest_replies: [r1, r2, r3], reply_count: 3, @@ -813,7 +851,10 @@ describe('Threads 2.0', () => { // A brand-new reply that lands via WS DURING the reload fetch — after reload() snapshots the // loaded ids, before hydrateState runs. Like the r3 ghost it is absent from the server page, // so a naive "loaded − serverPage" would wrongly drop it; the pre-fetch snapshot must save it. - const r4 = makeReply({ id: 'r4', created_at: '2020-01-01T00:00:04.000Z' }); + const r4 = makeReply({ + id: 'r4', + created_at: convertDateToTimestamp('2020-01-01T00:00:04.000Z'), + }); // The server's authoritative page (computed before r4 existed) has r3 hard-deleted, no r4. const hydrationThread = createTestThread({ @@ -840,14 +881,20 @@ describe('Threads 2.0', () => { // thread constructed directly and never registered (what the React Native SDK does via // `threadsById[id] ?? new Thread(...)`) it is always empty, and relying on it would drop the // user's unsent reply on every reconnect. - const r1 = makeReply({ id: 'r1', created_at: '2020-01-01T00:00:01.000Z' }); + const r1 = makeReply({ + id: 'r1', + created_at: convertDateToTimestamp('2020-01-01T00:00:01.000Z'), + }); const thread = createTestThread({ latest_replies: [r1], reply_count: 1 }); expect(thread.hasSubscriptions).to.equal(false); // A reply the user sent while offline, which failed. It only exists locally, and like any // just-attempted send it is the newest thing in the thread. const failed = formatMessage( - makeReply({ id: 'failed-1', created_at: '2021-06-01T00:00:09.000Z' }), + makeReply({ + id: 'failed-1', + created_at: convertDateToTimestamp('2021-06-01T00:00:09.000Z'), + }), ); failed.status = 'failed'; thread.messagePaginator.ingestItem(failed); @@ -855,8 +902,14 @@ describe('Threads 2.0', () => { // Server page is disjoint from the loaded window, which forces a rebuild — the one case the // reconcile's provenance guard does not cover. - const far1 = makeReply({ id: 'far1', created_at: '2021-06-01T00:00:01.000Z' }); - const far2 = makeReply({ id: 'far2', created_at: '2021-06-01T00:00:02.000Z' }); + const far1 = makeReply({ + id: 'far1', + created_at: convertDateToTimestamp('2021-06-01T00:00:01.000Z'), + }); + const far2 = makeReply({ + id: 'far2', + created_at: convertDateToTimestamp('2021-06-01T00:00:02.000Z'), + }); const hydrationThread = createTestThread({ latest_replies: [far1, far2], reply_count: 2, @@ -895,7 +948,7 @@ describe('Threads 2.0', () => { { length: 5 }, (_, i) => generateMsg({ - created_at: new Date(createdAt + 1000 * i), + created_at: convertDateToTimestamp(new Date(createdAt + 1000 * i)), }) as MessageResponse, ); const thread = createTestThread({ latest_replies: messages }); @@ -941,7 +994,7 @@ describe('Threads 2.0', () => { const thread = createTestThread({ read: [ { - last_read: new Date().toISOString(), + last_read: nowNs(), user: { id: TEST_USER_ID }, unread_messages: 42, }, @@ -960,7 +1013,7 @@ describe('Threads 2.0', () => { const thread = createTestThread({ read: [ { - last_read: new Date().toISOString(), + last_read: nowNs(), user: { id: TEST_USER_ID }, unread_messages: 42, }, @@ -981,11 +1034,15 @@ describe('Threads 2.0', () => { describe('reply pagination (messagePaginator)', () => { it('loads older replies via toTail() and scopes the request to the thread parent', async () => { // Seeded newest window with older replies still to load (reply_count > loaded). - const newest = makeReply({ created_at: '2020-01-03T00:00:00.000Z' }); + const newest = makeReply({ + created_at: convertDateToTimestamp('2020-01-03T00:00:00.000Z'), + }); const thread = createTestThread({ latest_replies: [newest], reply_count: 3 }); expect(thread.messagePaginator.state.getLatestValue().hasMoreTail).to.be.true; - const older = makeReply({ created_at: '2020-01-02T00:00:00.000Z' }); + const older = makeReply({ + created_at: convertDateToTimestamp('2020-01-02T00:00:00.000Z'), + }); const getRepliesStub = sinon .stub(thread.channel.getClient(), 'getReplies') .resolves({ messages: [older], duration: '' } as unknown as ReturnType< @@ -1002,11 +1059,15 @@ describe('Threads 2.0', () => { }); it('clears hasMoreTail once toTail() reaches the start of the reply list', async () => { - const newest = makeReply({ created_at: '2020-01-03T00:00:00.000Z' }); + const newest = makeReply({ + created_at: convertDateToTimestamp('2020-01-03T00:00:00.000Z'), + }); const thread = createTestThread({ latest_replies: [newest], reply_count: 2 }); expect(thread.messagePaginator.state.getLatestValue().hasMoreTail).to.be.true; - const older = makeReply({ created_at: '2020-01-02T00:00:00.000Z' }); + const older = makeReply({ + created_at: convertDateToTimestamp('2020-01-02T00:00:00.000Z'), + }); sinon .stub(thread.channel.getClient(), 'getReplies') .resolves({ messages: [older], duration: '' } as unknown as ReturnType< @@ -1027,7 +1088,7 @@ describe('Threads 2.0', () => { const thread = createTestThread({ read: [ { - last_read: new Date().toISOString(), + last_read: nowNs(), user: { id: TEST_USER_ID }, unread_messages: 42, }, @@ -1067,14 +1128,18 @@ describe('Threads 2.0', () => { }); it('reloads stale state when thread is active', async () => { - const initialReply = makeReply({ created_at: '2020-03-01T00:00:00.000Z' }); + const initialReply = makeReply({ + created_at: convertDateToTimestamp('2020-03-01T00:00:00.000Z'), + }); const thread = createTestThread({ latest_replies: [initialReply], reply_count: 1, }); thread.registerSubscriptions(); - const reloadedReply = makeReply({ created_at: '2020-03-01T00:00:01.000Z' }); + const reloadedReply = makeReply({ + created_at: convertDateToTimestamp('2020-03-01T00:00:01.000Z'), + }); const stubbedGetThread = sinon.stub(client, 'getThreadAndHydrate').resolves( createTestThread({ latest_replies: [initialReply, reloadedReply], @@ -1215,7 +1280,7 @@ describe('Threads 2.0', () => { const thread = createTestThread({ read: [ { - last_read: new Date().toISOString(), + last_read: nowNs(), user: { id: 'bob' }, unread_messages: 42, }, @@ -1244,7 +1309,7 @@ describe('Threads 2.0', () => { const thread = createTestThread({ read: [ { - last_read: lastReadAt.toISOString(), + last_read: dateToNs(lastReadAt), last_read_message_id: '', unread_messages: 42, user: { id: 'bob' }, @@ -1264,14 +1329,12 @@ describe('Threads 2.0', () => { channelResponse, generateMsg({ id: parentMessageResponse.id }), ) as ThreadStateResponse, - created_at: createdAt.toISOString(), + created_at: dateToNs(createdAt), }); const stateAfter = thread.state.getLatestValue(); expect(stateAfter.read['bob']?.unreadMessageCount).to.equal(0); - expect(stateAfter.read['bob']?.lastReadAt.toISOString()).to.equal( - createdAt.toISOString(), - ); + expect(stateAfter.read['bob']?.lastReadAt).to.equal(dateToNs(createdAt)); thread.unregisterSubscriptions(); }); @@ -1282,7 +1345,7 @@ describe('Threads 2.0', () => { const thread = createTestThread({ read: [ { - last_read: new Date().toISOString(), + last_read: nowNs(), user: { id: TEST_USER_ID }, unread_messages: 0, }, @@ -1294,7 +1357,7 @@ describe('Threads 2.0', () => { client.dispatchEvent({ type: 'notification.mark_unread', user: { id: TEST_USER_ID }, - created_at: new Date().toISOString(), + created_at: nowNs(), thread_id: uuidv4(), unread_messages: 7, }); @@ -1308,7 +1371,7 @@ describe('Threads 2.0', () => { const thread = createTestThread({ read: [ { - last_read: new Date().toISOString(), + last_read: nowNs(), user: { id: TEST_USER_ID }, unread_messages: 0, last_read_message_id: lastReadMessageId, @@ -1324,8 +1387,8 @@ describe('Threads 2.0', () => { client.dispatchEvent({ type: 'notification.mark_unread', user: { id: TEST_USER_ID }, - created_at: createdAt.toISOString(), - last_read_at: lastReadAt.toISOString(), + created_at: dateToNs(createdAt), + last_read_at: dateToNs(lastReadAt), thread_id: thread.id, first_unread_message_id: firstUnreadMessageId, unread_messages: 3, @@ -1336,8 +1399,8 @@ describe('Threads 2.0', () => { expect(stateAfter.read[TEST_USER_ID]?.firstUnreadMessageId).to.equal( firstUnreadMessageId, ); - expect(stateAfter.read[TEST_USER_ID]?.lastReadAt.toISOString()).to.equal( - lastReadAt.toISOString(), + expect(stateAfter.read[TEST_USER_ID]?.lastReadAt).to.equal( + dateToNs(lastReadAt), ); expect(stateAfter.read[TEST_USER_ID]?.lastReadMessageId).to.equal( lastReadMessageId, @@ -1354,7 +1417,7 @@ describe('Threads 2.0', () => { client.dispatchEvent({ type: 'notification.mark_unread', user: { id: otherUserId }, - created_at: createdAt.toISOString(), + created_at: dateToNs(createdAt), thread_id: thread.id, unread_messages: 4, }); @@ -1362,9 +1425,7 @@ describe('Threads 2.0', () => { const stateAfter = thread.state.getLatestValue(); expect(stateAfter.read[otherUserId]?.unreadMessageCount).to.equal(4); expect(stateAfter.read[otherUserId]?.user.id).to.equal(otherUserId); - expect(stateAfter.read[otherUserId]?.lastReadAt.toISOString()).to.equal( - createdAt.toISOString(), - ); + expect(stateAfter.read[otherUserId]?.lastReadAt).to.equal(dateToNs(createdAt)); }); }); @@ -1408,7 +1469,7 @@ describe('Threads 2.0', () => { const thread = createTestThread({ read: [ { - last_read: new Date().toISOString(), + last_read: nowNs(), user: { id: TEST_USER_ID }, unread_messages: 0, }, @@ -1434,7 +1495,7 @@ describe('Threads 2.0', () => { reply_count: 0, read: [ { - last_read: new Date().toISOString(), + last_read: nowNs(), user: { id: TEST_USER_ID }, unread_messages: 0, }, @@ -1483,7 +1544,7 @@ describe('Threads 2.0', () => { read: [ { user: { id: TEST_USER_ID }, - last_read: new Date().toISOString(), + last_read: nowNs(), unread_messages: 0, }, ], @@ -1515,7 +1576,7 @@ describe('Threads 2.0', () => { read: [ { user: { id: TEST_USER_ID }, - last_read: new Date().toISOString(), + last_read: nowNs(), unread_messages: 0, }, ], @@ -1542,7 +1603,7 @@ describe('Threads 2.0', () => { const thread = createTestThread({ read: [ { - last_read: new Date().toISOString(), + last_read: nowNs(), user: { id: TEST_USER_ID }, unread_messages: 42, }, @@ -1570,7 +1631,7 @@ describe('Threads 2.0', () => { const thread = createTestThread({ read: [ { - last_read: new Date().toISOString(), + last_read: nowNs(), user: { id: TEST_USER_ID }, unread_messages: 0, }, @@ -1602,7 +1663,7 @@ describe('Threads 2.0', () => { (_, i) => generateMsg({ parent_id: parentMessageResponse.id, - created_at: new Date(createdAt + 1000 * i), + created_at: convertDateToTimestamp(new Date(createdAt + 1000 * i)), }) as MessageResponse, ); const thread = createTestThread({ latest_replies: messages }); @@ -1628,7 +1689,7 @@ describe('Threads 2.0', () => { const createdAt = new Date().getTime(); // five messages "created" second apart const messages = Array.from({ length: 5 }, (_, i) => - makeReply({ created_at: new Date(createdAt + 1000 * i).toISOString() }), + makeReply({ created_at: msToNs(createdAt + 1000 * i) }), ); const thread = createTestThread({ latest_replies: messages, reply_count: 5 }); thread.registerSubscriptions(); @@ -1667,7 +1728,7 @@ describe('Threads 2.0', () => { const parentMessage = generateMsg({ id: thread.id, - deleted_at: new Date(), + deleted_at: convertDateToTimestamp(new Date()), type: 'deleted', }) as MessageResponse; @@ -1678,14 +1739,10 @@ describe('Threads 2.0', () => { const stateAfter = thread.state.getLatestValue(); - expect(stateAfter.deletedAt).to.be.a('date'); - expect(stateAfter.deletedAt!.toISOString()).to.equal( - parentMessage.deleted_at!.toISOString(), - ); - expect(stateAfter.parentMessage.deleted_at).to.be.a('date'); - expect(stateAfter.parentMessage.deleted_at!.toISOString()).to.equal( - parentMessage.deleted_at!.toISOString(), - ); + expect(stateAfter.deletedAt).to.be.a('number'); + expect(stateAfter.deletedAt).to.equal(parentMessage.deleted_at); + expect(stateAfter.parentMessage.deleted_at).to.be.a('number'); + expect(stateAfter.parentMessage.deleted_at).to.equal(parentMessage.deleted_at); }); it('reflects quoted_message updates in messagePaginator cache', () => { @@ -1714,7 +1771,7 @@ describe('Threads 2.0', () => { message: { ...quotedMessage, type: 'deleted', - deleted_at: new Date().toISOString(), + deleted_at: convertDateToTimestamp(new Date().toISOString()), }, }); @@ -1778,7 +1835,7 @@ describe('Threads 2.0', () => { type: 'like', user_id: 'other-user', message_id: messageId, - created_at: new Date().toISOString(), + created_at: nowNs(), }, }); @@ -1814,8 +1871,11 @@ describe('Threads 2.0', () => { client.dispatchEvent({ type: eventType, - user: { id: bannedUserId, deleted_at: new Date().toISOString() }, - created_at: new Date().toISOString(), + user: { + id: bannedUserId, + deleted_at: convertDateToTimestamp(new Date().toISOString()), + }, + created_at: nowNs(), }); expect(thread.messagePaginator.getItem(replyId)?.type).to.equal('deleted'); @@ -1839,7 +1899,7 @@ describe('Threads 2.0', () => { type: 'love', user_id: TEST_USER_ID, message_id: message.id, - created_at: new Date().toISOString(), + created_at: nowNs(), }, }); @@ -1863,7 +1923,7 @@ describe('Threads 2.0', () => { type: 'love', user_id: TEST_USER_ID, message_id: message.id, - created_at: new Date().toISOString(), + created_at: nowNs(), }, }); @@ -1888,7 +1948,7 @@ describe('Threads 2.0', () => { type: 'love', user_id: TEST_USER_ID, message_id: message.id, - created_at: new Date().toISOString(), + created_at: nowNs(), }, }); @@ -1912,7 +1972,7 @@ describe('Threads 2.0', () => { type: 'love', user_id: TEST_USER_ID, message_id: message.id, - created_at: new Date().toISOString(), + created_at: nowNs(), }, }); diff --git a/test/unit/utils.test.ts b/test/unit/utils.test.ts index d4e58a9ce1..324ddb9c9d 100644 --- a/test/unit/utils.test.ts +++ b/test/unit/utils.test.ts @@ -3,6 +3,7 @@ import { describe, beforeEach, afterEach, it, expect, vi } from 'vitest'; import { generateChannel } from './test-utils/generateChannel'; import { generateMember } from './test-utils/generateMember'; +import { generateMsg } from './test-utils/generateMessage'; import { generateUser } from './test-utils/generateUser'; import { getClientWithUser } from './test-utils/getClient'; @@ -13,11 +14,14 @@ import { userHasReadReceipts, formatMessage, generateChannelTempCid, + localMessageToNewMessagePayload, + toUpdatedMessagePayload, uniqBy, runDetached, sleep, computeOwnReactions, } from '../../src/utils'; +import { nsToMs } from '../../src/utils/time'; import type { ChannelFilters, @@ -640,3 +644,165 @@ describe('userHasReadReceipts', () => { expect(userHasReadReceipts(makeClient(undefined))).toBe(true); }); }); + +describe('request-payload date direction', () => { + // Both payload builders convert wire timestamps into the `Date` objects `MessageRequest` + // declares. A real on-device magnitude, so a skipped conversion shows up as an `Invalid Date`. + const NANOS = 1786219962651957000; + const DATE_KEYS = [ + 'created_at', + 'updated_at', + 'deleted_at', + 'pinned_at', + 'pin_expires', + ]; + + describe('localMessageToNewMessagePayload', () => { + it('sends every direction-crossing date field, and drops the server-managed ones', () => { + const payload = localMessageToNewMessagePayload( + formatMessage( + generateMsg({ + pinned_at: NANOS, + pin_expires: NANOS + 1e9, + message_text_updated_at: NANOS, + }), + ), + ); + + expect(payload.pinned_at).toBeInstanceOf(Date); + expect(payload.pin_expires).toBeInstanceOf(Date); + expect(payload.pinned_at?.getTime()).toBe(nsToMs(NANOS)); + expect(payload.pin_expires?.getTime()).toBe(nsToMs(NANOS + 1e9)); + + expect(payload).not.toHaveProperty('message_text_updated_at'); + + // No date field may still be a wire number, or a string wearing a `Date` annotation. + for (const key of DATE_KEYS) { + const value = (payload as Record)[key]; + expect(typeof value).not.toBe('number'); + expect(typeof value).not.toBe('string'); + } + }); + + it('serializes to RFC3339, which is what actually reaches the API', () => { + const payload = localMessageToNewMessagePayload( + formatMessage(generateMsg({ pinned_at: NANOS })), + ); + + expect(JSON.parse(JSON.stringify({ pinned_at: payload.pinned_at }))).toEqual({ + pinned_at: new Date(nsToMs(NANOS)).toISOString(), + }); + }); + + it('drops the sub-millisecond part, which a `Date` cannot carry', () => { + const withSubMs = NANOS + 123000; + const payload = localMessageToNewMessagePayload( + formatMessage(generateMsg({ pinned_at: withSubMs })), + ); + + expect(payload.pinned_at).toBeInstanceOf(Date); + expect(payload.pinned_at?.getTime()).toBe(nsToMs(withSubMs)); + expect(payload.pinned_at?.toISOString()).toMatch( + /^\d{4}-\d{2}-\d{2}T[\d:]{8}\.\d{3}Z$/, + ); + }); + + it('narrows shared_location to the request shape and converts end_at', () => { + const payload = localMessageToNewMessagePayload( + formatMessage( + generateMsg({ + shared_location: { + latitude: 1, + longitude: 2, + created_by_device_id: 'device', + end_at: NANOS, + created_at: NANOS, + updated_at: NANOS, + channel_cid: 'messaging:x', + user_id: 'u', + }, + }), + ), + ); + + expect(payload.shared_location?.end_at).toBeInstanceOf(Date); + expect(payload.shared_location?.end_at?.getTime()).toBe(nsToMs(NANOS)); + expect(payload.shared_location).not.toHaveProperty('created_at'); + expect(payload.shared_location).not.toHaveProperty('updated_at'); + expect(payload.shared_location).not.toHaveProperty('channel_cid'); + expect(payload.shared_location).not.toHaveProperty('user_id'); + }); + + it('omits the pin fields entirely when the message is not pinned', () => { + const payload = localMessageToNewMessagePayload(formatMessage(generateMsg())); + + expect(payload).not.toHaveProperty('pinned_at'); + expect(payload).not.toHaveProperty('pin_expires'); + }); + + it('converts an epoch timestamp rather than treating it as absent', () => { + const payload = localMessageToNewMessagePayload( + formatMessage(generateMsg({ pinned_at: 0 })), + ); + + expect(payload.pinned_at).toBeInstanceOf(Date); + expect(payload.pinned_at?.getTime()).toBe(0); + }); + }); + + describe('toUpdatedMessagePayload', () => { + it('strips only the fields that are not request fields at all', () => { + const payload = toUpdatedMessagePayload( + generateMsg({ pinned_at: NANOS, message_text_updated_at: NANOS }), + ); + + for (const key of [ + 'created_at', + 'updated_at', + 'deleted_at', + 'message_text_updated_at', + ]) { + expect(payload).not.toHaveProperty(key); + } + expect(payload.pinned_at).toBeInstanceOf(Date); + expect(payload.pinned_at?.getTime()).toBe(nsToMs(NANOS)); + }); + + it('SENDS pin_expires rather than stripping it — stripping clears the expiry', () => { + // Omitting it clears the expiry server-side. + const payload = toUpdatedMessagePayload( + generateMsg({ pinned_at: NANOS, pin_expires: NANOS + 3600e9 }), + ); + + expect(payload.pin_expires).toBeInstanceOf(Date); + expect(payload.pin_expires?.getTime()).toBe(nsToMs(NANOS + 3600e9)); + }); + + it('SENDS shared_location, narrowed to the request shape', () => { + const payload = toUpdatedMessagePayload( + generateMsg({ + shared_location: { + latitude: 1, + longitude: 2, + end_at: NANOS, + created_at: NANOS, + updated_at: NANOS, + channel_cid: 'messaging:x', + user_id: 'u', + }, + }), + ); + + expect(payload.shared_location?.end_at).toBeInstanceOf(Date); + expect(payload.shared_location?.end_at?.getTime()).toBe(nsToMs(NANOS)); + expect(payload.shared_location).not.toHaveProperty('created_at'); + }); + + it('reads pinned-ness nullishly, so an epoch pin still counts as pinned', () => { + expect(toUpdatedMessagePayload(generateMsg({ pinned_at: 0 })).pinned).toBe(true); + expect(toUpdatedMessagePayload(generateMsg({ pinned_at: undefined })).pinned).toBe( + false, + ); + }); + }); +}); diff --git a/test/unit/utils/time.test.ts b/test/unit/utils/time.test.ts new file mode 100644 index 0000000000..017541e7b4 --- /dev/null +++ b/test/unit/utils/time.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; + +import { msToNs, nsToDate, nsToMs, nsToRfc3339 } from '../../../src/utils/time'; + +describe('nsToRfc3339', () => { + /** A real on-device value, whose sub-millisecond remainder is non-zero. */ + const NANOS = 1786219962651957000; + + it('emits nine fractional digits', () => { + expect(nsToRfc3339(NANOS)).toMatch(/^\d{4}-\d{2}-\d{2}T[\d:]{8}\.\d{9}Z$/); + }); + + it('keeps the sub-millisecond part that nsToDate discards', () => { + const emitted = nsToRfc3339(NANOS); + const viaDate = nsToDate(NANOS).toISOString(); + + expect(emitted.slice(0, 23)).toBe(viaDate.slice(0, 23)); + expect(emitted.slice(23, 29)).toBe( + String(NANOS - nsToMs(NANOS) * 1e6).padStart(6, '0'), + ); + expect(emitted).not.toBe(viaDate); + }); + + it('round-trips a whole-millisecond value identically to nsToDate', () => { + const exactMs = msToNs(Date.parse('2026-01-02T03:04:05.678Z')); + + expect(nsToRfc3339(exactMs)).toBe('2026-01-02T03:04:05.678000000Z'); + expect(nsToRfc3339(exactMs).slice(0, 23)).toBe( + nsToDate(exactMs).toISOString().slice(0, 23), + ); + }); + + it('pads a small remainder rather than truncating it', () => { + const oneNsPast = msToNs(Date.parse('2026-01-02T03:04:05.678Z')) + 1; + + // The double cannot hold a 1 ns step here, so assert the shape, not an exact digit. + expect(nsToRfc3339(oneNsPast)).toMatch(/\.678\d{6}Z$/); + }); + + it('handles the epoch', () => { + expect(nsToRfc3339(0)).toBe('1970-01-01T00:00:00.000000000Z'); + }); +}); diff --git a/v9-to-v10-migration-guide-dates.md b/v9-to-v10-migration-guide-dates.md new file mode 100644 index 0000000000..69adc9a517 --- /dev/null +++ b/v9-to-v10-migration-guide-dates.md @@ -0,0 +1,447 @@ +# v9 → v10 Migration Guide — Dates Are Unix-Nanosecond Numbers + +> Scope: this guide covers the one change that touches **every** response and event type in the +> package — server-sent dates are now the unix-**nanosecond** `number` the API puts on the wire, +> rather than `Date` objects. It also covers the SDK state types that carry those values, the request +> fields that did **not** change, and the ways this breaks without a compile error. +> +> Sibling guides: +> +> - `v9-to-v10-migration-guide-client-construction.md` (constructor & options) +> - `v9-to-v10-migration-guide-logging.md` (`chatLoggerSystem`, sinks, scopes) +> - `v9-to-v10-migration-guide-methods.md` (per-method signatures) +> - `v9-to-v10-migration-guide-sort.md` (`SortParamRequest[]` shape) +> - `v9-to-v10-migration-guide-server-side.md` (server-side surface removal) +> - `v9-to-v10-migration-guide-type-renames.md` (hand-rolled type aliases → generated names) +> - `v9-to-v10-migration-guide-i18n.md` (notification identity, the `stream-chat/i18n` subpath) +> - `v9-to-v10-migration-guide-other.md` (everything else) + +## TL;DR + +- **Every server-sent date is a unix-nanosecond `number`.** `created_at`, `updated_at`, `deleted_at`, + `last_read`, `last_active`, `last_message_at`, `remind_at`, `pinned_at`, `end_at`, `archived_at`, + `expires` — on every response type, every `WSEvent` member, and the SDK state stores that mirror + them. Not a `Date`, not an ISO string. In v9 these were typed `string`; in the early v10 RCs they + were decoded to `Date`. Both are gone: there is no decoder layer any more. +- **Outgoing request date fields are still `Date`.** So a response value can no longer be assigned to + a request field — the one thing that _was_ safe in v9, when both sides were `string`. The compiler + catches this. +- **Three things break with no compile error**: every `Date`-based path is out of range, so + `new Date(ns)` and `dayjs(ns)` are both an `Invalid Date`; a millisecond/nanosecond mix-up between + two `number`s produces a plausible wrong answer and no complaint; and `0` is now a legitimate + timestamp, so `if (!created_at)` is wrong. +- **`t('timestamp.X', { timestamp })` is not type-checked** — i18next's interpolation bag is untyped, + so a raw nanosecond number reaches the formatter and renders the literal text `Invalid Date` into + your UI. See + [Rendering timestamps](#rendering-timestamps-the-one-path-the-compiler-does-not-guard). +- **Filter operands changed meaning**: a bare `number` in a filter is now read as nanoseconds, not + milliseconds. +- Convert with the helpers the package now exports: `convertTimestampToDate` (guarded), `nsToDate`, + `nsToMs`, `msToNs`, `nowNs`, `dateToNs`, `nsToRfc3339`, `NS_PER_MS`. + +--- + +## Why the numbers, and what they are + +The API has always sent timestamps as nanoseconds since the unix epoch. v9 typed them `string`; the +first v10 RCs generated a per-model decoder layer that turned them into `Date` objects on the way in. +The client is now generated with `--opt response_dates_as_number`, which types them as what the wire +actually carries, and `src/gen/model-decoders/` is no longer emitted at all. Frames arrive and are +used as-is. + +A current timestamp looks like `1786219962651957000`. + +### The two failure modes + +**Every `Date`-based path is out of range.** `Date` tops out at ±8.64e15 ms (ECMA-262 `TimeClip`, +about ±273,790 years). A nanosecond timestamp is ~1.79e18, so it does not fit — and a date library +reads a bare number as milliseconds, which lands in exactly the same place rather than somewhere +plausible. + +```ts +new Date(message.created_at); // Invalid Date +new Date(message.created_at).toISOString(); // RangeError: Invalid time value +dayjs(message.created_at).isValid(); // false +dayjs(message.created_at).format(); // 'Invalid Date' — the literal string +``` + +Neither is a type error, and the two surface differently: `.toISOString()` **throws**, usually +mid-render in a component that had no reason to expect it, while dayjs's `.format()` quietly returns +the string `'Invalid Date'` and renders it on screen. So this mistake is loud in a `RangeError` stack +trace and near-silent in a formatted timestamp — do not rely on noticing it either way. + +**A unit mix-up between two `number`s is the genuinely silent one.** Because nanoseconds are out of +`Date`'s range, that mistake at least announces itself. Comparing a wire timestamp against +`Date.now()`, adding a millisecond duration to one, or passing epoch milliseconds where nanoseconds +are expected all produce a plausible-looking number and no complaint at all: + +```ts +// Compiles, runs, and is wrong by a factor of a million. +if (mute.expires > Date.now()) { … } // every expiry looks ~56 million years away +``` + +The rest of this guide is organised around that: the conversions are mechanical, and the places +worth auditing are the ones where two numbers meet. + +### Precision + +Nanosecond epoch values exceed `Number.MAX_SAFE_INTEGER` (~9.01e15), so a `double` cannot hold every +one of them: at this magnitude the representable values are 256 ns apart. `JSON.parse` has already +quantised the value before your code sees it. + +What this means in practice: + +- **Ordering and comparison are unaffected.** Two distinct instants more than 256 ns apart compare + correctly, which is every instant a chat application distinguishes. +- **Exact equality against a value that has round-tripped through JSON is not guaranteed.** Do not + build an equality filter on a nanosecond timestamp you sent and read back. +- **`nsToMs` floors.** `nsToMs(msToNs(ms))` can land one millisecond early, because `msToNs` may + round down by up to 128 ns and `Math.floor` then crosses the millisecond boundary. Milliseconds are + the unit of display and of `setTimeout`, so this is cosmetic — but do not treat the ms↔ns round trip + as an identity. + +--- + +## The helpers + +All exported from the package root. + +| Helper | Signature | Use for | +| ----------------------------- | ------------------------------------------------- | ------------------------------------------------------------------------ | +| `convertTimestampToDate(ts?)` | `number \| null \| undefined → Date \| undefined` | The default. Guarded: `undefined` for an absent or non-finite value. | +| `nsToDate(ns)` | `number → Date` | A value known to be present. | +| `nsToMs(ns)` | `number → number` | Epoch milliseconds, for arithmetic against `Date.now()`. | +| `msToNs(ms)` | `number → number` | Milliseconds back into the wire unit. | +| `nowNs()` | `() → number` | The local clock as a wire timestamp, for optimistic writes. | +| `dateToNs(date)` | `Date → number` | A `Date` into the wire unit. | +| `nsToRfc3339(ns)` | `number → string` | A nanosecond-precision RFC3339 string, when milliseconds are not enough. | +| `NS_PER_MS` | `1e6` | The conversion constant, if you need it directly. | + +**Prefer `convertTimestampToDate` at the boundary where a wire number becomes something a date +library or a UI prop consumes.** Many timestamps are optional in practice even where the generated +type marks them required, and the guard is the whole point: an absent or `NaN` value returns +`undefined` instead of producing an `Invalid Date` that throws further along. + +```ts +import { convertTimestampToDate } from 'stream-chat'; + +const createdAt = convertTimestampToDate(message.created_at); +if (!createdAt) return null; // nothing renderable +``` + +Do not cast the `undefined` away, and do not paper over it with `new Date()` — that labels a +months-old message "Today". + +```ts +// WRONG — launders `undefined` into a required `Date`. +convertTimestampToDate(message.created_at) as Date; +// WRONG — invents "now". +convertTimestampToDate(message.created_at) ?? new Date(); +``` + +### Comparison and arithmetic + +Compare and sort the raw numbers. Do not round-trip through `Date`. + +```ts +// v9 / early v10 RC +if (new Date(a.created_at).getTime() < new Date(b.created_at).getTime()) { … } +messages.sort((a, b) => a.created_at.getTime() - b.created_at.getTime()); + +// v10 +if (a.created_at < b.created_at) { … } +messages.sort((a, b) => a.created_at - b.created_at); +``` + +For a **duration**, subtract in nanoseconds and convert **once**. Durations, intervals and delays stay +in milliseconds throughout the SDK, because that is what `setTimeout` and every "time left" value +speak. Converting each operand separately is how a rounding error becomes a visible one. + +```ts +// Time since a message, in seconds. +const secondsAgo = nsToMs(nowNs() - message.created_at) / 1000; + +// Delay until a live location expires. +setTimeout(stop, Math.max(0, nsToMs(location.end_at - nowNs()))); +``` + +The mirror-image mistake is comparing a wire timestamp against `Date.now()` directly. Both are +numbers, so nothing warns, and every wire timestamp looks about 56 million years in the future. + +```ts +// WRONG +if (mute.expires > Date.now()) { … } +// RIGHT +if (mute.expires > nowNs()) { … } +``` + +--- + +## `0` is a valid timestamp + +`0` is the unix epoch, and `!0` is `true`. Every truthiness check on a date field is now a latent +bug — where it used to be harmless, because a `Date` object is always truthy and an ISO string is +never empty. + +```ts +// WRONG — an epoch timestamp reads as "no timestamp". +if (!message.pinned_at) return; +const at = reminder.remind_at ? new Date(reminder.remind_at) : null; +const last = paginator.lastMessageAt || nowNs(); + +// RIGHT +if (message.pinned_at == null) return; +const at = reminder.remind_at != null ? nsToDate(reminder.remind_at) : null; +const last = paginator.lastMessageAt ?? nowNs(); +``` + +**This is not hypothetical for read state.** `0` is the sentinel this SDK actively writes for "never +read" — `channel.state.read[userId].last_read` is set to `0` when a channel gains its first unread +message for a user with no prior read state, and the offline-DB layer persists `0` for the same case. +So a truthiness check on `last_read` conflates "this user has never read the channel" — precisely +when an unread indicator matters most — with "there is no read state". Use `!= null`. + +The same applies to `channel.countUnread(lastRead?)`: it now distinguishes `null`/`undefined` (fall +back to the stored unread count) from `0` (count everything after the epoch). Passing `0` where you +previously passed `new Date(0)` keeps the old meaning. + +--- + +## Request fields are still `Date` + +Outgoing date fields are unchanged: `JSON.stringify` emits RFC3339 for a `Date`, and that is the +format the request spec declares. The consequence is that **a response value can no longer be handed +to a request field** — in v9 both sides were `string`, so this was safe and common. + +| Request field | Type | +| ------------------------------------------------------------------------------------------------------------- | ------ | +| `MessagePaginationParams.created_at_around` / `_before` / `_before_or_equal` / `_after` / `_after_or_equal` | `Date` | +| `MessageRequest.pinned_at`, `MessageRequest.pin_expires` | `Date` | +| `SharedLocation.end_at`, `UpdateLiveLocationRequest.end_at` | `Date` | +| `CreateReminderRequest.remind_at`, `UpdateReminderRequest.remind_at` | `Date` | +| `MarkUnreadRequest.message_timestamp` | `Date` | +| `SyncRequest.last_sync_at`, `TruncateChannelRequest.truncated_at`, `UpdateChannelRequest.hide_history_before` | `Date` | +| `ReactionRequest.created_at` / `updated_at`, `PushPreferenceInput.disabled_until` | `Date` | + +The compiler catches the mismatch. Convert with `nsToDate` on the way out: + +```ts +// v9 — both sides were strings, so this worked. +await channel.query({ messages: { created_at_around: message.created_at } }); + +// v10 — `created_at` is a number, `created_at_around` is a `Date`. +await channel.query({ messages: { created_at_around: nsToDate(message.created_at) } }); +``` + +`nsToDate` is millisecond-precision, which is all a `Date` can hold. If you need the nanosecond +remainder preserved on an outgoing field, `nsToRfc3339` produces a nine-fractional-digit RFC3339 +string — but note it does not satisfy a `Date`-typed field without a cast, and casting a string into a +`Date` annotation breaks any caller that inspects the payload rather than just serializing it. + +### One field the spec still types as a string + +`MessageDeliveredEvent.last_delivered_at` is declared as a bare `type: string` with no +`format: date-time`, so it arrives as RFC3339 while `created_at` on the very same event arrives as a +number. The SDK normalises it internally (`src/channel.ts`); if you read the field off the event +yourself, parse it rather than treating it as a wire number. This is an upstream spec bug and the +field is expected to become a number. + +### `pinMessage`'s `number` overload now collides with the response type + +```ts +client.pinMessage(messageOrId, timeoutOrExpirationDate?, pinnedAt?, requestOptions?); +``` + +For both date arguments a `number` means **relative seconds**, not a timestamp — unchanged from v9, +but `message.pinned_at` is now also a `number`, so the wrong thing type-checks: + +```ts +// WRONG — reads the timestamp as "1.79e18 seconds from now". +client.pinMessage(id, null, message.pinned_at); +// RIGHT +client.pinMessage(id, null, nsToDate(message.pinned_at)); +``` + +### Filter operands: a server query needs a `Date`, client-side matching reads a number as nanoseconds + +These two paths behave differently, and only one of them fails loudly. + +**A server-side filter must carry a `Date` (or an RFC3339 string).** The API type-checks the operand +and rejects a number outright — verified against the live API: + +```ts +await client.queryReminders({ filter: { remind_at: { $lte: new Date() } } }); +// -> works + +await client.queryReminders({ filter: { remind_at: { $lte: Date.now() * 1e6 } } }); +// -> QueryReminders failed with error: "field \"remind_at\" expects type date" +``` + +So a wire timestamp read off a response cannot be fed straight back into a filter. Convert it: +`nsToDate(message.created_at)`. + +**Client-side filtering and sorting is the silent one.** The paginators compile filters and +comparators that run against loaded items, and there a bare `number` operand is taken to **already +be** the wire unit. Epoch milliseconds — which worked in v9 — resolve to 1970 with no error: + +```ts +// Silently wrong for client-side matching: epoch ms read as nanoseconds. +{ + created_at: { + $gt: 1700000000000; + } +} +// Right: a Date or an ISO string is converted for you; a number must already be nanoseconds. +{ + created_at: { + $gt: new Date('2023-11-14T12:39:29Z'); + } +} +{ + created_at: { + $gt: msToNs(1700000000000); + } +} +``` + +--- + +## Rendering timestamps: the one path the compiler does not guard + +`stream-chat/i18n` exposes two ways to render a timestamp, and only one of them is type-safe. + +**`getDateString({ messageCreatedAt })` is typed `string | Date`.** A raw number is a compile error, +so this path guides you to the conversion: + +```ts +import { getDateString } from 'stream-chat/i18n'; +import { convertTimestampToDate } from 'stream-chat'; + +getDateString({ + messageCreatedAt: convertTimestampToDate(message.created_at), + t, + tDateTimeParser, + timestampTranslationKey: 'timestamp.MessageTimestamp', +}); +``` + +**`t('timestamp.X', { timestamp })` is not.** The `timestamp.*` translation keys carry a +`timestampFormatter` expression, and the value reaches it through i18next's interpolation options — +which are untyped. `timestampFormatter` itself declares `FormatterFactory`, but nothing +enforces that at the call site, so this compiles cleanly: + +```ts +// COMPILES. Renders the literal text 'Invalid Date' into the UI. +t('timestamp.LiveLocation', { timestamp: location.end_at }); +``` + +There is no error, no warning, and no `Invalid Date` to notice in review — just a wrong year in the +UI. **Convert at every `t('timestamp.*', …)` and `t('duration.*', …)` call site**, and treat these as +the places to audit first when migrating: + +```ts +t('timestamp.LiveLocation', { timestamp: convertTimestampToDate(location.end_at) }); +``` + +The formatter tolerates `undefined` — it renders an empty string rather than the literal text +`"undefined"` — so the guarded helper can be passed straight through. + +Two related notes: + +- **`duration.*` keys take a duration, not a timestamp.** `durationFormatter` goes through the date + library's `.duration()`, so it expects a length of time in **milliseconds**. Handing it a timestamp + renders something like "57 years ago". If you are deriving a duration from two wire timestamps, + subtract first and convert once (see [Comparison and arithmetic](#comparison-and-arithmetic)). +- **Presentational props still take `Date`.** The conversion boundary is where core data enters your + component tree, not the leaf that formats it. Components whose job is to render a date keep their + `Date` props. + +--- + +## Changed public types and members in this package + +Type changes where the member name is unchanged and only the type moved from `Date` to `number`: + +| Type / member | Was | Now | +| ----------------------------------------------------------- | ------------------- | --------------------- | +| `ChannelMuteStatus.createdAt` / `.expiresAt` | `Date \| null` | `number \| null` | +| `ChannelState['read'][userId].last_read` | `Date` | `number` | +| `ChannelState['read'][userId].last_delivered_at` | `Date \| undefined` | `number \| undefined` | +| `ThreadState.createdAt` | `Date` | `number` | +| `ThreadState.deletedAt` / `.updatedAt` | `Date \| null` | `number \| null` | +| `ThreadUserReadState.lastReadAt` | `Date` | `number` | +| `ReminderState.created_at` / `.updated_at` | `Date` | `number` | +| `ReminderState.remind_at` | `Date \| null` | `number \| null` | +| `UnreadSnapshotState.lastReadAt` | `Date \| null` | `number \| null` | +| `MessagePaginatorAggregateState.seededLastMessageAt` | `Date \| null` | `number \| null` | +| `MessagePaginator.lastMessageAt` (getter) | `Date \| null` | `number \| null` | +| `LocalEvent` `created_at`, and `received_at` on every event | `Date` | `number` | +| `ConnectedEvent.created_at` / `.received_at` | `Date` | `number` | +| `DBDeleteMessagesForChannelType.truncated_at` | `Date \| undefined` | `number \| undefined` | + +Signature changes: + +| Member | Was | Now | +| ---------------------------------------------------------------- | ------------------------------------- | ---------------------------------- | +| `channel.countUnread(lastRead?)` | `Date \| null` | `number \| null` | +| `channel.lastRead()` | `Date \| null \| undefined` | `number \| null \| undefined` | +| `channel.muteStatus()` | `{ createdAt: Date \| null; … }` | `{ createdAt: number \| null; … }` | +| `MessagePaginator.seedLastMessageAt(value)` | `string \| Date \| null \| undefined` | `number \| null \| undefined` | +| `MessagePaginator.truncate({ truncatedAt })` | `Date` | `number` | +| `MessagePaginator.applyMessageDeletionForUser({ deletedAt })` | `Date` | `number` | +| `MessagePaginator.findItemByTimestamp(timestamp, exactTsMatch?)` | epoch **ms** | wire **ns** | +| `MessageReceiptsTracker.onMessageDelivered({ deliveredAt })` | `Date` | `number` | +| `MessageReceiptsTracker.onMessageRead({ readAt })` | `Date` | `number` | +| `MessageReceiptsTracker.reconcileUserRead({ lastReadAt })` | `Date \| undefined` | `number \| undefined` | +| `timeLeftMs(remindAt)` | epoch **ms** | wire **ns** | +| `LocationComposer.validLocation` (getter) | `SharedLocation \| null` | `StaticLocationPreview \| null` | + +Renames — these do **not** fail as a type error if you were reading them off a value typed `any`: + +| Was | Now | +| ------------------------------------------------ | ------------------------------------------------ | +| `CooldownTimerState.ownLatestMessageDate` | `CooldownTimerState.ownLatestMessageTimestamp` | +| `cooldownTimer.ownLatestMessageDate` | `cooldownTimer.ownLatestMessageTimestamp` | +| `cooldownTimer.setOwnLatestMessageDate(date)` | `cooldownTimer.setOwnLatestMessageTimestamp(ns)` | +| `MsgRef.timestampMs` | `MsgRef.timestamp` | +| `OwnMessageReceiptsTrackerMessageLocator(msgMs)` | `OwnMessageReceiptsTrackerMessageLocator(ns)` | + +**Unit changes with no type change** — the compiler cannot help with these at all: + +| Member | Was | Now | +| ------------------------------------------------- | ------------ | ----------- | +| `PollState.lastActivityAt` | `Date` | wire **ns** | +| `LastComposerChange.stateUpdate` / `.draftUpdate` | epoch **ms** | wire **ns** | + +Removed: + +- `isDate` is gone from `src/utils`. It was never exported from the package root. The `isDate` in + `stream-chat/i18n` is unrelated and still there — but note it correctly reports that a wire number + is not a `Date`, so `timestamp && isDate(timestamp) ? … : undefined` now yields `undefined` for + every timestamp. Convert instead of guarding. +- `RESERVED_UPDATED_MESSAGE_FIELDS` no longer lists `pinned_at` and now lists + `message_text_updated_at`. `pinned_at`, `pin_expires` and `shared_location` **are** `MessageRequest` + fields, so stripping them from an update payload clears them server-side; they are converted and + sent instead. + +--- + +## Persisted state + +If you persist SDK values yourself — an offline database, a cache, a hydrated store — timestamps +written by a previous version are ISO strings or serialized `Date`s, and nothing in v10 coerces them +any more. In particular **`formatMessage` is no longer a normalisation boundary**: it used to turn +whatever it received into `Date` objects, and now passes a value straight through. + +Version your storage and discard or migrate what was written before the upgrade. `stream-chat-react-native`'s +offline database does this by bumping its schema version, which drops and recreates every table (note +that this also discards queued offline pending tasks). + +Storage-level notes if you maintain your own: + +- Store timestamps as a 64-bit **integer** column, not text. An integral `double` below 2^63 + round-trips through SQLite `INTEGER` exactly, and lexicographic ISO sorting is no longer needed. +- `ORDER BY` and range comparisons become plain numeric ones — drop any `datetime(…)` / + `strftime(…)` wrapping, which silently returns `NULL` for an integer column. +- Distinguish absent from epoch. Write `NULL` for an absent timestamp rather than `0` or `''`, or you + reintroduce exactly the ambiguity the `!= null` discipline above exists to remove. diff --git a/v9-to-v10-migration-guide-i18n.md b/v9-to-v10-migration-guide-i18n.md index 48733e0518..97d8c9090f 100644 --- a/v9-to-v10-migration-guide-i18n.md +++ b/v9-to-v10-migration-guide-i18n.md @@ -10,6 +10,7 @@ > - `v9-to-v10-migration-guide-server-side.md` (server-side surface removal) > - `v9-to-v10-migration-guide-sort.md` (`SortParamRequest[]` shape) > - `v9-to-v10-migration-guide-type-renames.md` (type aliases → generated names) +> - `v9-to-v10-migration-guide-dates.md` (server-sent dates as unix-nanosecond numbers — **read this for the `t('timestamp.X', { timestamp })` call sites**) > - `v9-to-v10-migration-guide-other.md` (everything else) ## TL;DR @@ -20,6 +21,12 @@ - **`Notification.message` is now documented as a developer-facing fallback, not display copy.** Its wording is not part of the public contract and may change in a minor release. Nothing breaks today, but anything user-facing should switch on `type`. See [Rendering notifications](#rendering-notifications). - **New subpath `stream-chat/i18n`** carries the shared translation runtime (`Streami18n`, formatters, date handling). Nothing is re-exported from `stream-chat`'s root, so the root bundle is unchanged. - **New subpath `stream-chat/i18n/codegen`** carries the build-time translation-catalog generator. Node-only, and ESM-only — but `engines.node` is now `>=22.18.0`, and `require(esm)` has been unflagged since 22.12, so `require()` works on every supported Node as well as `import`. +- **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 and renders the literal text `Invalid Date` into your UI. + `getDateString`'s `messageCreatedAt` _is_ typed (`string | Date`), so only the `t(…)` call sites + need auditing. See + [Timestamps reaching a formatter](#timestamps-reaching-a-formatter). - **`stream-chat` now depends on `i18next` and `dayjs`.** Install footprint grows ~2.3 MB; **bundle size is unaffected** unless you import `stream-chat/i18n`. - Nothing in the JSDoc ever described a `Notification.code` field. There is no such field and never was — the block documenting the `domain:entity:operation:result` scheme was attached to `type` and mislabelled. It has been corrected. @@ -236,6 +243,44 @@ Notable if you are building custom UI directly on `stream-chat`: - The keys with no inline default at their call site are injected via the `runtimeDefaults` option, because the catalog belongs to the UI layer rather than to core. +### Timestamps reaching a formatter + +Server-sent dates are unix-**nanosecond** numbers in v10 (see +[`v9-to-v10-migration-guide-dates.md`](./v9-to-v10-migration-guide-dates.md)), and the date layer has +no way to tell one from a millisecond value. There are two entry points and only one of them is +type-safe: + +- **`getDateString({ messageCreatedAt })` is typed `string | Date`.** A wire number is a compile + error, so this path forces the conversion. +- **`t('timestamp.X', { timestamp })` is not.** The `timestamp.*` keys carry a `timestampFormatter` + expression and the value arrives through i18next's interpolation options, which are untyped. + `timestampFormatter` declares `FormatterFactory`, but nothing enforces that at the + call site. + +```ts +// COMPILES CLEANLY. A nanosecond value is out of `Date`'s range, so dayjs cannot parse it and +// `.format()` returns the literal string 'Invalid Date' — which lands on screen. +t('timestamp.LiveLocation', { timestamp: location.end_at }); + +// Convert at the call site. +import { convertTimestampToDate } from 'stream-chat'; +t('timestamp.LiveLocation', { timestamp: convertTimestampToDate(location.end_at) }); +``` + +`convertTimestampToDate` returns `undefined` for an absent or non-finite value, and the formatter +renders an empty string for `undefined` rather than the literal text `"undefined"`, so it can be +passed straight through. + +**`duration.*` keys are the mirror-image trap.** `durationFormatter` goes through the date library's +`.duration()`, so it expects a length of time in **milliseconds** — not a timestamp. Handing it a +timestamp renders something like "57 years ago". Derive a duration by subtracting two wire timestamps +and converting once: `nsToMs(later - earlier)`. + +**`isDate` no longer helps.** `timestamp && isDate(timestamp) ? timestamp.toISOString() : undefined` +was a common idiom, and it now correctly reports that a wire number is not a `Date` — so it yields +`undefined` for every timestamp and the value silently disappears from the UI. Convert instead of +guarding. + ### Removed from the `Streami18n` surface Both UI SDKs' v9 classes exposed these. They are gone rather than deprecated — v10 is a breaking diff --git a/v9-to-v10-migration-guide-methods.md b/v9-to-v10-migration-guide-methods.md index 98c8815970..a9f1ac99ee 100644 --- a/v9-to-v10-migration-guide-methods.md +++ b/v9-to-v10-migration-guide-methods.md @@ -850,9 +850,12 @@ These now delegate to `channel.updateMemberPartial({ set: { archived: true } })` #### `channel.muteStatus` / `channel.sendAction` / `channel.keystroke` / `channel.stopTyping` +> Timestamps in these signatures are unix nanoseconds, not `Date`s — see +> [`v9-to-v10-migration-guide-dates.md`](./v9-to-v10-migration-guide-dates.md). + ```ts // v9 -channel.muteStatus(): { muted: boolean; createdAt: Date | null; expiresAt: Date | null }; +channel.muteStatus(): { muted: boolean; createdAt: number | null; expiresAt: number | null }; channel.sendAction(messageID, formData); channel.keystroke(parent_id?, options?: { user_id }); channel.stopTyping(parent_id?, options?: { user_id }); @@ -1402,6 +1405,17 @@ channel roles is a server-side operation; use with it, along with the rest of the v1 permission surface — see [the v1 permission system](./v9-to-v10-migration-guide-type-renames.md#the-v1-permission-system--removed). +## Removed after `10.0.0-rc.8` — moderator promotion moves server-side + +**`channel.addModerators(members, message?, options?, requestOptions?)` and +`channel.demoteModerators(members, message?, options?, requestOptions?)` — REMOVED.** The +clientside API no longer publishes `add_moderators` / `demote_moderators` on the channel-update +payload. Promoting or demoting an existing member is a server-side operation; use +[`@stream-io/node-sdk`](https://github.com/GetStream/stream-node). A member can still be given a +role at insert time, clientside, via +`channel.addMembers([{ user_id: 'thierry', channel_role: 'channel_moderator' }])`. This is the same +rationale as the `channel.assignRoles` removal above. + ## Removed after `10.0.0-rc.4` — moderation moves to the generated V2 API The `/moderation/*` methods on `StreamChat` were the last endpoints in the SDK that built diff --git a/v9-to-v10-migration-guide-other.md b/v9-to-v10-migration-guide-other.md index 17854f709e..c01a1f725d 100644 --- a/v9-to-v10-migration-guide-other.md +++ b/v9-to-v10-migration-guide-other.md @@ -9,11 +9,21 @@ > - `v9-to-v10-migration-guide-server-side.md` (server-side surface removal, dropped Node-only deps) > - `v9-to-v10-migration-guide-type-renames.md` (hand-rolled type aliases → generated names) > - `v9-to-v10-migration-guide-i18n.md` (notification identity, poll-composer field errors, the `stream-chat/i18n` subpath) +> - `v9-to-v10-migration-guide-dates.md` (server-sent dates as unix-nanosecond numbers) > > Read those first. This guide covers **exports, removed feature modules, event-type shape, filter constraints, small state/composer shape changes, and residual type/property renames** that the topic guides do not. +> +> **Start with the dates guide.** It is the change with the widest blast radius — every response and +> event type — and the only one whose main failure modes produce no compile error. ## TL;DR +- **Server-sent dates are unix-nanosecond `number`s** on every response and event type — not `Date` + objects and not ISO strings, while outgoing **request** date fields are still `Date`. `new Date(ns)` + is out of range, date libraries read a bare number as milliseconds, and `0` is a legitimate + timestamp so `if (!created_at)` is wrong. Full treatment, including the `t('timestamp.X', …)` call + sites the compiler does not guard, in + [`v9-to-v10-migration-guide-dates.md`](./v9-to-v10-migration-guide-dates.md). - **`engines.node` is now `>=22.18.0`** (was `>=18`). Node 22.18 is the release that unflagged TypeScript type stripping, which the package's own build scripts need — `prepare` runs the build, so a git-ref install has to be able to execute them. A registry install never builds, so if you are pinned @@ -27,10 +37,10 @@ - `EventTypes` (plural) renamed to `EventType` (singular). `CustomEventTypes` interface is unchanged — augment it to add custom event-type keys, same as v9. - Filter payloads now carry **per-endpoint operator constraints** (inline `Filters<{ … }>` on each request type) — previously-permissive filter objects may stop type-checking. Only one operator per field is allowed, and `null` is no longer a valid `$in` element. `QueryPollsFilters`, `QueryVotesFilters`, and `ReminderFilters` were the last hand-written holdouts and now derive from their request types too. - `ChannelState.membership` initializes to `undefined` (was `{}`); `ChannelState.typing` values are now `EventPayload<'typing.start' | 'typing.stop'>` (were `Event`); read receipts merged with the generated `ReadStateResponse`. -- Composer attachments now nest `mime_type` / `file_size` / `duration` under `.custom`; `LocationComposer` preview `end_at` is a `Date` (was ISO string). +- Composer attachments now nest `mime_type` / `file_size` / `duration` under `.custom`. `LocationComposer` state holds the **request** shape, so its `end_at` is a `Date` (was ISO string) — while an `end_at` read off a message is a unix-nanosecond number. `validLocation` returns `StaticLocationPreview | null` (was `SharedLocation | null`). See the [dates guide](./v9-to-v10-migration-guide-dates.md). - Composer configuration gained required `polls`, `attachments.enabled` and `attachments.customCdn` (all defaulted — only full-literal annotations break). The channel type's `uploads` / `polls` flags now resolve **into** that configuration, so read `composer.config` rather than `channel.serverConfig`. **Silent behaviour change:** a custom `doUploadRequest` no longer waives the `upload-file` capability — set `attachments.customCdn: true` if you upload to storage Stream does not host. - `Role` type renamed to `RoleName`. -- Assorted small tightenings: `TokenManager.setTokenOrProvider` user param narrowed, `revokeTokens(before)` no longer accepts `string`, `UserGroupPaginator` cursor field is a `Date`. +- Assorted small tightenings: `TokenManager.setTokenOrProvider` user param narrowed, `revokeTokens(before)` no longer accepts `string`. --- @@ -143,21 +153,21 @@ type LocalEvent = ( | ({ type: 'message.read_locally' } & { channel_type: string; cid: string; - created_at: Date; + created_at: number; channel_id?: string; last_read_message_id?: string; team?: string; user?: UserResponse; }) -) & { received_at?: Date }; +) & { received_at?: number }; // The hello event of the v2 connect endpoint (see "WebSocket transport" below). type ConnectedEvent = { type: 'connection.ok'; connection_id: string; - created_at: Date; + created_at: number; me: OwnUserResponse; - received_at?: Date; + received_at?: number; }; // Public alias — same name as in v9, wider shape. @@ -283,6 +293,11 @@ filter?: Filters<{ }>; ``` +**Note the asymmetry on date fields.** A filter operand is `Date | string`, while the same field on the +_response_ is a unix-nanosecond `number` — so a value read off a response cannot be fed back into a +filter, and a bare `number` operand is read as nanoseconds rather than milliseconds. See the +[dates guide](./v9-to-v10-migration-guide-dates.md#filter-operands-read-a-bare-number-as-nanoseconds). + Endpoints carrying a typed filter, and the property it sits on: | Request type | Property | @@ -460,23 +475,37 @@ Consequences: ### `LocationComposer` preview ```ts -// v9 +// v9 — `StaticLocationPayload` / `LiveLocationPayload` were hand-written and are gone export type LiveLocationPreview = Omit & { durationMs?: number; }; // end_at was set to `new Date(...).toISOString()` -// v10 -export type StaticLocationPreview = StaticLocationPayload & { message_id?: string }; -export type LiveLocationPreview = Omit & { +// v10 — both build on the generated request type `SharedLocation` +export type StaticLocationPreview = SharedLocation & { message_id?: string }; +export type LiveLocationPreview = Omit & { durationMs?: number; message_id?: string; }; -// end_at is now a Date (or undefined when durationMs is not a number) +// end_at is a Date, because composer state holds the REQUEST shape ``` If your app called `preview.end_at.toISOString()` or passed `end_at` directly to a `