From c86830bb5b4972b1c844766ee863f06d2bef4c35 Mon Sep 17 00:00:00 2001 From: Zita Szupera Date: Mon, 31 Aug 2026 10:02:11 +0200 Subject: [PATCH 01/11] feat: regenerate from latest API spec --- src/gen/chat/ChatApi.ts | 3 - src/gen/model-decoders/decoders.ts | 16 +-- src/gen/models/index.ts | 154 ++++++++++++++-------------- src/gen/moderation/ModerationApi.ts | 29 +++++- 4 files changed, 113 insertions(+), 89 deletions(-) diff --git a/src/gen/chat/ChatApi.ts b/src/gen/chat/ChatApi.ts index 95bd3da9f..849fada7e 100644 --- a/src/gen/chat/ChatApi.ts +++ b/src/gen/chat/ChatApi.ts @@ -627,9 +627,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, diff --git a/src/gen/model-decoders/decoders.ts b/src/gen/model-decoders/decoders.ts index 5d943bf23..7e07ccebe 100644 --- a/src/gen/model-decoders/decoders.ts +++ b/src/gen/model-decoders/decoders.ts @@ -1987,9 +1987,9 @@ 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 }, + + received_at: { type: 'DatetimeType', isSingle: true }, }; return decode(typeMappings, input); }; @@ -1998,9 +1998,9 @@ 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 }, + + received_at: { type: 'DatetimeType', isSingle: true }, }; return decode(typeMappings, input); }; @@ -2009,9 +2009,9 @@ 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 }, + + received_at: { type: 'DatetimeType', isSingle: true }, }; return decode(typeMappings, input); }; @@ -2037,9 +2037,9 @@ 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 }, + + received_at: { type: 'DatetimeType', isSingle: true }, }; return decode(typeMappings, input); }; diff --git a/src/gen/models/index.ts b/src/gen/models/index.ts index 974e8b4ef..8111332bb 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< @@ -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,11 +835,6 @@ export interface BanRequest { * Duration of the ban in minutes */ timeout?: number; - - /** - * User request object - */ - banned_by?: UserRequest; } export interface BanResponse { @@ -1281,6 +1275,8 @@ export interface ChannelConfigWithInfo { max_message_length: number; + message_retention: string; + mutes: boolean; name: string; @@ -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 { @@ -4515,8 +4508,6 @@ export interface GetMessageResponse { * Represents any chat message */ message: MessageWithChannelResponse; - - pending_message_metadata?: Record; } export interface GetOGResponse { @@ -5261,6 +5252,22 @@ export interface MemberUpdatedEvent { user?: UserResponseCommonFields; } +export interface MemberUserRequest { + id: string; + + image?: string; + + invisible?: boolean; + + language?: string; + + name?: string; + + custom?: Record; + + privacy_settings?: PrivacySettingsResponse; +} + export interface MembersResponse { /** * Duration of the request in milliseconds @@ -6033,6 +6040,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; /** @@ -6369,6 +6381,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; /** @@ -7701,8 +7718,6 @@ export interface PendingMessageResponse { */ message?: MessageResponse; - metadata?: Record; - /** * User response object */ @@ -7832,7 +7847,11 @@ export interface PollResponseData { vote_count: number; - voting_visibility: string; + /** + * Voting visibility of the poll + */ + + voting_visibility: 'anonymous' | 'public'; latest_answers: Array; @@ -8386,8 +8405,9 @@ export interface QueryChannelsRequest { }; has_unread: { - type: boolean; + type: true; operators: '$eq'; + closedSet: true; }; hidden: { @@ -8599,10 +8619,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 +8726,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 { @@ -10101,6 +10121,8 @@ export interface ReminderCreatedEvent { custom: CustomEventData; + reminder: ReminderResponseData; + /** * The type of event: "reminder.created" in this case */ @@ -10112,8 +10134,6 @@ export interface ReminderCreatedEvent { parent_id?: string; received_at?: Date; - - reminder?: ReminderResponseData; } export interface ReminderDeletedEvent { @@ -10139,6 +10159,8 @@ export interface ReminderDeletedEvent { custom: CustomEventData; + reminder: ReminderResponseData; + /** * The type of event: "reminder.deleted" in this case */ @@ -10150,8 +10172,6 @@ export interface ReminderDeletedEvent { parent_id?: string; received_at?: Date; - - reminder?: ReminderResponseData; } export interface ReminderNotificationEvent { @@ -10177,6 +10197,8 @@ export interface ReminderNotificationEvent { custom: CustomEventData; + reminder: ReminderResponseData; + /** * The type of event: "notification.reminder_due" in this case */ @@ -10185,8 +10207,6 @@ export interface ReminderNotificationEvent { parent_id?: string; received_at?: Date; - - reminder?: ReminderResponseData; } export interface ReminderResponseData { @@ -10241,6 +10261,8 @@ export interface ReminderUpdatedEvent { custom: CustomEventData; + reminder: ReminderResponseData; + /** * The type of event: "reminder.updated" in this case */ @@ -10252,8 +10274,6 @@ export interface ReminderUpdatedEvent { parent_id?: string; received_at?: Date; - - reminder?: ReminderResponseData; } export interface RemoveUserGroupMembersRequest { @@ -10657,8 +10677,9 @@ export interface SearchPayload { }; has_unread: { - type: boolean; + type: true; operators: '$eq'; + closedSet: true; }; hidden: { @@ -10995,6 +11016,8 @@ export interface SearchResultMessage { member?: ChannelMemberPartialResponse; + mentioned_channel_members?: Record; + moderation?: ModerationV2Response; /** @@ -11103,11 +11126,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 { @@ -11927,6 +11945,12 @@ export interface UnbanActionRequestPayload { target_user_id?: string; } +export interface UnbanRequest {} + +export interface UnbanResponse { + duration: string; +} + export interface UnblockActionRequestPayload { /** * Reason for the appeal decision @@ -12092,21 +12116,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 +12227,6 @@ export interface UpdateMessagePartialResponse { * Represents any chat message */ message?: MessageResponse; - - /** - * Pending message metadata - */ - pending_message_metadata?: Record; } export interface UpdateMessageRequest { @@ -12249,8 +12253,6 @@ export interface UpdateMessageResponse { * Represents any chat message */ message: MessageResponse; - - pending_message_metadata?: Record; } export interface UpdatePollOptionRequest { diff --git a/src/gen/moderation/ModerationApi.ts b/src/gen/moderation/ModerationApi.ts index fa6e9f916..d6b4bb36b 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, @@ -307,14 +309,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< @@ -720,6 +720,31 @@ export class ModerationApi { 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, + ); + + decoders['UnbanResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + async unmute( request: UnmuteRequest, From 80e57e907f7fa05d69184ea8ad4c5ff2120f3039 Mon Sep 17 00:00:00 2001 From: Zita Szupera Date: Mon, 31 Aug 2026 10:17:41 +0200 Subject: [PATCH 02/11] feat: migrate client to new API spec --- scripts/apply-custom-data-types.mts | 1 + src/channel.ts | 44 ------------------- src/client.ts | 6 ++- src/gen/models/index.ts | 2 +- test/typescript/index.js | 10 ----- .../typescript/response-generators/channel.js | 13 ------ v9-to-v10-migration-guide-methods.md | 11 +++++ 7 files changed, 18 insertions(+), 69 deletions(-) diff --git a/scripts/apply-custom-data-types.mts b/scripts/apply-custom-data-types.mts index 5967b1a3a..16d00de34 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/src/channel.ts b/src/channel.ts index ddba9d2fe..48917176b 100644 --- a/src/channel.ts +++ b/src/channel.ts @@ -1178,28 +1178,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 +1228,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. * diff --git a/src/client.ts b/src/client.ts index d15a2a2b1..fa12dec16 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1721,8 +1721,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); diff --git a/src/gen/models/index.ts b/src/gen/models/index.ts index 8111332bb..774841dca 100644 --- a/src/gen/models/index.ts +++ b/src/gen/models/index.ts @@ -5263,7 +5263,7 @@ export interface MemberUserRequest { name?: string; - custom?: Record; + custom?: CustomUserData; privacy_settings?: PrivacySettingsResponse; } diff --git a/test/typescript/index.js b/test/typescript/index.js index 289a95495..a81a2501d 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 0aa7fd0e3..5e314b259 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/v9-to-v10-migration-guide-methods.md b/v9-to-v10-migration-guide-methods.md index 98c881597..ccedcea79 100644 --- a/v9-to-v10-migration-guide-methods.md +++ b/v9-to-v10-migration-guide-methods.md @@ -1402,6 +1402,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 From c7daffb663697a65be3904b29b1c4518c9ca66b1 Mon Sep 17 00:00:00 2001 From: Zita Szupera Date: Mon, 31 Aug 2026 10:24:53 +0200 Subject: [PATCH 03/11] feat: remove date transformers --- scripts/generate-client.sh | 2 +- src/gen/model-decoders/decoders.ts | 2677 ---------------------------- src/gen/models/index.ts | 726 ++++---- 3 files changed, 364 insertions(+), 3041 deletions(-) diff --git a/scripts/generate-client.sh b/scripts/generate-client.sh index 4d42aa9c5..df399c7b9 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/gen/model-decoders/decoders.ts b/src/gen/model-decoders/decoders.ts index 7e07ccebe..5520cc009 100644 --- a/src/gen/model-decoders/decoders.ts +++ b/src/gen/model-decoders/decoders.ts @@ -31,2680 +31,3 @@ const decode = (typeMappings: TypeMapping, input?: Record) => { 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 }, - - reminder: { type: 'ReminderResponseData', isSingle: true }, - - received_at: { type: 'DatetimeType', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['ReminderDeletedEvent'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - reminder: { type: 'ReminderResponseData', isSingle: true }, - - received_at: { type: 'DatetimeType', isSingle: true }, - }; - return decode(typeMappings, input); -}; - -decoders['ReminderNotificationEvent'] = (input?: { [key: string]: any }) => { - const typeMappings: TypeMapping = { - created_at: { type: 'DatetimeType', isSingle: true }, - - reminder: { type: 'ReminderResponseData', isSingle: true }, - - received_at: { type: 'DatetimeType', 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 }, - - reminder: { type: 'ReminderResponseData', isSingle: true }, - - received_at: { type: 'DatetimeType', 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/models/index.ts b/src/gen/models/index.ts index 774841dca..c308cf750 100644 --- a/src/gen/models/index.ts +++ b/src/gen/models/index.ts @@ -127,7 +127,7 @@ export interface AIIndicatorClearEvent { /** * Date/time of creation */ - created_at: Date; + created_at: number; custom: CustomEventData; @@ -151,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; @@ -182,7 +182,7 @@ export interface AIIndicatorStopEvent { */ cid?: string; - received_at?: Date; + received_at?: number; } export interface AIIndicatorUpdateEvent { @@ -194,7 +194,7 @@ export interface AIIndicatorUpdateEvent { /** * Date/time of creation */ - created_at: Date; + created_at: number; /** * The ID of the message @@ -228,7 +228,7 @@ export interface AIIndicatorUpdateEvent { */ cid?: string; - received_at?: Date; + received_at?: number; } export interface AITextConfig { @@ -319,7 +319,7 @@ export interface ActionLogResponse { /** * Timestamp when the action was taken */ - created_at: Date; + created_at: number; /** * Unique identifier of the action log @@ -448,7 +448,7 @@ export interface AppUpdatedEvent { /** * Date/time of creation */ - created_at: Date; + created_at: number; app: AppEventResponse; @@ -459,7 +459,7 @@ export interface AppUpdatedEvent { */ type: string; - received_at?: Date; + received_at?: number; } export interface AppealItemResponse { @@ -471,7 +471,7 @@ export interface AppealItemResponse { /** * When the flag was created */ - created_at: Date; + created_at: number; /** * ID of the entity @@ -493,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 @@ -756,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. @@ -766,7 +766,7 @@ export interface BanInfoResponse { /** * When the ban expires */ - expires?: Date; + expires?: number; /** * Reason for the ban @@ -838,9 +838,9 @@ export interface BanRequest { } export interface BanResponse { - created_at: Date; + created_at: number; - expires?: Date; + expires?: number; reason?: string; @@ -919,7 +919,7 @@ export interface BlockListResponse { /** * Date/time of creation */ - created_at?: Date; + created_at?: number; id?: string; @@ -930,7 +930,7 @@ export interface BlockListResponse { /** * Date/time of the last update */ - updated_at?: Date; + updated_at?: number; } export interface BlockListRule { @@ -970,7 +970,7 @@ export interface BlockUsersResponse { /** * Timestamp when the user was blocked */ - created_at: Date; + created_at: number; /** * Duration of the request in milliseconds @@ -984,7 +984,7 @@ export interface BlockedUserResponse { */ blocked_user_id: string; - created_at: Date; + created_at: number; /** * ID of the user who blocked another user @@ -1265,7 +1265,7 @@ export interface ChannelConfigWithInfo { count_messages: boolean; - created_at: Date; + created_at: number; custom_events: boolean; @@ -1303,7 +1303,7 @@ export interface ChannelConfigWithInfo { typing_events: boolean; - updated_at: Date; + updated_at: number; uploads: boolean; @@ -1363,7 +1363,7 @@ export interface ChannelCreatedEvent { /** * Date/time of creation */ - created_at: Date; + created_at: number; /** * Represents channel in chat @@ -1399,7 +1399,7 @@ export interface ChannelCreatedEvent { */ cid?: string; - received_at?: Date; + received_at?: number; /** * The team ID @@ -1415,7 +1415,7 @@ export interface ChannelDeletedEvent { /** * Date/time of creation */ - created_at: Date; + created_at: number; /** * Represents channel in chat @@ -1451,7 +1451,7 @@ export interface ChannelDeletedEvent { */ cid?: string; - received_at?: Date; + received_at?: number; /** * The team ID @@ -1467,7 +1467,7 @@ export interface ChannelFrozenEvent { /** * Date/time of creation */ - created_at: Date; + created_at: number; custom: CustomEventData; @@ -1491,7 +1491,7 @@ export interface ChannelFrozenEvent { */ cid?: string; - received_at?: Date; + received_at?: number; } export interface ChannelGetOrCreateRequest { @@ -1540,7 +1540,7 @@ export interface ChannelHiddenEvent { /** * Date/time of creation */ - created_at: Date; + created_at: number; /** * Represents channel in chat @@ -1576,7 +1576,7 @@ export interface ChannelHiddenEvent { */ cid?: string; - received_at?: Date; + received_at?: number; /** * The team ID @@ -1666,7 +1666,7 @@ export interface ChannelKickedEvent { /** * Date/time of creation */ - created_at: Date; + created_at: number; custom: CustomEventData; @@ -1697,7 +1697,7 @@ export interface ChannelKickedEvent { */ cid?: string; - received_at?: Date; + received_at?: number; /** * The team ID @@ -1751,7 +1751,7 @@ export interface ChannelMemberResponse { /** * Date/time of creation */ - created_at: Date; + created_at: number; notifications_muted: boolean; @@ -1763,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 @@ -1806,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 @@ -1840,7 +1840,7 @@ export interface ChannelMetadata { custom: CustomChannelData; - last_message_at?: Date; + last_message_at?: number; member_count?: number; @@ -1855,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 @@ -1929,7 +1929,7 @@ export type ChannelOwnCapability = export interface ChannelPushPreferencesResponse { chat_level?: string; - disabled_until?: Date; + disabled_until?: number; chat_preferences?: ChatPreferencesResponse; } @@ -1943,7 +1943,7 @@ export interface ChannelResponse { /** * Date/time of creation */ - created_at: Date; + created_at: number; disabled: boolean; @@ -1965,7 +1965,7 @@ export interface ChannelResponse { /** * Date/time of the last update */ - updated_at: Date; + updated_at: number; /** * Custom data for this object @@ -1995,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 @@ -2005,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 @@ -2025,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 @@ -2040,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 @@ -2083,7 +2083,7 @@ export interface ChannelStateResponse { hidden?: boolean; - hide_messages_before?: Date; + hide_messages_before?: number; watcher_count?: number; @@ -2133,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 @@ -2178,7 +2178,7 @@ export interface ChannelTruncatedEvent { /** * Date/time of creation */ - created_at: Date; + created_at: number; /** * Represents channel in chat @@ -2216,7 +2216,7 @@ export interface ChannelTruncatedEvent { message_id?: string; - received_at?: Date; + received_at?: number; /** * The team ID @@ -2237,7 +2237,7 @@ export interface ChannelUnFrozenEvent { /** * Date/time of creation */ - created_at: Date; + created_at: number; custom: CustomEventData; @@ -2261,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 @@ -2306,7 +2306,7 @@ export interface ChannelUpdatedEvent { message_id?: string; - received_at?: Date; + received_at?: number; /** * The team ID @@ -2327,7 +2327,7 @@ export interface ChannelVisibleEvent { /** * Date/time of creation */ - created_at: Date; + created_at: number; /** * Represents channel in chat @@ -2363,7 +2363,7 @@ export interface ChannelVisibleEvent { */ cid?: string; - received_at?: Date; + received_at?: number; /** * The team ID @@ -2406,7 +2406,7 @@ export interface ChatDraftPayloadResponse { export interface ChatDraftResponse { channel_cid: string; - created_at: Date; + created_at: number; message: ChatDraftPayloadResponse; @@ -2420,7 +2420,7 @@ export interface ChatDraftResponse { export interface ChatMessageResponse { cid: string; - created_at: Date; + created_at: number; deleted_reply_count: number; @@ -2444,7 +2444,7 @@ export interface ChatMessageResponse { type: string; - updated_at: Date; + updated_at: number; attachments: Array; @@ -2469,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; @@ -2594,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; @@ -2604,7 +2604,7 @@ export interface ChatReactionGroupResponse { } export interface ChatReactionGroupUserResponse { - created_at: Date; + created_at: number; user_id: string; @@ -2615,7 +2615,7 @@ export interface ChatReactionGroupUserResponse { } export interface ChatReactionResponse { - created_at: Date; + created_at: number; message_id: string; @@ -2623,7 +2623,7 @@ export interface ChatReactionResponse { type: string; - updated_at: Date; + updated_at: number; user_id: string; @@ -2638,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; @@ -2659,7 +2659,7 @@ export interface ChatReminderResponseData { export interface ChatSharedLocationResponseData { channel_cid: string; - created_at: Date; + created_at: number; created_by_device_id: string; @@ -2669,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; } @@ -2714,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 { @@ -2808,7 +2808,7 @@ export interface ConfigResponse { /** * When the configuration was created */ - created_at: Date; + created_at: number; /** * Unique identifier for the moderation configuration @@ -2823,7 +2823,7 @@ export interface ConfigResponse { /** * When the configuration was last updated */ - updated_at: Date; + updated_at: number; supported_video_call_harm_types: Array; @@ -3132,13 +3132,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 { @@ -3409,7 +3409,7 @@ export interface DeviceResponse { /** * Date/time of creation */ - created_at: Date; + created_at: number; /** * Device ID @@ -3456,7 +3456,7 @@ export interface DraftDeletedEvent { /** * Date/time of creation */ - created_at: Date; + created_at: number; custom: CustomEventData; @@ -3475,7 +3475,7 @@ export interface DraftDeletedEvent { */ parent_id?: string; - received_at?: Date; + received_at?: number; draft?: DraftResponse; } @@ -3544,7 +3544,7 @@ export interface DraftPayloadResponse { export interface DraftResponse { channel_cid: string; - created_at: Date; + created_at: number; /** * Contains the draft message content @@ -3573,7 +3573,7 @@ export interface DraftUpdatedEvent { /** * Date/time of creation */ - created_at: Date; + created_at: number; custom: CustomEventData; @@ -3592,7 +3592,7 @@ export interface DraftUpdatedEvent { */ parent_id?: string; - received_at?: Date; + received_at?: number; draft?: DraftResponse; } @@ -3659,7 +3659,7 @@ export interface EntityCreatorResponse { banned: boolean; - created_at: Date; + created_at: number; /** * Number of major actions performed on the user @@ -3679,7 +3679,7 @@ export interface EntityCreatorResponse { role: string; - updated_at: Date; + updated_at: number; blocked_user_ids: Array; @@ -3689,17 +3689,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; } @@ -3756,13 +3756,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 @@ -3775,7 +3775,7 @@ export interface FeedsBookmarkResponse { } export interface FeedsEnrichedCollectionResponse { - created_at: Date; + created_at: number; id: string; @@ -3783,7 +3783,7 @@ export interface FeedsEnrichedCollectionResponse { status: string; - updated_at: Date; + updated_at: number; user_id: string; @@ -3793,7 +3793,7 @@ export interface FeedsEnrichedCollectionResponse { export interface FeedsFeedResponse { activity_count: number; - created_at: Date; + created_at: number; description: string; @@ -3813,14 +3813,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; @@ -3959,19 +3959,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 @@ -3986,7 +3986,7 @@ export interface FeedsReactionResponse { export interface FeedsShareResponse { activity_id: string; - created_at: Date; + created_at: number; /** * User response object @@ -3999,7 +3999,7 @@ export interface FeedsV3ActivityResponse { comment_count: number; - created_at: Date; + created_at: number; hidden: boolean; @@ -4019,7 +4019,7 @@ export interface FeedsV3ActivityResponse { type: string; - updated_at: Date; + updated_at: number; visibility: string; @@ -4054,11 +4054,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; @@ -4104,7 +4104,7 @@ export interface FeedsV3CommentResponse { confidence_score: number; - created_at: Date; + created_at: number; downvote_count: number; @@ -4122,7 +4122,7 @@ export interface FeedsV3CommentResponse { status: string; - updated_at: Date; + updated_at: number; upvote_count: number; @@ -4137,9 +4137,9 @@ export interface FeedsV3CommentResponse { controversy_score?: number; - deleted_at?: Date; + deleted_at?: number; - edited_at?: Date; + edited_at?: number; parent_id?: string; @@ -4249,7 +4249,7 @@ export interface FlagDetailsResponse { } export interface FlagFeedbackResponse { - created_at: Date; + created_at: number; message_id: string; @@ -4359,7 +4359,7 @@ export interface FloodSimilarRuleParameters { export interface FullUserResponse { banned: boolean; - created_at: Date; + created_at: number; id: string; @@ -4381,7 +4381,7 @@ export interface FullUserResponse { unread_threads: number; - updated_at: Date; + updated_at: number; blocked_user_ids: Array; @@ -4397,19 +4397,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; @@ -4419,9 +4419,9 @@ export interface FullUserResponse { } export interface FutureChannelBanResponse { - created_at: Date; + created_at: number; - expires?: Date; + expires?: number; reason?: string; @@ -4713,7 +4713,7 @@ export interface HarmConfig { export interface HealthCheckEvent { connection_id: string; - created_at: Date; + created_at: number; custom: CustomEventData; @@ -4721,7 +4721,7 @@ export interface HealthCheckEvent { cid?: string; - received_at?: Date; + received_at?: number; me?: OwnUserResponse; } @@ -5017,11 +5017,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; @@ -5072,20 +5072,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 @@ -5126,7 +5126,7 @@ export interface MemberAddedEvent { */ cid?: string; - received_at?: Date; + received_at?: number; /** * The team ID @@ -5142,7 +5142,7 @@ export interface MemberRemovedEvent { /** * Date/time of creation */ - created_at: Date; + created_at: number; /** * Represents channel in chat @@ -5183,7 +5183,7 @@ export interface MemberRemovedEvent { */ cid?: string; - received_at?: Date; + received_at?: number; /** * The team ID @@ -5199,7 +5199,7 @@ export interface MemberUpdatedEvent { /** * Date/time of creation */ - created_at: Date; + created_at: number; /** * Represents channel in chat @@ -5240,7 +5240,7 @@ export interface MemberUpdatedEvent { */ cid?: string; - received_at?: Date; + received_at?: number; /** * The team ID @@ -5323,7 +5323,7 @@ export interface MessageDeletedEvent { /** * Date/time of creation */ - created_at: Date; + created_at: number; /** * Whether the message was hard deleted @@ -5374,7 +5374,7 @@ export interface MessageDeletedEvent { */ deleted_for_me?: boolean; - received_at?: Date; + received_at?: number; /** * The team ID @@ -5390,7 +5390,7 @@ export interface MessageDeliveredEvent { /** * Date/time of creation */ - created_at: Date; + created_at: number; custom: CustomEventData; @@ -5434,7 +5434,7 @@ export interface MessageDeliveredEvent { */ last_delivered_message_id?: string; - received_at?: Date; + received_at?: number; /** * The team ID @@ -5452,19 +5452,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; @@ -5502,7 +5502,7 @@ export interface MessageModerationResult { /** * Date/time of creation */ - created_at: Date; + created_at: number; /** * ID of the message @@ -5512,7 +5512,7 @@ export interface MessageModerationResult { /** * Date/time of the last update */ - updated_at: Date; + updated_at: number; /** * Whether user has bad karma @@ -5551,7 +5551,7 @@ export interface MessageNewEvent { /** * Date/time of creation */ - created_at: Date; + created_at: number; message_id: string; @@ -5602,7 +5602,7 @@ export interface MessageNewEvent { */ parent_author?: string; - received_at?: Date; + received_at?: number; /** * The team ID @@ -5702,7 +5702,7 @@ export interface MessageReadEvent { /** * Date/time of creation */ - created_at: Date; + created_at: number; custom: CustomEventData; @@ -5741,7 +5741,7 @@ export interface MessageReadEvent { */ last_read_message_id?: string; - received_at?: Date; + received_at?: number; /** * The team ID @@ -5859,7 +5859,7 @@ export interface MessageResponse { /** * Date/time of creation */ - created_at: Date; + created_at: number; deleted_reply_count: number; @@ -5916,7 +5916,7 @@ export interface MessageResponse { /** * Date/time of the last update */ - updated_at: Date; + updated_at: number; /** * Array of message attachments @@ -5968,11 +5968,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 @@ -5987,12 +5987,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 @@ -6070,7 +6070,7 @@ export interface MessageUndeletedEvent { /** * Date/time of creation */ - created_at: Date; + created_at: number; message_id: string; @@ -6111,7 +6111,7 @@ export interface MessageUndeletedEvent { */ cid?: string; - received_at?: Date; + received_at?: number; /** * The team ID @@ -6131,7 +6131,7 @@ export interface MessageUpdatedEvent { /** * Date/time of creation */ - created_at: Date; + created_at: number; message_id: string; @@ -6172,7 +6172,7 @@ export interface MessageUpdatedEvent { */ cid?: string; - received_at?: Date; + received_at?: number; /** * The team ID @@ -6195,7 +6195,7 @@ export interface MessageWithChannelResponse { /** * Date/time of creation */ - created_at: Date; + created_at: number; deleted_reply_count: number; @@ -6252,7 +6252,7 @@ export interface MessageWithChannelResponse { /** * Date/time of the last update */ - updated_at: Date; + updated_at: number; /** * Array of message attachments @@ -6309,11 +6309,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 @@ -6328,12 +6328,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 @@ -6457,7 +6457,7 @@ export interface ModerationCallResponse { cid: string; - created_at: Date; + created_at: number; current_session_id: string; @@ -6471,7 +6471,7 @@ export interface ModerationCallResponse { type: string; - updated_at: Date; + updated_at: number; blocked_user_ids: Array; @@ -6479,13 +6479,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; @@ -6501,7 +6501,7 @@ export interface ModerationCustomActionEvent { */ action_id: string; - created_at: Date; + created_at: number; custom: CustomEventData; @@ -6509,7 +6509,7 @@ export interface ModerationCustomActionEvent { type: string; - received_at?: Date; + received_at?: number; /** * Additional options passed to the custom action @@ -6523,7 +6523,7 @@ export interface ModerationCustomActionEvent { } export interface ModerationFlagResponse { - created_at: Date; + created_at: number; entity_id: string; @@ -6531,7 +6531,7 @@ export interface ModerationFlagResponse { type: string; - updated_at: Date; + updated_at: number; user_id: string; @@ -6566,7 +6566,7 @@ export interface ModerationFlaggedEvent { */ content_type: string; - created_at: Date; + created_at: number; /** * The ID of the flagged content @@ -6577,11 +6577,11 @@ export interface ModerationFlaggedEvent { type: string; - received_at?: Date; + received_at?: number; } export interface ModerationMarkReviewedEvent { - created_at: Date; + created_at: number; custom: CustomEventData; @@ -6589,7 +6589,7 @@ export interface ModerationMarkReviewedEvent { type: string; - received_at?: Date; + received_at?: number; /** * Represents any chat message @@ -6672,7 +6672,7 @@ export interface ModerationPayloadResponse { } export interface ModerationQueueResponse { - created_at: Date; + created_at: number; created_by: string; @@ -6686,7 +6686,7 @@ export interface ModerationQueueResponse { type: string; - updated_at: Date; + updated_at: number; sort: Array>; @@ -6778,7 +6778,7 @@ export interface NotificationAddedToChannelEvent { /** * Date/time of creation */ - created_at: Date; + created_at: number; /** * Represents channel in chat @@ -6813,7 +6813,7 @@ export interface NotificationAddedToChannelEvent { */ cid?: string; - received_at?: Date; + received_at?: number; /** * The team ID @@ -6827,7 +6827,7 @@ export interface NotificationChannelDeletedEvent { /** * Date/time of creation */ - created_at: Date; + created_at: number; /** * Represents channel in chat @@ -6863,7 +6863,7 @@ export interface NotificationChannelDeletedEvent { */ cid?: string; - received_at?: Date; + received_at?: number; /** * The team ID @@ -6894,7 +6894,7 @@ export interface NotificationChannelMutesUpdatedEvent { /** * Date/time of creation */ - created_at: Date; + created_at: number; custom: CustomEventData; @@ -6905,14 +6905,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 @@ -6950,7 +6950,7 @@ export interface NotificationChannelTruncatedEvent { message_id?: string; - received_at?: Date; + received_at?: number; /** * The team ID @@ -6986,7 +6986,7 @@ export interface NotificationInviteAcceptedEvent { /** * Date/time of creation */ - created_at: Date; + created_at: number; /** * Represents channel in chat @@ -7027,7 +7027,7 @@ export interface NotificationInviteAcceptedEvent { */ cid?: string; - received_at?: Date; + received_at?: number; /** * The team ID @@ -7043,7 +7043,7 @@ export interface NotificationInviteRejectedEvent { /** * Date/time of creation */ - created_at: Date; + created_at: number; /** * Represents channel in chat @@ -7084,7 +7084,7 @@ export interface NotificationInviteRejectedEvent { */ cid?: string; - received_at?: Date; + received_at?: number; /** * The team ID @@ -7100,7 +7100,7 @@ export interface NotificationInvitedEvent { /** * Date/time of creation */ - created_at: Date; + created_at: number; /** * Represents channel in chat @@ -7141,7 +7141,7 @@ export interface NotificationInvitedEvent { */ cid?: string; - received_at?: Date; + received_at?: number; /** * The team ID @@ -7157,7 +7157,7 @@ export interface NotificationMarkReadEvent { /** * Date/time of creation */ - created_at: Date; + created_at: number; /** * The total number of unread messages @@ -7208,7 +7208,7 @@ export interface NotificationMarkReadEvent { */ last_read_message_id?: string; - received_at?: Date; + received_at?: number; /** * The team ID @@ -7248,7 +7248,7 @@ export interface NotificationMarkUnreadEvent { /** * Date/time of creation */ - created_at: Date; + created_at: number; custom: CustomEventData; @@ -7287,14 +7287,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 @@ -7352,7 +7352,7 @@ export interface NotificationMutesUpdatedEvent { /** * Date/time of creation */ - created_at: Date; + created_at: number; custom: CustomEventData; @@ -7363,14 +7363,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; @@ -7417,7 +7417,7 @@ export interface NotificationNewMessageEvent { parent_author?: string; - received_at?: Date; + received_at?: number; /** * The team ID @@ -7444,7 +7444,7 @@ export interface NotificationRemovedFromChannelEvent { /** * Date/time of creation */ - created_at: Date; + created_at: number; /** * Represents channel in chat @@ -7485,7 +7485,7 @@ export interface NotificationRemovedFromChannelEvent { */ cid?: string; - received_at?: Date; + received_at?: number; /** * The team ID @@ -7501,7 +7501,7 @@ export interface NotificationThreadMessageNewEvent { /** * Date/time of creation */ - created_at: Date; + created_at: number; message_id: string; @@ -7553,7 +7553,7 @@ export interface NotificationThreadMessageNewEvent { parent_author?: string; - received_at?: Date; + received_at?: number; /** * The team ID @@ -7593,7 +7593,7 @@ export interface OnlyUserID { export interface OwnUserResponse { banned: boolean; - created_at: Date; + created_at: number; id: string; @@ -7613,7 +7613,7 @@ export interface OwnUserResponse { unread_threads: number; - updated_at: Date; + updated_at: number; channel_mutes: Array; @@ -7627,17 +7627,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; @@ -7670,7 +7670,7 @@ export interface PendingMessageEvent { /** * Date/time of creation */ - created_at: Date; + created_at: number; /** * The method used for the pending message @@ -7684,7 +7684,7 @@ export interface PendingMessageEvent { */ type: string; - received_at?: Date; + received_at?: number; /** * Represents channel in chat @@ -7728,7 +7728,7 @@ export interface PollClosedEvent { /** * Date/time of creation */ - created_at: Date; + created_at: number; custom: CustomEventData; @@ -7751,14 +7751,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; @@ -7781,7 +7781,7 @@ export interface PollDeletedEvent { */ message_id?: string; - received_at?: Date; + received_at?: number; } export interface PollOptionInput { @@ -7831,7 +7831,7 @@ export interface PollResponseData { answers_count: number; - created_at: Date; + created_at: number; created_by_id: string; @@ -7843,7 +7843,7 @@ export interface PollResponseData { name: string; - updated_at: Date; + updated_at: number; vote_count: number; @@ -7879,7 +7879,7 @@ export interface PollUpdatedEvent { /** * Date/time of creation */ - created_at: Date; + created_at: number; custom: CustomEventData; @@ -7902,14 +7902,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; @@ -7934,14 +7934,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; @@ -7966,14 +7966,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; @@ -7998,7 +7998,7 @@ export interface PollVoteRemovedEvent { */ message_id?: string; - received_at?: Date; + received_at?: number; } export interface PollVoteResponse { @@ -8013,7 +8013,7 @@ export interface PollVoteResponse { } export interface PollVoteResponseData { - created_at: Date; + created_at: number; id: string; @@ -8021,7 +8021,7 @@ export interface PollVoteResponseData { poll_id: string; - updated_at: Date; + updated_at: number; answer_text?: string; @@ -8114,7 +8114,7 @@ export interface PushPreferencesResponse { chat_level?: string; - disabled_until?: Date; + disabled_until?: number; feeds_level?: string; @@ -9725,15 +9725,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; @@ -9762,7 +9762,7 @@ export interface ReactionDeletedEvent { /** * Date/time of creation */ - created_at: Date; + created_at: number; /** * Represents channel in chat @@ -9803,7 +9803,7 @@ export interface ReactionDeletedEvent { message_id?: string; - received_at?: Date; + received_at?: number; /** * The team ID @@ -9836,12 +9836,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`. @@ -9858,7 +9858,7 @@ export interface ReactionGroupUserResponse { /** * The time when the user reacted. */ - created_at: Date; + created_at: number; /** * The ID of the user who reacted. @@ -9875,7 +9875,7 @@ export interface ReactionNewEvent { /** * Date/time of creation */ - created_at: Date; + created_at: number; /** * Represents channel in chat @@ -9916,7 +9916,7 @@ export interface ReactionNewEvent { message_id?: string; - received_at?: Date; + received_at?: number; /** * The team ID @@ -9968,7 +9968,7 @@ export interface ReactionResponse { /** * Date/time of creation */ - created_at: Date; + created_at: number; /** * Message ID @@ -9988,7 +9988,7 @@ export interface ReactionResponse { /** * Date/time of the last update */ - updated_at: Date; + updated_at: number; /** * User ID @@ -10010,7 +10010,7 @@ export interface ReactionUpdatedEvent { /** * Date/time of creation */ - created_at: Date; + created_at: number; message_id: string; @@ -10056,7 +10056,7 @@ export interface ReactionUpdatedEvent { */ cid?: string; - received_at?: Date; + received_at?: number; /** * The team ID @@ -10075,7 +10075,7 @@ export interface ReadReceiptsResponse { } export interface ReadStateResponse { - last_read: Date; + last_read: number; unread_messages: number; @@ -10084,7 +10084,7 @@ export interface ReadStateResponse { */ user: UserResponse; - last_delivered_at?: Date; + last_delivered_at?: number; last_delivered_message_id?: string; @@ -10107,7 +10107,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 @@ -10133,7 +10133,7 @@ export interface ReminderCreatedEvent { */ parent_id?: string; - received_at?: Date; + received_at?: number; } export interface ReminderDeletedEvent { @@ -10145,7 +10145,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 @@ -10171,7 +10171,7 @@ export interface ReminderDeletedEvent { */ parent_id?: string; - received_at?: Date; + received_at?: number; } export interface ReminderNotificationEvent { @@ -10183,7 +10183,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 @@ -10206,21 +10206,21 @@ export interface ReminderNotificationEvent { parent_id?: string; - received_at?: Date; + 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 @@ -10247,7 +10247,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 @@ -10273,7 +10273,7 @@ export interface ReminderUpdatedEvent { */ parent_id?: string; - received_at?: Date; + received_at?: number; } export interface RemoveUserGroupMembersRequest { @@ -10314,7 +10314,7 @@ export interface ReviewQueueItemResponse { /** * When the item was created */ - created_at: Date; + created_at: number; /** * ID of the entity being reviewed @@ -10363,7 +10363,7 @@ export interface ReviewQueueItemResponse { /** * When the item was last updated */ - updated_at: Date; + updated_at: number; /** * Moderation actions taken @@ -10388,7 +10388,7 @@ export interface ReviewQueueItemResponse { /** * When the review was completed */ - completed_at?: Date; + completed_at?: number; config_key?: string; @@ -10400,7 +10400,7 @@ export interface ReviewQueueItemResponse { /** * When the item was escalated */ - escalated_at?: Date; + escalated_at?: number; /** * ID of the moderator who escalated the item @@ -10410,7 +10410,7 @@ export interface ReviewQueueItemResponse { /** * When the item was reviewed */ - reviewed_at?: Date; + reviewed_at?: number; /** * Teams associated with this item @@ -10454,7 +10454,7 @@ export interface Role { /** * Date/time of creation */ - created_at: Date; + created_at: number; /** * Whether this is a custom role or built-in @@ -10469,7 +10469,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 @@ -10926,7 +10926,7 @@ export interface SearchResult { export interface SearchResultMessage { cid: string; - created_at: Date; + created_at: number; deleted_reply_count: number; @@ -10950,7 +10950,7 @@ export interface SearchResultMessage { type: string; - updated_at: Date; + updated_at: number; attachments: Array; @@ -10975,19 +10975,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; @@ -11185,7 +11185,7 @@ export interface SharedLocationResponse { /** * Date/time of creation */ - created_at: Date; + created_at: number; /** * Device ID that created the live location @@ -11212,7 +11212,7 @@ export interface SharedLocationResponse { /** * Date/time of the last update */ - updated_at: Date; + updated_at: number; /** * User ID @@ -11222,7 +11222,7 @@ export interface SharedLocationResponse { /** * Time when the live location expires */ - end_at?: Date; + end_at?: number; /** * Represents channel in chat @@ -11238,7 +11238,7 @@ export interface SharedLocationResponse { export interface SharedLocationResponseData { channel_cid: string; - created_at: Date; + created_at: number; created_by_device_id: string; @@ -11248,11 +11248,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 @@ -11507,18 +11507,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 @@ -11550,7 +11550,7 @@ export interface ThreadResponse { /** * Date/time of creation */ - created_at: Date; + created_at: number; /** * Created By User ID @@ -11580,7 +11580,7 @@ export interface ThreadResponse { /** * Date/time of the last update */ - updated_at: Date; + updated_at: number; /** * Custom data for this object @@ -11590,12 +11590,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 @@ -11632,7 +11632,7 @@ export interface ThreadStateResponse { /** * Date/time of creation */ - created_at: Date; + created_at: number; /** * Created By User ID @@ -11662,7 +11662,7 @@ export interface ThreadStateResponse { /** * Date/time of the last update */ - updated_at: Date; + updated_at: number; latest_replies: Array; @@ -11674,12 +11674,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; @@ -11707,7 +11707,7 @@ export interface ThreadStateResponse { } export interface ThreadUpdatedEvent { - created_at: Date; + created_at: number; custom: CustomEventData; @@ -11719,7 +11719,7 @@ export interface ThreadUpdatedEvent { cid?: string; - received_at?: Date; + received_at?: number; thread?: ThreadResponse; } @@ -11851,7 +11851,7 @@ export interface TypingStartEvent { /** * Date/time of creation */ - created_at: Date; + created_at: number; custom: CustomEventData; @@ -11880,7 +11880,7 @@ export interface TypingStartEvent { */ parent_id?: string; - received_at?: Date; + received_at?: number; user?: UserResponseCommonFields; } @@ -11889,7 +11889,7 @@ export interface TypingStopEvent { /** * Date/time of creation */ - created_at: Date; + created_at: number; custom: CustomEventData; @@ -11918,7 +11918,7 @@ export interface TypingStopEvent { */ parent_id?: string; - received_at?: Date; + received_at?: number; user?: UserResponseCommonFields; } @@ -12000,7 +12000,7 @@ export interface UnmuteResponse { export interface UnreadCountsChannel { channel_id: string; - last_read: Date; + last_read: number; unread_count: number; } @@ -12014,7 +12014,7 @@ export interface UnreadCountsChannelType { } export interface UnreadCountsThread { - last_read: Date; + last_read: number; last_read_message_id: string; @@ -12660,7 +12660,7 @@ export interface UserBannedEvent { /** * Date/time of creation */ - created_at: Date; + created_at: number; custom: CustomEventData; @@ -12693,14 +12693,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 @@ -12738,7 +12738,7 @@ export interface UserDeactivatedEvent { /** * Date/time of creation */ - created_at: Date; + created_at: number; custom: CustomEventData; @@ -12749,7 +12749,7 @@ export interface UserDeactivatedEvent { */ type: string; - received_at?: Date; + received_at?: number; created_by?: UserResponseCommonFields; } @@ -12758,7 +12758,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) @@ -12799,19 +12799,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; @@ -12826,7 +12826,7 @@ export interface UserGroupCreatedEvent { /** * Date/time of creation */ - created_at: Date; + created_at: number; custom: CustomEventData; @@ -12835,7 +12835,7 @@ export interface UserGroupCreatedEvent { */ type: string; - received_at?: Date; + received_at?: number; user?: UserResponseCommonFields; @@ -12846,7 +12846,7 @@ export interface UserGroupDeletedEvent { /** * Date/time of creation */ - created_at: Date; + created_at: number; custom: CustomEventData; @@ -12855,7 +12855,7 @@ export interface UserGroupDeletedEvent { */ type: string; - received_at?: Date; + received_at?: number; user?: UserResponseCommonFields; @@ -12865,7 +12865,7 @@ export interface UserGroupDeletedEvent { export interface UserGroupMember { app_pk: number; - created_at: Date; + created_at: number; group_id: string; @@ -12878,7 +12878,7 @@ export interface UserGroupMemberAddedEvent { /** * Date/time of creation */ - created_at: Date; + created_at: number; /** * The user IDs that were added @@ -12892,7 +12892,7 @@ export interface UserGroupMemberAddedEvent { */ type: string; - received_at?: Date; + received_at?: number; user?: UserResponseCommonFields; @@ -12903,7 +12903,7 @@ export interface UserGroupMemberRemovedEvent { /** * Date/time of creation */ - created_at: Date; + created_at: number; /** * The user IDs that were removed @@ -12917,7 +12917,7 @@ export interface UserGroupMemberRemovedEvent { */ type: string; - received_at?: Date; + received_at?: number; user?: UserResponseCommonFields; @@ -12925,13 +12925,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; @@ -12946,7 +12946,7 @@ export interface UserGroupUpdatedEvent { /** * Date/time of creation */ - created_at: Date; + created_at: number; custom: CustomEventData; @@ -12955,7 +12955,7 @@ export interface UserGroupUpdatedEvent { */ type: string; - received_at?: Date; + received_at?: number; user?: UserResponseCommonFields; @@ -12972,7 +12972,7 @@ export interface UserMessagesDeletedEvent { /** * Date/time of creation */ - created_at: Date; + created_at: number; custom: CustomEventData; @@ -13007,7 +13007,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 @@ -13018,11 +13018,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 @@ -13039,7 +13039,7 @@ export interface UserMutedEvent { /** * Date/time of creation */ - created_at: Date; + created_at: number; custom: CustomEventData; @@ -13050,7 +13050,7 @@ export interface UserMutedEvent { */ type: string; - received_at?: Date; + received_at?: number; /** * The target users that were muted @@ -13064,7 +13064,7 @@ export interface UserPresenceChangedEvent { /** * Date/time of creation */ - created_at: Date; + created_at: number; custom: CustomEventData; @@ -13075,14 +13075,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; @@ -13093,7 +13093,7 @@ export interface UserReactivatedEvent { */ type: string; - received_at?: Date; + received_at?: number; created_by?: UserResponseCommonFields; } @@ -13135,7 +13135,7 @@ export interface UserResponse { /** * Date/time of creation */ - created_at: Date; + created_at: number; /** * Unique user identifier @@ -13160,7 +13160,7 @@ export interface UserResponse { /** * Date/time of the last update */ - updated_at: Date; + updated_at: number; blocked_user_ids: Array; @@ -13179,19 +13179,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 @@ -13201,7 +13201,7 @@ export interface UserResponse { /** * Revocation date for tokens */ - revoke_tokens_issued_before?: Date; + revoke_tokens_issued_before?: number; teams_role?: Record; } @@ -13209,7 +13209,7 @@ export interface UserResponse { export interface UserResponseCommonFields { banned: boolean; - created_at: Date; + created_at: number; id: string; @@ -13219,7 +13219,7 @@ export interface UserResponseCommonFields { role: string; - updated_at: Date; + updated_at: number; blocked_user_ids: Array; @@ -13229,17 +13229,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; } @@ -13247,7 +13247,7 @@ export interface UserResponseCommonFields { export interface UserResponsePrivacyFields { banned: boolean; - created_at: Date; + created_at: number; id: string; @@ -13257,7 +13257,7 @@ export interface UserResponsePrivacyFields { role: string; - updated_at: Date; + updated_at: number; blocked_user_ids: Array; @@ -13267,19 +13267,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; @@ -13300,7 +13300,7 @@ export interface UserUnbannedEvent { /** * Date/time of creation */ - created_at: Date; + created_at: number; custom: CustomEventData; @@ -13330,7 +13330,7 @@ export interface UserUnbannedEvent { */ cid?: string; - received_at?: Date; + received_at?: number; /** * Whether the target user was shadow unbanned @@ -13351,7 +13351,7 @@ export interface UserUpdatedEvent { /** * Date/time of creation */ - created_at: Date; + created_at: number; custom: CustomEventData; @@ -13362,14 +13362,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 @@ -13400,14 +13400,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 @@ -13438,7 +13438,7 @@ export interface UserWatchingStopEvent { */ cid?: string; - received_at?: Date; + received_at?: number; } export interface VelocityFilterConfig { From 169351c0e47426fb74e92b337cb0e572c435cc08 Mon Sep 17 00:00:00 2001 From: Zita Szupera Date: Mon, 31 Aug 2026 15:46:46 +0200 Subject: [PATCH 04/11] feat: adjust stream-chat-js to date type changes --- src/CooldownTimer.ts | 63 +++-- src/LiveLocationManager.ts | 30 +-- src/channel.ts | 97 ++++--- src/channel_state.ts | 22 +- src/client.ts | 22 +- src/connection.ts | 17 +- src/entityStore/applyReactionLocally.ts | 13 +- src/gen/chat/ChatApi.ts | 215 +-------------- src/gen/model-decoders/decoders.ts | 33 --- .../model-decoders/event-decoder-mapping.ts | 198 -------------- src/gen/models/index.ts | 9 + src/gen/moderation/ModerationApi.ts | 51 ---- src/index.ts | 9 + src/messageComposer/messageComposer.ts | 22 +- .../messageComposer/sharedLocation.ts | 3 +- .../MessageDeliveryReporter.ts | 7 +- src/messageDelivery/MessageReceiptsTracker.ts | 79 +++--- .../MessageOperationStatePolicy.ts | 11 +- src/messageOperations/optimistic.ts | 12 +- src/offline-support/offline_support_api.ts | 5 +- src/offline-support/offline_sync_manager.ts | 10 - src/offline-support/types.ts | 3 +- .../createdAtAroundPaginationFlags.ts | 17 +- .../cursorDerivation/linearPaginationFlags.ts | 2 +- src/pagination/paginators/ChannelPaginator.ts | 10 +- .../paginators/MessageIntervalPaginator.ts | 42 +-- src/pagination/paginators/MessagePaginator.ts | 28 +- .../paginators/UserGroupPaginator.ts | 5 +- src/pagination/utility.normalization.ts | 27 +- src/poll.ts | 20 +- src/reminders/Reminder.ts | 41 ++- src/reminders/ReminderManager.ts | 2 +- src/reminders/ReminderTimer.ts | 13 +- src/thread.ts | 46 ++-- src/types.ts | 10 +- src/utils.ts | 16 +- src/utils/time.ts | 72 +++++ test/unit/CooldownTimer.test.ts | 5 +- .../MessageComposer/messageComposer.test.ts | 73 +++--- .../messageComposer/sharedLocation.test.ts | 4 +- test/unit/channel.test.js | 159 ++++++------ test/unit/channel_state.test.js | 9 +- test/unit/connection.test.js | 14 +- .../MessageReceiptsTracker.test.ts | 213 ++++++++------- .../MessageOperations.test.ts | 41 +-- .../optimisticRouting.test.ts | 6 +- .../offline_support_api.test.ts | 56 ++-- .../pagination/UserGroupPaginator.test.ts | 14 +- .../paginators/ChannelPaginator.test.ts | 29 ++- .../paginators/MessagePaginator.test.ts | 245 +++++++++++++----- .../paginators/UserGroupPaginator.test.ts | 3 +- test/unit/reminders/Reminder.test.ts | 19 +- test/unit/reminders/ReminderManager.test.ts | 62 ++--- test/unit/test-utils/generateChannel.ts | 15 +- test/unit/test-utils/generateMessage.ts | 30 ++- test/unit/test-utils/generateMessageDraft.ts | 3 +- test/unit/test-utils/generateReadResponse.js | 3 +- .../unit/test-utils/generateThreadResponse.js | 8 +- test/unit/test-utils/generateUser.js | 5 +- .../test-utils/mockChannelQueryResponse.js | 37 +-- test/unit/test-utils/time.ts | 19 ++ test/unit/threads.test.ts | 88 +++---- test/unit/wireTimestamps.test.ts | 244 +++++++++++++++++ 63 files changed, 1352 insertions(+), 1334 deletions(-) delete mode 100644 src/gen/model-decoders/decoders.ts delete mode 100644 src/gen/model-decoders/event-decoder-mapping.ts create mode 100644 src/utils/time.ts create mode 100644 test/unit/test-utils/time.ts create mode 100644 test/unit/wireTimestamps.test.ts diff --git a/src/CooldownTimer.ts b/src/CooldownTimer.ts index c57dee068..617a3d760 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 e67f9770a..db960912f 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,11 +40,14 @@ export type LiveLocationManagerState = { messages: Map; }; -const isExpiredLocation = (location: SharedLiveLocationResponse) => { - const endTimeTimestamp = new Date(location.end_at).getTime(); +const isExpiredLocation = (location: SharedLiveLocationResponse) => + location.end_at < nowNs(); - return endTimeTimestamp < Date.now(); -}; +/** + * Milliseconds from now until a live location stops sharing, for `setTimeout`. 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, @@ -240,12 +244,9 @@ export class LiveLocationManager extends WithSubscriptions { location.message_id, { ...location, - stopSharingTimeout: setTimeout( - () => { - this.unregisterMessages([location.message_id]); - }, - new Date(location.end_at).getTime() - Date.now(), - ), + stopSharingTimeout: setTimeout(() => { + this.unregisterMessages([location.message_id]); + }, msUntilExpiry(location.end_at)), }, ]), ), @@ -369,12 +370,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: setTimeout(() => { + this.unregisterMessages([message.id]); + }, msUntilExpiry(message.shared_location.end_at)), }); return { ...currentValue, diff --git a/src/channel.ts b/src/channel.ts index 48917176b..933424c7a 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'; @@ -1340,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(); @@ -1360,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; @@ -1415,7 +1417,7 @@ export class Channel extends ChannelApi { type: 'typing.start', parent_id: parentId, ...(options || {}), - created_at: new Date(), + created_at: nowNs(), custom: {}, }, }, @@ -1448,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: {}, }, }, @@ -1468,7 +1470,7 @@ export class Channel extends ChannelApi { { event: { type: 'ai_indicator.clear', - created_at: new Date(), + created_at: nowNs(), custom: {}, }, }, @@ -1488,7 +1490,7 @@ export class Channel extends ChannelApi { { event: { type: 'ai_indicator.stop', - created_at: new Date(), + created_at: nowNs(), custom: {}, }, }, @@ -1522,7 +1524,7 @@ export class Channel extends ChannelApi { type: 'typing.stop', parent_id: parentId, ...(options || {}), - created_at: new Date(), + created_at: nowNs(), custom: {}, }, }, @@ -1708,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', @@ -1876,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(); @@ -1913,10 +1916,11 @@ 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) { + countUnread(lastRead?: number | null) { if (!lastRead) return this.state.unreadCount; let count = 0; const latestMessages = this.messagePaginator.headItems; @@ -2483,31 +2487,23 @@ export class Channel extends ChannelApi { !(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, @@ -2537,21 +2533,26 @@ export class Channel extends ChannelApi { if (event.user?.id && event.created_at) { 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 @@ -2562,7 +2563,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, }; }, @@ -2613,7 +2614,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, @@ -2663,7 +2664,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( @@ -2701,7 +2702,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, }; @@ -2738,16 +2739,16 @@ export class Channel extends ChannelApi { break; case 'channel.truncated': if (event.channel?.truncated_at) { - const truncatedAtDate = new Date(event.channel.truncated_at); + const truncatedAt = event.channel.truncated_at; - this._setOwnUnreadCount(this.countUnread(truncatedAtDate)); + 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(); @@ -2817,7 +2818,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, @@ -3029,7 +3030,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, @@ -3043,11 +3044,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 6da7c5caf..6148a3822 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 fa12dec16..32c2ac6dc 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) 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(), }); } } @@ -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 ? mute.expires > nowNs() : true, + createdAt: mute.created_at ?? nowNs(), + expiresAt: mute.expires ?? null, }; break; } diff --git a/src/connection.ts b/src/connection.ts index 19a1cd444..6752ee02f 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/entityStore/applyReactionLocally.ts b/src/entityStore/applyReactionLocally.ts index bdfca2c72..c6c104fca 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 849fada7e..2a27d6088 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 }; } @@ -646,8 +615,6 @@ export class ChatApi { requestOptions, ); - decoders['UpdateChannelResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -673,8 +640,6 @@ export class ChatApi { requestOptions, ); - decoders['Response']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -700,8 +665,6 @@ export class ChatApi { requestOptions, ); - decoders['GetDraftResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -729,8 +692,6 @@ export class ChatApi { requestOptions, ); - decoders['CreateDraftResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -756,8 +717,6 @@ export class ChatApi { requestOptions, ); - decoders['EventResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -783,8 +742,6 @@ export class ChatApi { requestOptions, ); - decoders['Response']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -813,8 +770,6 @@ export class ChatApi { requestOptions, ); - decoders['UploadChannelFileResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -842,8 +797,6 @@ export class ChatApi { requestOptions, ); - decoders['HideChannelResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -869,8 +822,6 @@ export class ChatApi { requestOptions, ); - decoders['Response']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -900,8 +851,6 @@ export class ChatApi { requestOptions, ); - decoders['UploadChannelResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -930,8 +879,6 @@ export class ChatApi { requestOptions, ); - decoders['UpdateMemberPartialResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -964,8 +911,6 @@ export class ChatApi { requestOptions, ); - decoders['SendMessageResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -999,8 +944,6 @@ export class ChatApi { requestOptions, ); - decoders['GetManyMessagesResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -1058,8 +1001,6 @@ export class ChatApi { requestOptions, ); - decoders['GetPinnedMessagesResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -1103,8 +1044,6 @@ export class ChatApi { requestOptions, ); - decoders['ChannelStateResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -1131,8 +1070,6 @@ export class ChatApi { requestOptions, ); - decoders['MarkReadResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -1158,8 +1095,6 @@ export class ChatApi { requestOptions, ); - decoders['ShowChannelResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -1190,8 +1125,6 @@ export class ChatApi { requestOptions, ); - decoders['Response']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -1223,8 +1156,6 @@ export class ChatApi { requestOptions, ); - decoders['TruncateChannelResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -1252,8 +1183,6 @@ export class ChatApi { requestOptions, ); - decoders['Response']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -1282,8 +1211,6 @@ export class ChatApi { requestOptions, ); - decoders['QueryDraftsResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -1305,8 +1232,6 @@ export class ChatApi { requestOptions, ); - decoders['MembersResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -1334,8 +1259,6 @@ export class ChatApi { requestOptions, ); - decoders['DeleteMessageResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -1357,8 +1280,6 @@ export class ChatApi { requestOptions, ); - decoders['GetMessageResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -1387,8 +1308,6 @@ export class ChatApi { requestOptions, ); - decoders['UpdateMessageResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -1418,8 +1337,6 @@ export class ChatApi { requestOptions, ); - decoders['UpdateMessagePartialResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -1446,8 +1363,6 @@ export class ChatApi { requestOptions, ); - decoders['MessageActionResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -1476,8 +1391,6 @@ export class ChatApi { requestOptions, ); - decoders['SendReactionResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -1502,8 +1415,6 @@ export class ChatApi { requestOptions, ); - decoders['DeleteReactionResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -1531,8 +1442,6 @@ export class ChatApi { requestOptions, ); - decoders['GetReactionsResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -1563,8 +1472,6 @@ export class ChatApi { requestOptions, ); - decoders['QueryReactionsResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -1591,8 +1498,6 @@ export class ChatApi { requestOptions, ); - decoders['MessageActionResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -1618,8 +1523,6 @@ export class ChatApi { requestOptions, ); - decoders['PollVoteResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -1643,8 +1546,6 @@ export class ChatApi { requestOptions, ); - decoders['PollVoteResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -1668,8 +1569,6 @@ export class ChatApi { requestOptions, ); - decoders['DeleteReminderResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -1696,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, }; @@ -1713,7 +1610,7 @@ export class ChatApi { }; const response = await this.apiClient.sendRequest< - StreamResponse + StreamResponse >( 'POST', '/api/v2/chat/messages/{message_id}/reminders', @@ -1724,8 +1621,6 @@ export class ChatApi { requestOptions, ); - decoders['ReminderResponseData']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -1767,8 +1662,6 @@ export class ChatApi { requestOptions, ); - decoders['GetRepliesResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -1792,8 +1685,6 @@ export class ChatApi { requestOptions, ); - decoders['QueryMessageFlagsResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -1819,8 +1710,6 @@ export class ChatApi { requestOptions, ); - decoders['MuteChannelResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -1844,8 +1733,6 @@ export class ChatApi { requestOptions, ); - decoders['UnmuteResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -1869,8 +1756,6 @@ export class ChatApi { requestOptions, ); - decoders['QueryBannedUsersResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -1894,8 +1779,6 @@ export class ChatApi { requestOptions, ); - decoders['QueryFutureChannelBansResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -1924,8 +1807,6 @@ export class ChatApi { requestOptions, ); - decoders['QueryRemindersResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -1947,8 +1828,6 @@ export class ChatApi { requestOptions, ); - decoders['SearchResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -1980,8 +1859,6 @@ export class ChatApi { requestOptions, ); - decoders['SyncResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -2016,8 +1893,6 @@ export class ChatApi { requestOptions, ); - decoders['QueryThreadsResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -2053,8 +1928,6 @@ export class ChatApi { requestOptions, ); - decoders['GetThreadResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -2082,8 +1955,6 @@ export class ChatApi { requestOptions, ); - decoders['UpdateThreadPartialResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -2102,8 +1973,6 @@ export class ChatApi { requestOptions, ); - decoders['WrappedUnreadCountsResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -2125,8 +1994,6 @@ export class ChatApi { requestOptions, ); - decoders['Response']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -2145,8 +2012,6 @@ export class ChatApi { requestOptions, ); - decoders['ListDevicesResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -2173,8 +2038,6 @@ export class ChatApi { requestOptions, ); - decoders['Response']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -2199,8 +2062,6 @@ export class ChatApi { requestOptions, ); - decoders['CreateGuestResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -2223,8 +2084,6 @@ export class ChatApi { requestOptions, ); - decoders['{}']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -2246,8 +2105,6 @@ export class ChatApi { requestOptions, ); - decoders['GetOGResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -2280,8 +2137,6 @@ export class ChatApi { requestOptions, ); - decoders['PollResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -2314,8 +2169,6 @@ export class ChatApi { requestOptions, ); - decoders['PollResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -2342,8 +2195,6 @@ export class ChatApi { requestOptions, ); - decoders['QueryPollsResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -2365,8 +2216,6 @@ export class ChatApi { requestOptions, ); - decoders['Response']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -2388,8 +2237,6 @@ export class ChatApi { requestOptions, ); - decoders['PollResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -2415,8 +2262,6 @@ export class ChatApi { requestOptions, ); - decoders['PollResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -2442,8 +2287,6 @@ export class ChatApi { requestOptions, ); - decoders['PollOptionResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -2470,8 +2313,6 @@ export class ChatApi { requestOptions, ); - decoders['PollOptionResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -2494,8 +2335,6 @@ export class ChatApi { requestOptions, ); - decoders['Response']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -2518,8 +2357,6 @@ export class ChatApi { requestOptions, ); - decoders['PollOptionResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -2548,8 +2385,6 @@ export class ChatApi { requestOptions, ); - decoders['PollVotesResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -2574,8 +2409,6 @@ export class ChatApi { requestOptions, ); - decoders['UpsertPushPreferencesResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -2609,8 +2442,6 @@ export class ChatApi { requestOptions, ); - decoders['SearchRolesResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -2632,8 +2463,6 @@ export class ChatApi { requestOptions, ); - decoders['Response']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -2657,8 +2486,6 @@ export class ChatApi { requestOptions, ); - decoders['FileUploadResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -2680,8 +2507,6 @@ export class ChatApi { requestOptions, ); - decoders['Response']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -2708,8 +2533,6 @@ export class ChatApi { requestOptions, ); - decoders['ImageUploadResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -2741,8 +2564,6 @@ export class ChatApi { requestOptions, ); - decoders['ListUserGroupsResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -2771,8 +2592,6 @@ export class ChatApi { requestOptions, ); - decoders['CreateUserGroupResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -2806,8 +2625,6 @@ export class ChatApi { requestOptions, ); - decoders['SearchUserGroupsResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -2832,8 +2649,6 @@ export class ChatApi { requestOptions, ); - decoders['Response']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -2860,8 +2675,6 @@ export class ChatApi { requestOptions, ); - decoders['GetUserGroupResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -2890,8 +2703,6 @@ export class ChatApi { requestOptions, ); - decoders['UpdateUserGroupResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -2920,8 +2731,6 @@ export class ChatApi { requestOptions, ); - decoders['AddUserGroupMembersResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -2949,8 +2758,6 @@ export class ChatApi { requestOptions, ); - decoders['RemoveUserGroupMembersResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -2972,8 +2779,6 @@ export class ChatApi { requestOptions, ); - decoders['QueryUsersResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -2998,8 +2803,6 @@ export class ChatApi { requestOptions, ); - decoders['UpdateUsersResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -3024,8 +2827,6 @@ export class ChatApi { requestOptions, ); - decoders['UpdateUsersResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -3044,8 +2845,6 @@ export class ChatApi { requestOptions, ); - decoders['GetBlockedUsersResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -3068,8 +2867,6 @@ export class ChatApi { requestOptions, ); - decoders['BlockUsersResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -3088,8 +2885,6 @@ export class ChatApi { requestOptions, ); - decoders['SharedLocationsResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -3117,8 +2912,6 @@ export class ChatApi { requestOptions, ); - decoders['SharedLocationResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -3143,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 5520cc009..000000000 --- a/src/gen/model-decoders/decoders.ts +++ /dev/null @@ -1,33 +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; -}; 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 c0dc4826a..000000000 --- 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 c308cf750..1b3e37515 100644 --- a/src/gen/models/index.ts +++ b/src/gen/models/index.ts @@ -3086,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 diff --git a/src/gen/moderation/ModerationApi.ts b/src/gen/moderation/ModerationApi.ts index d6b4bb36b..67279d175 100644 --- a/src/gen/moderation/ModerationApi.ts +++ b/src/gen/moderation/ModerationApi.ts @@ -41,7 +41,6 @@ import type { UpsertConfigRequest, UpsertConfigResponse, } from '../models'; -import { decoders } from '../model-decoders/decoders'; export class ModerationApi { constructor(public readonly apiClient: ApiClient) {} @@ -74,8 +73,6 @@ export class ModerationApi { requestOptions, ); - decoders['GetActionConfigResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -107,8 +104,6 @@ export class ModerationApi { requestOptions, ); - decoders['UpsertActionConfigResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -133,8 +128,6 @@ export class ModerationApi { requestOptions, ); - decoders['BulkUpsertActionConfigResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -159,8 +152,6 @@ export class ModerationApi { requestOptions, ); - decoders['BulkDeleteActionConfigResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -184,8 +175,6 @@ export class ModerationApi { requestOptions, ); - decoders['DeleteActionConfigResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -212,8 +201,6 @@ export class ModerationApi { requestOptions, ); - decoders['AppealResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -235,8 +222,6 @@ export class ModerationApi { requestOptions, ); - decoders['GetAppealResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -265,8 +250,6 @@ export class ModerationApi { requestOptions, ); - decoders['QueryAppealsResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -297,8 +280,6 @@ export class ModerationApi { requestOptions, ); - decoders['BulkActionAppealsResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -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,8 +670,6 @@ export class ModerationApi { requestOptions, ); - decoders['SubmitActionResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -740,8 +693,6 @@ export class ModerationApi { requestOptions, ); - decoders['UnbanResponse']?.(response.body); - return { ...response.body, metadata: response.metadata }; } @@ -764,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 2e5aee6d2..7de841318 100644 --- a/src/index.ts +++ b/src/index.ts @@ -107,6 +107,15 @@ export { formatMessage, } from './utils'; export { FixedSizeQueueCache } from './utils/FixedSizeQueueCache'; +export { + convertTimestampToDate, + dateToNs, + msToNs, + nowNs, + NS_PER_MS, + nsToDate, + nsToMs, +} from './utils/time'; export * from './ChannelManager'; export * from './ConnectionRecoveryManager'; export * from './EventHandlerPipeline'; diff --git a/src/messageComposer/messageComposer.ts b/src/messageComposer/messageComposer.ts index 934bba5eb..b9cf0c089 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: { @@ -486,7 +492,7 @@ 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 < 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({ @@ -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 aaf15be76..5b31195ff 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 { nowNs } from '../../../utils/time'; import type { MessageComposerMiddlewareState, MessageCompositionMiddleware, @@ -19,7 +20,7 @@ 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(); return next({ ...state, diff --git a/src/messageDelivery/MessageDeliveryReporter.ts b/src/messageDelivery/MessageDeliveryReporter.ts index 607eb21eb..bfdd73b34 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 efb61b4c7..8bb0c4768 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; 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,10 +271,8 @@ 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 lastReadTimestamp = r.last_read ?? null; + const lastDeliveredTimestamp = r.last_delivered_at ?? null; const lastReadRef = lastReadTimestamp ? (this.locateMessage(lastReadTimestamp) ?? MIN_REF) : MIN_REF; @@ -311,13 +306,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 +342,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,13 +392,14 @@ 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 ?? '' } + ? { timestamp: lastReadAt, msgId: lastReadMessageId ?? '' } : { ...MIN_REF }; // If no change, exit early. @@ -620,24 +618,22 @@ 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 @@ -661,17 +657,14 @@ 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; + if (!Number.isFinite(userReadState.last_read)) 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 2b8f9fa16..f6326ad14 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 8f91869ab..1a9ff852e 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 1cd3dfe04..7aae108c4 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'; @@ -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, @@ -1152,7 +1153,7 @@ export abstract class AbstractOfflineDB implements OfflineDBApi { let unreadCount = 0; if (truncated_at) { - unreadCount = activeChannel.countUnread(new Date(truncated_at)); + unreadCount = activeChannel.countUnread(truncated_at); } const upsertReadQueries = await this.upsertReads({ diff --git a/src/offline-support/offline_sync_manager.ts b/src/offline-support/offline_sync_manager.ts index 644429ba5..a87793d53 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 b160d4d56..b40ab5601 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 f581e807f..26991a888 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 1a38ed66b..09302e56d 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 61291d9c2..6d6e13bc0 100644 --- a/src/pagination/paginators/ChannelPaginator.ts +++ b/src/pagination/paginators/ChannelPaginator.ts @@ -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); diff --git a/src/pagination/paginators/MessageIntervalPaginator.ts b/src/pagination/paginators/MessageIntervalPaginator.ts index ee622b030..1601a03b4 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 fe044f7b7..a3a131473 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 || !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 { @@ -437,7 +434,8 @@ export class MessagePaginator extends MessageIntervalPaginator { 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(lastReadAt), limit: options?.pageSize, }, updateState: false, diff --git a/src/pagination/paginators/UserGroupPaginator.ts b/src/pagination/paginators/UserGroupPaginator.ts index 502f4fe5f..f1e600734 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 { nsToDate } from '../../utils/time'; import { StoreBackedItemIndex } from '../../entityStore/StoreBackedItemIndex'; type UserGroupListCursor = { @@ -87,7 +88,9 @@ 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. + created_at_gt: nsToDate(lastItem.created_at).toISOString(), id_gt: lastItem.id, } satisfies UserGroupListCursor); }; diff --git a/src/pagination/utility.normalization.ts b/src/pagination/utility.normalization.ts index 85bf29061..a3f4f1f87 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 f1132d292..c87f3de06 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 669e02bb3..ce3b42c3a 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 2bce1d38a..9148873b7 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 50ae7bd8c..538584202 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 c4fddcb2a..d4f35744d 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; @@ -159,9 +164,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 +180,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 +198,7 @@ export class Thread extends WithSubscriptions { } const formattedParentMessage = formatMessage(parentMessage); - const createdAt = parentMessage.created_at - ? new Date(parentMessage.created_at) - : new Date(); + const createdAt = parentMessage.created_at ?? nowNs(); this.state = new StateStore({ active: false, @@ -210,7 +213,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 +667,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; @@ -722,10 +725,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: event.last_read_at ?? createdAt, user, firstUnreadMessageId: event.first_unread_message_id, unreadMessageCount: event.unread_messages ?? 0, @@ -767,7 +767,7 @@ export class Thread extends WithSubscriptions { // in that thread nextUserRead = { ...nextUserRead, - lastReadAt: event.created_at ? new Date(event.created_at) : new Date(), + lastReadAt: event.created_at ?? nowNs(), user: event.user, unreadMessageCount: 0, }; @@ -802,7 +802,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 +926,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 +1159,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 +1179,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 +1190,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 9bb48f33b..0ac88cb84 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 f8f191454..e6d84bf6b 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 } 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(), }; }; @@ -533,7 +537,7 @@ export const findIndexInSortedArray = ({ * * @example * ```ts - * selectValueToCompare: (message) => message.created_at.getTime() + * selectValueToCompare: (message) => message.created_at * ``` */ selectValueToCompare?: (arrayElement: T) => L | T; @@ -749,8 +753,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 000000000..cbc790111 --- /dev/null +++ b/src/utils/time.ts @@ -0,0 +1,72 @@ +/** + * 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: + * + * - `new Date(ns)` is **out of range**. `Date` tops out around 8.64e15 ms while a current + * nanosecond timestamp is ~1.79e18, so the constructor yields `Invalid Date` and + * `.toISOString()` on it throws `RangeError`. Neither is a type error, so nothing warns you. + * - Date libraries read a bare number as milliseconds. `dayjs(ns)` renders a date roughly 50,000 + * years out, silently. + * + * 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 `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/unit/CooldownTimer.test.ts b/test/unit/CooldownTimer.test.ts index 8526ac293..52f354f33 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. @@ -77,7 +78,9 @@ describe('CooldownTimer', () => { 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 () => { diff --git a/test/unit/MessageComposer/messageComposer.test.ts b/test/unit/MessageComposer/messageComposer.test.ts index 29e5a5cb6..f21b2a094 100644 --- a/test/unit/MessageComposer/messageComposer.test.ts +++ b/test/unit/MessageComposer/messageComposer.test.ts @@ -21,6 +21,7 @@ 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'; const generateUuidV4Output = 'test-uuid'; // Mock dependencies @@ -48,10 +49,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 +70,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 +81,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 +419,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 +582,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 +688,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 +845,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 +929,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 +1326,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 +1347,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 +1398,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 +1414,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 +1627,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 +1644,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, @@ -1677,8 +1676,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, @@ -1694,7 +1693,7 @@ describe('MessageComposer', () => { return { message: generateMsg({ id: messageId, - updated_at: olderServerTime.toISOString(), + updated_at: olderServerTime, }), }; }, @@ -1710,8 +1709,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, @@ -1727,7 +1726,7 @@ describe('MessageComposer', () => { return { message: generateMsg({ id: messageId, - updated_at: olderServerTime.toISOString(), + updated_at: olderServerTime, }), }; }, @@ -1749,9 +1748,7 @@ describe('MessageComposer', () => { updated_at: 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 +1757,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 () => { diff --git a/test/unit/MessageComposer/middleware/messageComposer/sharedLocation.test.ts b/test/unit/MessageComposer/middleware/messageComposer/sharedLocation.test.ts index 90f3abbc0..77a306e7d 100644 --- a/test/unit/MessageComposer/middleware/messageComposer/sharedLocation.test.ts +++ b/test/unit/MessageComposer/middleware/messageComposer/sharedLocation.test.ts @@ -65,10 +65,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, }, diff --git a/test/unit/channel.test.js b/test/unit/channel.test.js index 752e9844f..96f9da7c4 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`, @@ -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'); @@ -707,7 +709,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 () { @@ -753,14 +755,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 () { @@ -1321,7 +1324,7 @@ describe('Channel _handleChannelEvent', function () { type: 'channel.truncated', user: { id: 'id' }, channel: { - truncated_at: new Date().toISOString(), + truncated_at: convertDateToTimestamp(), }, }); @@ -1370,7 +1373,7 @@ describe('Channel _handleChannelEvent', function () { type: 'channel.truncated', user: { id: 'id' }, channel: { - truncated_at: new Date().toISOString(), + truncated_at: convertDateToTimestamp(), }, }); @@ -1416,7 +1419,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']); @@ -1487,7 +1490,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 +1498,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 () { @@ -1689,7 +1692,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 +1713,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 +1747,7 @@ describe('Channel _handleChannelEvent', function () { channel_id: channel.id, user: bannedUser, hard_delete: true, - created_at: deletedAt.toISOString(), + created_at: deletedAt, }); expect( @@ -1756,9 +1755,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). @@ -1844,25 +1841,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 +1873,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 +1891,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 +1924,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 +1945,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 +1972,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, @@ -2053,7 +2044,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 +2052,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, @@ -2090,7 +2081,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 +2093,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 +2119,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 +2137,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 +2164,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 +2173,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, ); @@ -3182,8 +3169,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 +3183,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 +3207,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 +3226,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 +3245,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 +3274,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'), ); }); }); diff --git a/test/unit/channel_state.test.js b/test/unit/channel_state.test.js index bb5c3b02e..7ae1efe34 100644 --- a/test/unit/channel_state.test.js +++ b/test/unit/channel_state.test.js @@ -6,6 +6,7 @@ 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'; const toISOString = (timestampMs) => new Date(timestampMs).toISOString(); @@ -19,13 +20,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 +38,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; diff --git a/test/unit/connection.test.js b/test/unit/connection.test.js index bc5d039cb..07da0c810 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/MessageReceiptsTracker.test.ts b/test/unit/messageDelivery/MessageReceiptsTracker.test.ts index e338b25ad..1687d26cf 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({ @@ -758,11 +755,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 +774,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 +785,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', }, }, diff --git a/test/unit/messageOperations/MessageOperations.test.ts b/test/unit/messageOperations/MessageOperations.test.ts index bd861ead2..942b7c2e6 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/optimisticRouting.test.ts b/test/unit/messageOperations/optimisticRouting.test.ts index e3066c086..5021fa42d 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 87771c928..a2a75ced5 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; @@ -1146,10 +1148,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 +1159,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 +1607,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 +1678,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 +1786,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 +1823,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, }; @@ -3249,12 +3251,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 +3272,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 +3282,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 3dc250426..2e0504acb 100644 --- a/test/unit/pagination/UserGroupPaginator.test.ts +++ b/test/unit/pagination/UserGroupPaginator.test.ts @@ -3,13 +3,15 @@ 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 { nsToDate } 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'), + created_at: convertDateToTimestamp('2026-01-01T00:00:00.000Z'), updated_at: new Date('2026-01-01T00:00:00.000Z'), ...overrides, }); @@ -34,12 +36,12 @@ 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'), + created_at: convertDateToTimestamp('2026-01-02T00:00:00.000Z'), updated_at: new Date('2026-01-02T00:00:00.000Z'), }), ]; @@ -47,7 +49,7 @@ describe('UserGroupPaginator', () => { createUserGroup({ id: 'group-3', name: 'QA Support', - created_at: new Date('2026-01-03T00:00:00.000Z'), + created_at: convertDateToTimestamp('2026-01-03T00:00:00.000Z'), updated_at: 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: nsToDate(firstPage[1].created_at).toISOString(), 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: nsToDate(firstPage[1].created_at).toISOString(), id_gt: firstPage[1].id, }); expect(paginator.items).toEqual([...firstPage, ...secondPage]); diff --git a/test/unit/pagination/paginators/ChannelPaginator.test.ts b/test/unit/pagination/paginators/ChannelPaginator.test.ts index ad30873dd..802b2a6a2 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); }); @@ -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', () => { @@ -308,12 +309,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', () => { diff --git a/test/unit/pagination/paginators/MessagePaginator.test.ts b/test/unit/pagination/paginators/MessagePaginator.test.ts index 979a08ffd..7e4c06435 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,8 +64,14 @@ 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); @@ -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', @@ -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'), + }), ], }); @@ -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, @@ -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({ @@ -683,7 +747,7 @@ describe('MessagePaginator', () => { }); 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(), @@ -720,7 +784,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 +803,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(), @@ -794,7 +859,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(), @@ -847,7 +912,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(), @@ -1547,7 +1612,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 +1632,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 +1652,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 +1674,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 +1698,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 +1724,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 +1748,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 +1766,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(); @@ -3374,7 +3453,7 @@ describe('MessagePaginator', () => { }); }; - const at = (iso: string) => new Date(iso).getTime(); + const at = (iso: string) => convertDateToTimestamp(iso); beforeEach(() => { skipSystemMessages = false; @@ -3394,10 +3473,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 +3487,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 +3496,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 +3532,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', () => { @@ -3503,7 +3603,7 @@ describe('MessagePaginator', () => { created_at: '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', () => { @@ -3520,7 +3620,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', () => { @@ -3534,18 +3634,18 @@ describe('MessagePaginator', () => { ); // 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( @@ -3555,7 +3655,7 @@ describe('MessagePaginator', () => { created_at: '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', () => { @@ -3571,14 +3671,14 @@ describe('MessagePaginator', () => { 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 +3693,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(); diff --git a/test/unit/pagination/paginators/UserGroupPaginator.test.ts b/test/unit/pagination/paginators/UserGroupPaginator.test.ts index bc263f232..8d58c8ff9 100644 --- a/test/unit/pagination/paginators/UserGroupPaginator.test.ts +++ b/test/unit/pagination/paginators/UserGroupPaginator.test.ts @@ -3,11 +3,12 @@ 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), + created_at: convertDateToTimestamp(createdAt), updated_at: new Date(createdAt), }); diff --git a/test/unit/reminders/Reminder.test.ts b/test/unit/reminders/Reminder.test.ts index 62b14f97b..99fd2e7e1 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 2da41191e..859d01545 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/test-utils/generateChannel.ts b/test/unit/test-utils/generateChannel.ts index f7f2fce67..566fc3d2d 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 e22c02695..246f72e6d 100644 --- a/test/unit/test-utils/generateMessage.ts +++ b/test/unit/test-utils/generateMessage.ts @@ -1,11 +1,21 @@ import { generateUUIDv4 as uuidv4 } from '../../../src/utils'; import type { MessageResponse, UserResponse } from '../../../src'; +import { convertDateToTimestamp } from './time'; + +/** The message fields the API sends as unix-nanosecond numbers. */ +const TIMESTAMP_FIELDS = [ + 'created_at', + 'updated_at', + 'deleted_at', + 'pinned_at', + 'message_text_updated_at', +] as const; 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 +35,17 @@ export const generateMsg = ( silent: false, status: 'received', ...msg, - }; + } as MessageResponse & Record; + + // Tests read far better overriding a timestamp with a date literal, but the wire carries numbers — + // and a fixture that hands the SDK a `Date` cannot catch the bugs that unit exists to prevent. + // Normalize every timestamp override here, so no individual test has to. + for (const field of TIMESTAMP_FIELDS) { + const value = message[field]; + if (value != null && typeof value !== 'number') { + message[field] = convertDateToTimestamp(value as Date | number | string); + } + } + + return message; }; diff --git a/test/unit/test-utils/generateMessageDraft.ts b/test/unit/test-utils/generateMessageDraft.ts index 57151ea12..c85214023 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 92cdc7ee1..05cd2c04d 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 108a16bad..918cd3cd2 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 bbaaa9c78..f46cf5644 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 554dea504..db6cf21b0 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 000000000..91d188a09 --- /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 c284578cc..358b73862 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', @@ -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'); @@ -941,7 +941,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 +960,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, }, @@ -1027,7 +1027,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, }, @@ -1215,7 +1215,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 +1244,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 +1264,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 +1280,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 +1292,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 +1306,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 +1322,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 +1334,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 +1352,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 +1360,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 +1404,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 +1430,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 +1479,7 @@ describe('Threads 2.0', () => { read: [ { user: { id: TEST_USER_ID }, - last_read: new Date().toISOString(), + last_read: nowNs(), unread_messages: 0, }, ], @@ -1515,7 +1511,7 @@ describe('Threads 2.0', () => { read: [ { user: { id: TEST_USER_ID }, - last_read: new Date().toISOString(), + last_read: nowNs(), unread_messages: 0, }, ], @@ -1542,7 +1538,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 +1566,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, }, @@ -1628,7 +1624,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(); @@ -1678,14 +1674,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', () => { @@ -1778,7 +1770,7 @@ describe('Threads 2.0', () => { type: 'like', user_id: 'other-user', message_id: messageId, - created_at: new Date().toISOString(), + created_at: nowNs(), }, }); @@ -1815,7 +1807,7 @@ describe('Threads 2.0', () => { client.dispatchEvent({ type: eventType, user: { id: bannedUserId, deleted_at: new Date().toISOString() }, - created_at: new Date().toISOString(), + created_at: nowNs(), }); expect(thread.messagePaginator.getItem(replyId)?.type).to.equal('deleted'); @@ -1839,7 +1831,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 +1855,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 +1880,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 +1904,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/wireTimestamps.test.ts b/test/unit/wireTimestamps.test.ts new file mode 100644 index 000000000..c80af1b15 --- /dev/null +++ b/test/unit/wireTimestamps.test.ts @@ -0,0 +1,244 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { getClientWithUser } from './test-utils/getClient'; +import { generateMsg } from './test-utils/generateMessage'; +import { formatMessage } from '../../src'; +import type { Channel, StreamChat } from '../../src'; +import { getMessageCreatedAtTimestamp } from '../../src/pagination/paginators/MessageIntervalPaginator'; +import { dateToNs, msToNs, nowNs, nsToDate, nsToMs } from '../../src/utils/time'; + +/** + * Regressions for the unit itself, not for any one feature. + * + * Every server-sent timestamp is a unix-**nanosecond** number. Almost nothing about getting that + * wrong is a type error — `new Date(number)` compiles, `number - number` compiles, and a `Date` + * assigned into a numeric field only fails where the field is actually typed. So the failures this + * change was made to prevent are all silent, and this suite is the only mechanical guard on them. + * + * Every case below uses a REALISTIC nanosecond value rather than something built from + * `Date.now()`, because that is what makes a regression visible: `new Date(1.79e18)` is out of + * Date's range, so a stray conversion produces an `Invalid Date` or a year ~58,000 rather than a + * plausible-looking timestamp that quietly passes. + */ + +/** A real on-device value, from the bug report that prompted the ns work. */ +const NANOS = 1786219962651957000; + +const seedLatestWindow = ( + channel: Channel, + ...messages: ReturnType[] +) => + channel.messagePaginator.ingestPage({ + page: messages.map((m) => formatMessage(m)), + isHead: true, + isTail: true, + setActive: true, + }); + +describe('wire timestamps (unix nanoseconds)', () => { + let client: StreamChat; + let channel: Channel; + + beforeEach(() => { + client = getClientWithUser({ id: 'own-user' }); + channel = client.channel('messaging', 'wire-timestamps'); + }); + + it('is out of Date range, which is what makes every stray conversion silent', () => { + // The premise of the whole suite. If this ever stops holding, the guards below stop guarding. + expect(Number.isNaN(new Date(NANOS).getTime())).toBe(true); + expect(nsToMs(NANOS)).toBe(Math.floor(NANOS / 1e6)); + expect(new Date(nsToMs(NANOS)).getFullYear()).toBe(2026); + }); + + describe('message ordering', () => { + it('reads created_at as a finite number', () => { + const message = formatMessage(generateMsg({ created_at: NANOS })); + + expect(getMessageCreatedAtTimestamp(message)).toBe(NANOS); + }); + + it('advances lastMessageAt rather than leaving it null', () => { + // The regression: `created_at instanceof Date` was the gate here, so this returned `null` for + // every message and silently disabled channel-list sorting. + seedLatestWindow(channel, generateMsg({ created_at: NANOS })); + + expect(channel.messagePaginator.lastMessageAt).toBe(NANOS); + }); + + it('orders by the wire value, not by a coerced one', () => { + const older = generateMsg({ id: 'older', created_at: NANOS }); + const newer = generateMsg({ id: 'newer', created_at: NANOS + msToNs(60_000) }); + + seedLatestWindow(channel, older, newer); + + expect(channel.messagePaginator.lastMessageAt).toBe(newer.created_at); + expect(channel.messagePaginator.headmostItem?.id).toBe('newer'); + }); + + it('takes the server seed as the floor when it is newer than anything loaded', () => { + const seed = NANOS + msToNs(120_000); + seedLatestWindow(channel, generateMsg({ created_at: NANOS })); + + channel.messagePaginator.seedLastMessageAt(seed); + + expect(channel.messagePaginator.lastMessageAt).toBe(seed); + }); + }); + + describe('unread counting', () => { + it('counts only messages newer than the last-read wire timestamp', () => { + seedLatestWindow( + channel, + generateMsg({ created_at: NANOS - msToNs(1000), user: { id: 'other' } }), + generateMsg({ created_at: NANOS + msToNs(1000), user: { id: 'other' } }), + generateMsg({ created_at: NANOS + msToNs(2000), user: { id: 'other' } }), + ); + + // Comparing against a `Date` here coerced it to milliseconds, so every message looked newer. + expect(channel.countUnread(NANOS)).toBe(2); + }); + }); + + describe('expiry checks against the local clock', () => { + it('reads an elapsed channel mute as expired', () => { + // The regression: comparing a nanosecond `expires` against `Date.now()` made every mute look + // far-future, so an expired mute never lapsed. + client.mutedChannels = [ + { + channel: { cid: channel.cid }, + created_at: nowNs() - msToNs(120_000), + expires: nowNs() - msToNs(60_000), + }, + ] as StreamChat['mutedChannels']; + + expect(client._muteStatus(channel.cid).muted).toBe(false); + }); + + it('reads a mute expiring in the future as active', () => { + client.mutedChannels = [ + { + channel: { cid: channel.cid }, + created_at: nowNs(), + expires: nowNs() + msToNs(60_000), + }, + ] as StreamChat['mutedChannels']; + + expect(client._muteStatus(channel.cid).muted).toBe(true); + }); + }); + + describe('slow mode', () => { + it('reports remaining cooldown from an own message inside the window', () => { + // The regression: the own-message timestamp went through `new Date(ns)`, yielding an Invalid + // Date that was discarded — so the cooldown never showed at all. + channel.data = { ...channel.data, cooldown: 30, own_capabilities: [] }; + seedLatestWindow( + channel, + generateMsg({ created_at: nowNs() - msToNs(10_000), user: { id: 'own-user' } }), + ); + + channel.cooldownTimer.refresh(); + + expect(channel.cooldownTimer.ownLatestMessageTimestamp).toEqual(expect.any(Number)); + expect(channel.cooldownTimer.cooldownRemaining).toBe(20); + }); + + it('reports no cooldown once the window has elapsed', () => { + channel.data = { ...channel.data, cooldown: 30, own_capabilities: [] }; + seedLatestWindow( + channel, + generateMsg({ created_at: nowNs() - msToNs(60_000), user: { id: 'own-user' } }), + ); + + channel.cooldownTimer.refresh(); + + expect(channel.cooldownTimer.cooldownRemaining).toBe(0); + }); + }); + + describe('durations stay milliseconds', () => { + it('derives a reminder countdown in milliseconds from a wire deadline', async () => { + const { Reminder } = await import('../../src'); + const remindAt = nowNs() + msToNs(60_000); + + const reminder = new Reminder({ + data: { + channel_cid: channel.cid, + created_at: nowNs(), + message_id: 'm1', + remind_at: remindAt, + updated_at: nowNs(), + user_id: 'own-user', + } as never, + }); + + // Milliseconds, not nanoseconds — this is what `setTimeout` and every "time left" reader use. + expect(reminder.timeLeftMs).toBeGreaterThan(55_000); + expect(reminder.timeLeftMs).toBeLessThanOrEqual(60_000); + reminder.clearTimer(); + }); + + it('treats the epoch as a real deadline rather than as "unset"', async () => { + const { Reminder } = await import('../../src'); + + // `0` is a legitimate timestamp but it is falsy, so the truthiness checks these guards + // replaced read the epoch as "no reminder set" and left `timeLeftMs` null. + const reminder = new Reminder({ + data: { + channel_cid: channel.cid, + created_at: nowNs(), + message_id: 'm-epoch', + remind_at: 0, + updated_at: nowNs(), + user_id: 'own-user', + } as never, + }); + + expect(reminder.remindAt).toBe(0); + expect(reminder.timeLeftMs).not.toBeNull(); + // Long overdue, since the deadline is 1970. + expect(reminder.timeLeftMs!).toBeLessThan(0); + reminder.clearTimer(); + }); + }); + + describe('request boundaries still take Date', () => { + it('sends a Date, built from the read state, for a created_at_around jump', async () => { + // Request date fields are still typed `Date`, so a wire value crossing back out has to be + // converted. Passing the raw number produced an unparseable `created_at_around`. + const lastReadAt = NANOS; + channel.state.read['own-user'] = { + last_read: lastReadAt, + unread_messages: 3, + user: { id: 'own-user' } as never, + }; + const executeQuery = vi + .spyOn(channel.messagePaginator, 'executeQuery') + .mockResolvedValue({ + stateCandidate: { items: [] }, + targetInterval: null, + } as never); + vi.spyOn(channel.messagePaginator, 'jumpToMessage').mockResolvedValue( + true as never, + ); + + await channel.messagePaginator.jumpToTheFirstUnreadMessage({ pageSize: 25 }); + + const sent = executeQuery.mock.calls[0][0] as { + queryShape: { created_at_around: Date }; + }; + expect(sent.queryShape.created_at_around).toBeInstanceOf(Date); + expect(sent.queryShape.created_at_around.toISOString()).toBe( + new Date(nsToMs(lastReadAt)).toISOString(), + ); + }); + + it('round-trips a wire timestamp through Date without losing the millisecond', () => { + const asDate = nsToDate(NANOS); + + expect(dateToNs(asDate)).toBe(msToNs(nsToMs(NANOS))); + expect(asDate.toISOString()).toBe(new Date(nsToMs(NANOS)).toISOString()); + }); + }); +}); From f66437df68447217b383bb4e69aa9255c1b58608 Mon Sep 17 00:00:00 2001 From: Zita Szupera Date: Tue, 1 Sep 2026 09:42:29 +0200 Subject: [PATCH 05/11] chore: update docs, remove unnecessary test --- test/unit/wireTimestamps.test.ts | 244 ---------------------- v9-to-v10-migration-guide-methods.md | 2 +- v9-to-v10-migration-guide-other.md | 12 +- v9-to-v10-migration-guide-type-renames.md | 4 +- 4 files changed, 9 insertions(+), 253 deletions(-) delete mode 100644 test/unit/wireTimestamps.test.ts diff --git a/test/unit/wireTimestamps.test.ts b/test/unit/wireTimestamps.test.ts deleted file mode 100644 index c80af1b15..000000000 --- a/test/unit/wireTimestamps.test.ts +++ /dev/null @@ -1,244 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -import { getClientWithUser } from './test-utils/getClient'; -import { generateMsg } from './test-utils/generateMessage'; -import { formatMessage } from '../../src'; -import type { Channel, StreamChat } from '../../src'; -import { getMessageCreatedAtTimestamp } from '../../src/pagination/paginators/MessageIntervalPaginator'; -import { dateToNs, msToNs, nowNs, nsToDate, nsToMs } from '../../src/utils/time'; - -/** - * Regressions for the unit itself, not for any one feature. - * - * Every server-sent timestamp is a unix-**nanosecond** number. Almost nothing about getting that - * wrong is a type error — `new Date(number)` compiles, `number - number` compiles, and a `Date` - * assigned into a numeric field only fails where the field is actually typed. So the failures this - * change was made to prevent are all silent, and this suite is the only mechanical guard on them. - * - * Every case below uses a REALISTIC nanosecond value rather than something built from - * `Date.now()`, because that is what makes a regression visible: `new Date(1.79e18)` is out of - * Date's range, so a stray conversion produces an `Invalid Date` or a year ~58,000 rather than a - * plausible-looking timestamp that quietly passes. - */ - -/** A real on-device value, from the bug report that prompted the ns work. */ -const NANOS = 1786219962651957000; - -const seedLatestWindow = ( - channel: Channel, - ...messages: ReturnType[] -) => - channel.messagePaginator.ingestPage({ - page: messages.map((m) => formatMessage(m)), - isHead: true, - isTail: true, - setActive: true, - }); - -describe('wire timestamps (unix nanoseconds)', () => { - let client: StreamChat; - let channel: Channel; - - beforeEach(() => { - client = getClientWithUser({ id: 'own-user' }); - channel = client.channel('messaging', 'wire-timestamps'); - }); - - it('is out of Date range, which is what makes every stray conversion silent', () => { - // The premise of the whole suite. If this ever stops holding, the guards below stop guarding. - expect(Number.isNaN(new Date(NANOS).getTime())).toBe(true); - expect(nsToMs(NANOS)).toBe(Math.floor(NANOS / 1e6)); - expect(new Date(nsToMs(NANOS)).getFullYear()).toBe(2026); - }); - - describe('message ordering', () => { - it('reads created_at as a finite number', () => { - const message = formatMessage(generateMsg({ created_at: NANOS })); - - expect(getMessageCreatedAtTimestamp(message)).toBe(NANOS); - }); - - it('advances lastMessageAt rather than leaving it null', () => { - // The regression: `created_at instanceof Date` was the gate here, so this returned `null` for - // every message and silently disabled channel-list sorting. - seedLatestWindow(channel, generateMsg({ created_at: NANOS })); - - expect(channel.messagePaginator.lastMessageAt).toBe(NANOS); - }); - - it('orders by the wire value, not by a coerced one', () => { - const older = generateMsg({ id: 'older', created_at: NANOS }); - const newer = generateMsg({ id: 'newer', created_at: NANOS + msToNs(60_000) }); - - seedLatestWindow(channel, older, newer); - - expect(channel.messagePaginator.lastMessageAt).toBe(newer.created_at); - expect(channel.messagePaginator.headmostItem?.id).toBe('newer'); - }); - - it('takes the server seed as the floor when it is newer than anything loaded', () => { - const seed = NANOS + msToNs(120_000); - seedLatestWindow(channel, generateMsg({ created_at: NANOS })); - - channel.messagePaginator.seedLastMessageAt(seed); - - expect(channel.messagePaginator.lastMessageAt).toBe(seed); - }); - }); - - describe('unread counting', () => { - it('counts only messages newer than the last-read wire timestamp', () => { - seedLatestWindow( - channel, - generateMsg({ created_at: NANOS - msToNs(1000), user: { id: 'other' } }), - generateMsg({ created_at: NANOS + msToNs(1000), user: { id: 'other' } }), - generateMsg({ created_at: NANOS + msToNs(2000), user: { id: 'other' } }), - ); - - // Comparing against a `Date` here coerced it to milliseconds, so every message looked newer. - expect(channel.countUnread(NANOS)).toBe(2); - }); - }); - - describe('expiry checks against the local clock', () => { - it('reads an elapsed channel mute as expired', () => { - // The regression: comparing a nanosecond `expires` against `Date.now()` made every mute look - // far-future, so an expired mute never lapsed. - client.mutedChannels = [ - { - channel: { cid: channel.cid }, - created_at: nowNs() - msToNs(120_000), - expires: nowNs() - msToNs(60_000), - }, - ] as StreamChat['mutedChannels']; - - expect(client._muteStatus(channel.cid).muted).toBe(false); - }); - - it('reads a mute expiring in the future as active', () => { - client.mutedChannels = [ - { - channel: { cid: channel.cid }, - created_at: nowNs(), - expires: nowNs() + msToNs(60_000), - }, - ] as StreamChat['mutedChannels']; - - expect(client._muteStatus(channel.cid).muted).toBe(true); - }); - }); - - describe('slow mode', () => { - it('reports remaining cooldown from an own message inside the window', () => { - // The regression: the own-message timestamp went through `new Date(ns)`, yielding an Invalid - // Date that was discarded — so the cooldown never showed at all. - channel.data = { ...channel.data, cooldown: 30, own_capabilities: [] }; - seedLatestWindow( - channel, - generateMsg({ created_at: nowNs() - msToNs(10_000), user: { id: 'own-user' } }), - ); - - channel.cooldownTimer.refresh(); - - expect(channel.cooldownTimer.ownLatestMessageTimestamp).toEqual(expect.any(Number)); - expect(channel.cooldownTimer.cooldownRemaining).toBe(20); - }); - - it('reports no cooldown once the window has elapsed', () => { - channel.data = { ...channel.data, cooldown: 30, own_capabilities: [] }; - seedLatestWindow( - channel, - generateMsg({ created_at: nowNs() - msToNs(60_000), user: { id: 'own-user' } }), - ); - - channel.cooldownTimer.refresh(); - - expect(channel.cooldownTimer.cooldownRemaining).toBe(0); - }); - }); - - describe('durations stay milliseconds', () => { - it('derives a reminder countdown in milliseconds from a wire deadline', async () => { - const { Reminder } = await import('../../src'); - const remindAt = nowNs() + msToNs(60_000); - - const reminder = new Reminder({ - data: { - channel_cid: channel.cid, - created_at: nowNs(), - message_id: 'm1', - remind_at: remindAt, - updated_at: nowNs(), - user_id: 'own-user', - } as never, - }); - - // Milliseconds, not nanoseconds — this is what `setTimeout` and every "time left" reader use. - expect(reminder.timeLeftMs).toBeGreaterThan(55_000); - expect(reminder.timeLeftMs).toBeLessThanOrEqual(60_000); - reminder.clearTimer(); - }); - - it('treats the epoch as a real deadline rather than as "unset"', async () => { - const { Reminder } = await import('../../src'); - - // `0` is a legitimate timestamp but it is falsy, so the truthiness checks these guards - // replaced read the epoch as "no reminder set" and left `timeLeftMs` null. - const reminder = new Reminder({ - data: { - channel_cid: channel.cid, - created_at: nowNs(), - message_id: 'm-epoch', - remind_at: 0, - updated_at: nowNs(), - user_id: 'own-user', - } as never, - }); - - expect(reminder.remindAt).toBe(0); - expect(reminder.timeLeftMs).not.toBeNull(); - // Long overdue, since the deadline is 1970. - expect(reminder.timeLeftMs!).toBeLessThan(0); - reminder.clearTimer(); - }); - }); - - describe('request boundaries still take Date', () => { - it('sends a Date, built from the read state, for a created_at_around jump', async () => { - // Request date fields are still typed `Date`, so a wire value crossing back out has to be - // converted. Passing the raw number produced an unparseable `created_at_around`. - const lastReadAt = NANOS; - channel.state.read['own-user'] = { - last_read: lastReadAt, - unread_messages: 3, - user: { id: 'own-user' } as never, - }; - const executeQuery = vi - .spyOn(channel.messagePaginator, 'executeQuery') - .mockResolvedValue({ - stateCandidate: { items: [] }, - targetInterval: null, - } as never); - vi.spyOn(channel.messagePaginator, 'jumpToMessage').mockResolvedValue( - true as never, - ); - - await channel.messagePaginator.jumpToTheFirstUnreadMessage({ pageSize: 25 }); - - const sent = executeQuery.mock.calls[0][0] as { - queryShape: { created_at_around: Date }; - }; - expect(sent.queryShape.created_at_around).toBeInstanceOf(Date); - expect(sent.queryShape.created_at_around.toISOString()).toBe( - new Date(nsToMs(lastReadAt)).toISOString(), - ); - }); - - it('round-trips a wire timestamp through Date without losing the millisecond', () => { - const asDate = nsToDate(NANOS); - - expect(dateToNs(asDate)).toBe(msToNs(nsToMs(NANOS))); - expect(asDate.toISOString()).toBe(new Date(nsToMs(NANOS)).toISOString()); - }); - }); -}); diff --git a/v9-to-v10-migration-guide-methods.md b/v9-to-v10-migration-guide-methods.md index ccedcea79..b22fff7b7 100644 --- a/v9-to-v10-migration-guide-methods.md +++ b/v9-to-v10-migration-guide-methods.md @@ -852,7 +852,7 @@ These now delegate to `channel.updateMemberPartial({ set: { archived: true } })` ```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 }); diff --git a/v9-to-v10-migration-guide-other.md b/v9-to-v10-migration-guide-other.md index 17854f709..8c21717ca 100644 --- a/v9-to-v10-migration-guide-other.md +++ b/v9-to-v10-migration-guide-other.md @@ -30,7 +30,7 @@ - Composer attachments now nest `mime_type` / `file_size` / `duration` under `.custom`; `LocationComposer` preview `end_at` is a `Date` (was ISO string). - 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 +143,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. @@ -709,7 +709,7 @@ ISO-string form is gone — construct a `Date` at the call site. ### `UserGroupPaginator` cursor -The `created_at_gt` cursor field is derived from `lastItem.created_at`, which is now a `Date` (was a string). The paginator internally calls `.toISOString()` on it — read paths (`useNextCursor`) are unchanged, but any custom sub-class or off-path consumer that pulled `.created_at` off the paginator's items must handle the `Date` type. +The `created_at_gt` cursor field is derived from `lastItem.created_at`, which is now a unix-**nanosecond** `number` (was a string). The paginator converts it internally (`nsToDate(lastItem.created_at).toISOString()`) — read paths (`useNextCursor`) are unchanged, but any custom sub-class or off-path consumer that pulled `.created_at` off the paginator's items must handle the number: `new Date(ns)` is out of range, so use `convertTimestampToDate` / `nsToDate`. ### Aliases dropped on `StreamChat` diff --git a/v9-to-v10-migration-guide-type-renames.md b/v9-to-v10-migration-guide-type-renames.md index 207552432..224fe553d 100644 --- a/v9-to-v10-migration-guide-type-renames.md +++ b/v9-to-v10-migration-guide-type-renames.md @@ -182,13 +182,13 @@ Because the hand-written list omitted `latest_hidden_channels`, and a `user.upda | Field | v10 `Device` (removed) | `DeviceResponse` | | --------------------- | --------------------------------------------- | ------------------ | -| `created_at` | `string` | `Date` | +| `created_at` | `string` | `number` | | `push_provider` | `'firebase' \| 'apn' \| 'huawei' \| 'xiaomi'` | `string` | | `user_id` | `string \| undefined` | `string` | | `provider`, `user` | present | **gone** | | `hardware_id`, `voip` | **absent** | present (optional) | -`created_at` is the one that bites: the response decoders have always produced a `Date` here, so the old `string` annotation was wrong. Call sites doing `new Date(device.created_at)` still work; ones doing `device.created_at.slice(...)` were already broken at runtime and now fail to compile. +`created_at` is the one that bites: it is the unix-**nanosecond** number the API sends, so the old `string` annotation was wrong in a way that hid a second problem. `device.created_at.slice(...)` was already broken at runtime and now fails to compile — but so does anything that treats the value as a date: `new Date(device.created_at)` is **out of range** for a nanosecond timestamp and yields an `Invalid Date`. Use `convertTimestampToDate(device.created_at)` (or `nsToDate`), or compare the raw numbers. ### Stale `Omit` keys — two types quietly widened From 345b179ebf4e042f78457c8b9e0c91688ed9ce7e Mon Sep 17 00:00:00 2001 From: Zita Szupera Date: Tue, 1 Sep 2026 16:43:38 +0200 Subject: [PATCH 06/11] fix: review fixes --- CLAUDE.md | 2 +- src/channel.ts | 3 +- src/constants.ts | 5 +- src/index.ts | 1 + src/messageComposer/LocationComposer.ts | 60 +++++++- .../messageComposer/sharedLocation.ts | 8 +- src/messageDelivery/MessageReceiptsTracker.ts | 16 +- src/offline-support/offline_support_api.ts | 14 +- src/pagination/paginators/MessagePaginator.ts | 2 +- src/utils.ts | 47 +++++- src/utils/time.ts | 10 ++ .../MessageComposer/LocationComposer.test.ts | 114 +++++++++++--- .../messageComposer/sharedLocation.test.ts | 65 ++++++++ test/unit/channel.test.js | 20 +++ .../MessageReceiptsTracker.test.ts | 23 +++ .../paginators/MessagePaginator.test.ts | 45 ++++++ test/unit/test-utils/generateMessage.ts | 1 + test/unit/utils.test.ts | 139 ++++++++++++++++++ test/unit/utils/time.test.ts | 43 ++++++ 19 files changed, 563 insertions(+), 55 deletions(-) create mode 100644 test/unit/utils/time.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index fa5920ccf..f6c4be067 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. `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/src/channel.ts b/src/channel.ts index 933424c7a..d78b7392d 100644 --- a/src/channel.ts +++ b/src/channel.ts @@ -1921,7 +1921,8 @@ export class Channel extends ChannelApi { * @returns Unread count. */ countUnread(lastRead?: number | null) { - if (!lastRead) return this.state.unreadCount; + // 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) { diff --git a/src/constants.ts b/src/constants.ts index 15515100c..f7b535094 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/index.ts b/src/index.ts index 7de841318..2e3ac69fd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -115,6 +115,7 @@ export { NS_PER_MS, nsToDate, nsToMs, + nsToRfc3339, } from './utils/time'; export * from './ChannelManager'; export * from './ConnectionRecoveryManager'; diff --git a/src/messageComposer/LocationComposer.ts b/src/messageComposer/LocationComposer.ts index 9d4fdcad6..b1ca05dbd 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/middleware/messageComposer/sharedLocation.ts b/src/messageComposer/middleware/messageComposer/sharedLocation.ts index 5b31195ff..76dd03720 100644 --- a/src/messageComposer/middleware/messageComposer/sharedLocation.ts +++ b/src/messageComposer/middleware/messageComposer/sharedLocation.ts @@ -1,7 +1,7 @@ import type { MiddlewareHandlerParams } from '../../../middleware'; import type { SharedLocationResponseData as Gen_SharedLocationResponseData } from '../../../gen/models'; import type { MessageComposer } from '../../messageComposer'; -import { nowNs } from '../../../utils/time'; +import { dateToNs, nowNs } from '../../../utils/time'; import type { MessageComposerMiddlewareState, MessageCompositionMiddleware, @@ -22,12 +22,16 @@ export const createSharedLocationCompositionMiddleware = ( if (!locationComposer || !location || !composer.client.user) return forward(); 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/MessageReceiptsTracker.ts b/src/messageDelivery/MessageReceiptsTracker.ts index 8bb0c4768..5a1fbd0cf 100644 --- a/src/messageDelivery/MessageReceiptsTracker.ts +++ b/src/messageDelivery/MessageReceiptsTracker.ts @@ -42,7 +42,7 @@ const isValidReadState = ( ): readState is ReadStoreUserState & { last_read: number; user: UserResponse; -} => !!readState?.user && !!readState.last_read; +} => !!readState?.user && readState.last_read != null; const compareRefsAsc = (a: MsgRef, b: MsgRef) => a.timestamp !== b.timestamp ? a.timestamp - b.timestamp : 0; @@ -273,12 +273,14 @@ export class MessageReceiptsTracker extends WithSubscriptions { for (const r of responses) { const lastReadTimestamp = r.last_read ?? null; const lastDeliveredTimestamp = r.last_delivered_at ?? null; - const lastReadRef = lastReadTimestamp - ? (this.locateMessage(lastReadTimestamp) ?? MIN_REF) - : MIN_REF; - let lastDeliveredRef = lastDeliveredTimestamp - ? (this.locateMessage(lastDeliveredTimestamp) ?? MIN_REF) - : MIN_REF; + 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; diff --git a/src/offline-support/offline_support_api.ts b/src/offline-support/offline_support_api.ts index 7aae108c4..8d5d1a91d 100644 --- a/src/offline-support/offline_support_api.ts +++ b/src/offline-support/offline_support_api.ts @@ -726,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 @@ -1161,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. @@ -1443,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/pagination/paginators/MessagePaginator.ts b/src/pagination/paginators/MessagePaginator.ts index a3a131473..430ce6c06 100644 --- a/src/pagination/paginators/MessagePaginator.ts +++ b/src/pagination/paginators/MessagePaginator.ts @@ -422,7 +422,7 @@ 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) { + if (lastReadAt != null) { let { firstUnreadMessageId: inferredFirstUnreadMessageId, lastReadMessageId: inferredLastReadMessageId, diff --git a/src/utils.ts b/src/utils.ts index e6d84bf6b..b7d89ff62 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -13,7 +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 } from './utils/time'; +import { nowNs, nsToRfc3339 } from './utils/time'; const logger = chatLoggerSystem.getLogger('utils'); @@ -409,6 +409,33 @@ export function messageWithReactionRemoved( }; } +// Generated as `Date`, but `Date` is millisecond-only and the API takes nanosecond RFC3339. +const toRequestDate = (ns: number) => nsToRfc3339(ns) as unknown as Date; + +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: toRequestDate(pinned_at) } : {}), + ...(pin_expires != null ? { pin_expires: toRequestDate(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: toRequestDate(shared_location.end_at) } + : {}), + }, + } + : {}), +}); + export const localMessageToNewMessagePayload = ( localMessage: LocalMessage, ): MessageRequest => { @@ -418,6 +445,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, @@ -438,12 +469,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 = ( @@ -462,10 +496,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, + }), }; }; diff --git a/src/utils/time.ts b/src/utils/time.ts index cbc790111..d738352f3 100644 --- a/src/utils/time.ts +++ b/src/utils/time.ts @@ -44,6 +44,16 @@ 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()); diff --git a/test/unit/MessageComposer/LocationComposer.test.ts b/test/unit/MessageComposer/LocationComposer.test.ts index b86850a0b..b90999d09 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/middleware/messageComposer/sharedLocation.test.ts b/test/unit/MessageComposer/middleware/messageComposer/sharedLocation.test.ts index 77a306e7d..80d41dbf2 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' }; @@ -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/channel.test.js b/test/unit/channel.test.js index 96f9da7c4..b120e5320 100644 --- a/test/unit/channel.test.js +++ b/test/unit/channel.test.js @@ -153,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 diff --git a/test/unit/messageDelivery/MessageReceiptsTracker.test.ts b/test/unit/messageDelivery/MessageReceiptsTracker.test.ts index 1687d26cf..9a24fa5f2 100644 --- a/test/unit/messageDelivery/MessageReceiptsTracker.test.ts +++ b/test/unit/messageDelivery/MessageReceiptsTracker.test.ts @@ -748,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'); diff --git a/test/unit/pagination/paginators/MessagePaginator.test.ts b/test/unit/pagination/paginators/MessagePaginator.test.ts index 7e4c06435..d53864056 100644 --- a/test/unit/pagination/paginators/MessagePaginator.test.ts +++ b/test/unit/pagination/paginators/MessagePaginator.test.ts @@ -746,6 +746,51 @@ 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: '2021-01-01T00:00:00.000Z', id: 'm-first' }), + createMessage({ created_at: '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' }), + ); + }); + it('falls back to created_at_around query when unread ids are missing and lastReadAt exists', async () => { const lastReadAt = convertDateToTimestamp('2021-01-02T00:00:00.000Z'); const channelWithReadState = { diff --git a/test/unit/test-utils/generateMessage.ts b/test/unit/test-utils/generateMessage.ts index 246f72e6d..1a491456b 100644 --- a/test/unit/test-utils/generateMessage.ts +++ b/test/unit/test-utils/generateMessage.ts @@ -8,6 +8,7 @@ const TIMESTAMP_FIELDS = [ 'updated_at', 'deleted_at', 'pinned_at', + 'pin_expires', 'message_text_updated_at', ] as const; diff --git a/test/unit/utils.test.ts b/test/unit/utils.test.ts index d4e58a9ce..435eec861 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,6 +14,8 @@ import { userHasReadReceipts, formatMessage, generateChannelTempCid, + localMessageToNewMessagePayload, + toUpdatedMessagePayload, uniqBy, runDetached, sleep, @@ -640,3 +643,139 @@ describe('userHasReadReceipts', () => { expect(userHasReadReceipts(makeClient(undefined))).toBe(true); }); }); + +describe('request-payload date direction', () => { + // Both payload builders emit full-precision RFC3339 rather than a `Date`, which is + // millisecond-only. A real on-device magnitude, so a regression shows up as an `Invalid Date`. + const NANOS = 1786219962651957000; + const wire = (value: unknown) => value as unknown as string; + const NS_RFC3339 = /^\d{4}-\d{2}-\d{2}T[\d:]{8}\.\d{9}Z$/; + + 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(wire(payload.pinned_at)).toMatch(NS_RFC3339); + expect(wire(payload.pin_expires)).toMatch(NS_RFC3339); + + expect(payload).not.toHaveProperty('message_text_updated_at'); + + // No date field may still be a number. + for (const key of [ + 'created_at', + 'updated_at', + 'deleted_at', + 'pinned_at', + 'pin_expires', + ]) { + expect(typeof (payload as Record)[key]).not.toBe('number'); + } + }); + + it('keeps the sub-millisecond part a Date would have dropped', () => { + const withSubMs = 1786219962651957000 + 123000; + const payload = localMessageToNewMessagePayload( + formatMessage(generateMsg({ pinned_at: withSubMs })), + ); + + const emitted = wire(payload.pinned_at); + expect(emitted).toMatch(NS_RFC3339); + expect(emitted.slice(-10, -1)).toBe( + String(Math.floor(withSubMs / 1e6) % 1000).padStart(3, '0') + + String(withSubMs - Math.floor(withSubMs / 1e6) * 1e6).padStart(6, '0'), + ); + expect(new Date(Math.floor(withSubMs / 1e6)).toISOString()).not.toBe(emitted); + }); + + 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(wire(payload.shared_location?.end_at)).toMatch(NS_RFC3339); + expect(payload.shared_location).not.toHaveProperty('created_at'); + expect(payload.shared_location).not.toHaveProperty('updated_at'); + }); + + 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'); + }); + }); + + 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(wire(payload.pinned_at)).toMatch(NS_RFC3339); + }); + + 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(wire(payload.pin_expires)).toMatch(NS_RFC3339); + }); + + 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(wire(payload.shared_location?.end_at)).toMatch(NS_RFC3339); + 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 000000000..017541e7b --- /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'); + }); +}); From d23708b6c41205a879d9b4ed4eb57664a2f1d015 Mon Sep 17 00:00:00 2001 From: Zita Szupera Date: Wed, 2 Sep 2026 10:05:02 +0200 Subject: [PATCH 07/11] fix: date values in unit tests --- test/unit/ChannelManager.test.ts | 27 ++-- test/unit/CooldownTimer.test.ts | 42 ++--- .../MessageComposer/CustomDataManager.test.ts | 9 +- .../MessageComposer/attachmentManager.test.ts | 11 +- .../linkPreviewsManager.test.ts | 9 +- .../MessageComposer/messageComposer.test.ts | 11 +- .../messageComposer/attachments.test.ts | 21 +-- .../messageComposer/cleanData.test.ts | 5 +- .../messageComposer/commandInjection.test.ts | 9 +- .../compositionValidation.test.ts | 45 +++--- .../messageComposer/customData.test.ts | 9 +- .../messageComposer/linkPreviews.test.ts | 33 ++-- .../messageComposerState.test.ts | 53 +++--- .../messageComposer/pollOnly.test.ts | 5 +- .../messageComposer/textComposer.test.ts | 33 ++-- .../messageComposer/userDataInjection.test.ts | 5 +- .../textComposer/MentionsSearchSource.test.ts | 31 ++-- .../unit/MessageComposer/textComposer.test.ts | 21 +-- test/unit/channel.test.js | 100 +++++++----- test/unit/channel_state.test.js | 39 ++++- test/unit/client.test.js | 70 +++++--- .../unit/configuration/channel.config.test.ts | 11 +- .../instanceConfiguration.integration.test.ts | 11 +- .../MessageDeliveryReporter.test.ts | 73 ++++++--- .../unit/messageOperations/optimistic.test.ts | 5 +- .../offline_support_api.test.ts | 25 ++- .../pagination/UserGroupPaginator.test.ts | 6 +- .../paginator.initializeConfig.test.ts | 16 +- .../paginators/ChannelPaginator.test.ts | 56 +++++-- .../paginators/MessagePaginator.test.ts | 151 ++++++++++-------- .../MessagePaginatorThrottle.test.ts | 5 +- .../paginators/PinnedMessagePaginator.test.ts | 9 +- .../paginators/UserGroupPaginator.test.ts | 2 +- test/unit/poll.test.js | 57 +++---- test/unit/poll_manager.test.ts | 33 ++-- test/unit/reactions.optimistic.test.ts | 7 +- .../search/ChannelMemberSearchSource.test.ts | 5 +- test/unit/search/searchDebounce.test.ts | 5 +- test/unit/test-utils/generateMessage.ts | 22 +-- test/unit/threads.test.ts | 146 ++++++++++++----- 40 files changed, 758 insertions(+), 475 deletions(-) diff --git a/test/unit/ChannelManager.test.ts b/test/unit/ChannelManager.test.ts index 2fe086d57..a7f869c82 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 52f354f33..0b1060dc3 100644 --- a/test/unit/CooldownTimer.test.ts +++ b/test/unit/CooldownTimer.test.ts @@ -74,7 +74,7 @@ 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' } })); @@ -167,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 }, }), ); @@ -204,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 }, }), ); @@ -219,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 }, }), ); @@ -234,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 }, }), ); @@ -267,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 }, }), ); @@ -294,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 }, }), ); @@ -318,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 }, }), ); @@ -353,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 }, }), ); @@ -388,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 }, }), ); @@ -426,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/MessageComposer/CustomDataManager.test.ts b/test/unit/MessageComposer/CustomDataManager.test.ts index 9a0f00fb9..e1dea84e4 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/attachmentManager.test.ts b/test/unit/MessageComposer/attachmentManager.test.ts index 2a925e7de..cf4ad58fb 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 ed2d743ed..58d6d8e9f 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 f21b2a094..65bee5ce0 100644 --- a/test/unit/MessageComposer/messageComposer.test.ts +++ b/test/unit/MessageComposer/messageComposer.test.ts @@ -22,6 +22,7 @@ import { getCommandByName } from '../../../src/messageComposer/middleware/textCo 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 @@ -1660,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 }; }, @@ -1688,7 +1689,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: generateMsg({ @@ -1721,7 +1722,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: generateMsg({ @@ -1745,7 +1746,7 @@ 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 = composed!.localMessage.updated_at + msToNs(100); @@ -2335,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 50393ee28..f077011f4 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 a612d58a8..ae1790054 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 c5dfe4240..8b36646ff 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 9228a2ea2..4822157b3 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,14 @@ describe('stream-io/message-composer-middleware/data-validation', () => { }, localMessage: { ...editedMessage, - created_at: new Date(editedMessage.created_at as string), + created_at: convertDateToTimestamp( + new Date(editedMessage.created_at as string), + ), deleted_at: null, pinned_at: null, - updated_at: new Date(editedMessage.updated_at as string), + updated_at: convertDateToTimestamp( + new Date(editedMessage.updated_at as string), + ), } as LocalMessage, sendOptions: {}, }), @@ -623,7 +628,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 +639,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 b82a95851..5f34f525f 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 c5747ba5d..b8246b5a7 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 97cdd28d0..3163107e6 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 9f5df46aa..28256a62c 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/textComposer.test.ts b/test/unit/MessageComposer/middleware/messageComposer/textComposer.test.ts index d68a18e28..7ee51cd16 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 d9103d90e..52e5bcf2a 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 749e2792c..08031324f 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 4304b103f..2351c6b62 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 b120e5320..19e18f2be 100644 --- a/test/unit/channel.test.js +++ b/test/unit/channel.test.js @@ -35,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, @@ -752,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, }; @@ -860,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(), }); @@ -1108,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)], @@ -1125,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( @@ -1218,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', }, ]; @@ -1332,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); @@ -1356,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 }, }, }; @@ -1411,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); @@ -1478,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( @@ -1532,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. @@ -1584,7 +1594,7 @@ describe('Channel _handleChannelEvent', function () { ...quotedMessage, type: 'deleted', text: 'after delete', - deleted_at: new Date().toISOString(), + deleted_at: convertDateToTimestamp(new Date().toISOString()), }, }); @@ -1618,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()), }, }); @@ -1638,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()), }, }); @@ -1656,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()), }, }); @@ -1678,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()), }, }); @@ -1698,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()), }, }); @@ -1786,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'); @@ -1802,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']); @@ -1818,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, @@ -1840,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(); @@ -2304,13 +2314,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; @@ -2332,13 +2342,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; @@ -2595,7 +2605,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', () => { @@ -2654,7 +2664,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'), }, ]; @@ -2680,7 +2690,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'), }, ], }, @@ -3362,7 +3372,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, }, @@ -3373,7 +3383,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, @@ -3399,7 +3411,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 @@ -4292,7 +4308,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 7ae1efe34..008cd2199 100644 --- a/test/unit/channel_state.test.js +++ b/test/unit/channel_state.test.js @@ -7,6 +7,7 @@ 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(); @@ -168,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' }, }, @@ -195,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); @@ -205,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); @@ -217,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; @@ -430,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' } }; @@ -464,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 1e111fd3e..3913c9bd8 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(); diff --git a/test/unit/configuration/channel.config.test.ts b/test/unit/configuration/channel.config.test.ts index 4fa1d5727..f229b74a2 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 1b5ae384e..a74a106a0 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/messageDelivery/MessageDeliveryReporter.test.ts b/test/unit/messageDelivery/MessageDeliveryReporter.test.ts index 8417327c6..0d8e703d3 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/messageOperations/optimistic.test.ts b/test/unit/messageOperations/optimistic.test.ts index ee520b0e4..4b9c0f77e 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/offline-support/offline_support_api.test.ts b/test/unit/offline-support/offline_support_api.test.ts index a2a75ced5..1d4d160e2 100644 --- a/test/unit/offline-support/offline_support_api.test.ts +++ b/test/unit/offline-support/offline_support_api.test.ts @@ -1125,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()), + }, ], }, ]); @@ -2373,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; @@ -2427,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; diff --git a/test/unit/pagination/UserGroupPaginator.test.ts b/test/unit/pagination/UserGroupPaginator.test.ts index 2e0504acb..dee39819b 100644 --- a/test/unit/pagination/UserGroupPaginator.test.ts +++ b/test/unit/pagination/UserGroupPaginator.test.ts @@ -12,7 +12,7 @@ const createUserGroup = ( id: 'group-1', name: 'Backend Support', created_at: convertDateToTimestamp('2026-01-01T00:00:00.000Z'), - updated_at: new Date('2026-01-01T00:00:00.000Z'), + updated_at: convertDateToTimestamp(new Date('2026-01-01T00:00:00.000Z')), ...overrides, }); @@ -42,7 +42,7 @@ describe('UserGroupPaginator', () => { id: 'group-2', name: 'Frontend Support', created_at: convertDateToTimestamp('2026-01-02T00:00:00.000Z'), - updated_at: new Date('2026-01-02T00:00:00.000Z'), + updated_at: convertDateToTimestamp(new Date('2026-01-02T00:00:00.000Z')), }), ]; const secondPage = [ @@ -50,7 +50,7 @@ describe('UserGroupPaginator', () => { id: 'group-3', name: 'QA Support', created_at: convertDateToTimestamp('2026-01-03T00:00:00.000Z'), - updated_at: new Date('2026-01-03T00:00:00.000Z'), + updated_at: convertDateToTimestamp(new Date('2026-01-03T00:00:00.000Z')), }), ]; diff --git a/test/unit/pagination/paginator.initializeConfig.test.ts b/test/unit/pagination/paginator.initializeConfig.test.ts index f113c8cae..6dd5c44b5 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 802b2a6a2..3c2718b32 100644 --- a/test/unit/pagination/paginators/ChannelPaginator.test.ts +++ b/test/unit/pagination/paginators/ChannelPaginator.test.ts @@ -214,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, }; @@ -266,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', () => { @@ -280,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, }; @@ -471,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 }, }, @@ -496,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(); @@ -562,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); }); @@ -585,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(); @@ -1611,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 @@ -1665,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 d53864056..e5aeca53d 100644 --- a/test/unit/pagination/paginators/MessagePaginator.test.ts +++ b/test/unit/pagination/paginators/MessagePaginator.test.ts @@ -77,11 +77,11 @@ describe('MessagePaginator', () => { 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 }); @@ -371,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 @@ -406,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({ @@ -457,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. @@ -503,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({ @@ -589,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 = () => ({ @@ -602,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'), }), ], }); @@ -770,8 +770,14 @@ describe('MessagePaginator', () => { const executeQuerySpy = vi.spyOn(paginator, 'executeQuery').mockResolvedValue({ stateCandidate: { items: [ - createMessage({ created_at: '2021-01-01T00:00:00.000Z', id: 'm-first' }), - createMessage({ created_at: '2021-01-03T00:00:00.000Z', id: 'm-second' }), + 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, @@ -800,7 +806,7 @@ describe('MessagePaginator', () => { read: { user1: { first_unread_message_id: null, - last_read: lastReadAt, + last_read: convertDateToTimestamp(lastReadAt), last_read_message_id: null, }, }, @@ -817,8 +823,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, @@ -856,7 +868,7 @@ describe('MessagePaginator', () => { read: { user1: { first_unread_message_id: null, - last_read: lastReadAt, + last_read: convertDateToTimestamp(lastReadAt), last_read_message_id: null, }, }, @@ -874,11 +886,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', }), ], @@ -912,7 +924,7 @@ describe('MessagePaginator', () => { read: { user1: { first_unread_message_id: null, - last_read: lastReadAt, + last_read: convertDateToTimestamp(lastReadAt), last_read_message_id: 'm-read', }, }, @@ -930,8 +942,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'); @@ -965,7 +983,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, }, @@ -1246,17 +1264,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({ @@ -1278,7 +1296,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, @@ -1298,7 +1316,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, @@ -1428,12 +1446,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]); @@ -1452,12 +1470,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], @@ -1468,7 +1486,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); @@ -1498,28 +1516,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, }); @@ -1542,7 +1560,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)', () => { @@ -1591,7 +1609,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', () => { @@ -1859,7 +1877,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, }); @@ -2160,7 +2178,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, }); @@ -2235,7 +2255,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(); @@ -3036,7 +3059,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, }); @@ -3620,7 +3645,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, }), ); @@ -3635,7 +3660,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(); @@ -3645,7 +3670,7 @@ 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).toBe(at('2021-01-01T00:00:00.000Z')); @@ -3657,7 +3682,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(); @@ -3674,7 +3699,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'), }), ); // The main list no longer relies on an explicit channel-level call: ingestion advances the @@ -3697,7 +3722,7 @@ describe('MessagePaginator', () => { 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).toBe(at('2021-01-01T00:00:00.000Z')); @@ -3709,7 +3734,7 @@ 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', () => { @@ -3763,7 +3788,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', }), ); @@ -3780,7 +3805,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', }), ); @@ -3796,16 +3821,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'); @@ -3817,14 +3842,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'); @@ -3843,14 +3868,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. @@ -3859,7 +3884,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 aee8f1293..df0d2a482 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 cc4445c45..3289c7c6b 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 8d58c8ff9..f457685c6 100644 --- a/test/unit/pagination/paginators/UserGroupPaginator.test.ts +++ b/test/unit/pagination/paginators/UserGroupPaginator.test.ts @@ -9,7 +9,7 @@ const makeGroup = (id: string, createdAt: string): UserGroupResponse => ({ id, name: id, created_at: convertDateToTimestamp(createdAt), - updated_at: new Date(createdAt), + updated_at: convertDateToTimestamp(new Date(createdAt)), }); const response = (groups: UserGroupResponse[]) => ({ duration: '', user_groups: groups }); diff --git a/test/unit/poll.test.js b/test/unit/poll.test.js index f2af85c32..3978e337a 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 12c197820..c9fc8db42 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 7b5b6d575..e280b92c7 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/search/ChannelMemberSearchSource.test.ts b/test/unit/search/ChannelMemberSearchSource.test.ts index 9c8ddc3e9..233826390 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 5319602ea..cf968afed 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/generateMessage.ts b/test/unit/test-utils/generateMessage.ts index 1a491456b..1f10d4450 100644 --- a/test/unit/test-utils/generateMessage.ts +++ b/test/unit/test-utils/generateMessage.ts @@ -2,16 +2,6 @@ import { generateUUIDv4 as uuidv4 } from '../../../src/utils'; import type { MessageResponse, UserResponse } from '../../../src'; import { convertDateToTimestamp } from './time'; -/** The message fields the API sends as unix-nanosecond numbers. */ -const TIMESTAMP_FIELDS = [ - 'created_at', - 'updated_at', - 'deleted_at', - 'pinned_at', - 'pin_expires', - 'message_text_updated_at', -] as const; - export const generateMsg = ( msg: Partial & { date?: Date | number | string } = {}, ): MessageResponse => { @@ -36,17 +26,7 @@ export const generateMsg = ( silent: false, status: 'received', ...msg, - } as MessageResponse & Record; - - // Tests read far better overriding a timestamp with a date literal, but the wire carries numbers — - // and a fixture that hands the SDK a `Date` cannot catch the bugs that unit exists to prevent. - // Normalize every timestamp override here, so no individual test has to. - for (const field of TIMESTAMP_FIELDS) { - const value = message[field]; - if (value != null && typeof value !== 'number') { - message[field] = convertDateToTimestamp(value as Date | number | string); - } - } + } as MessageResponse; return message; }; diff --git a/test/unit/threads.test.ts b/test/unit/threads.test.ts index 358b73862..964b81353 100644 --- a/test/unit/threads.test.ts +++ b/test/unit/threads.test.ts @@ -277,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({ @@ -292,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); @@ -334,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', @@ -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 }); @@ -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< @@ -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], @@ -1598,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 }); @@ -1663,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; @@ -1706,7 +1771,7 @@ describe('Threads 2.0', () => { message: { ...quotedMessage, type: 'deleted', - deleted_at: new Date().toISOString(), + deleted_at: convertDateToTimestamp(new Date().toISOString()), }, }); @@ -1806,7 +1871,10 @@ describe('Threads 2.0', () => { client.dispatchEvent({ type: eventType, - user: { id: bannedUserId, deleted_at: new Date().toISOString() }, + user: { + id: bannedUserId, + deleted_at: convertDateToTimestamp(new Date().toISOString()), + }, created_at: nowNs(), }); From 7ccc6dfbb6ce71b09d37c88a5c25de352463f652 Mon Sep 17 00:00:00 2001 From: Zita Szupera Date: Wed, 2 Sep 2026 14:20:46 +0200 Subject: [PATCH 08/11] fix: more review fixes --- CLAUDE.md | 2 +- src/LiveLocationManager.ts | 2 +- src/channel.ts | 12 +- src/client.ts | 4 +- src/messageComposer/messageComposer.ts | 4 +- src/offline-support/offline_support_api.ts | 2 +- src/pagination/paginators/MessagePaginator.ts | 2 +- src/utils.ts | 19 +- src/utils/time.ts | 12 +- test/unit/utils.test.ts | 81 ++-- v9-to-v10-migration-guide-dates.md | 431 ++++++++++++++++++ v9-to-v10-migration-guide-i18n.md | 45 ++ v9-to-v10-migration-guide-methods.md | 3 + v9-to-v10-migration-guide-other.md | 43 +- v9-to-v10-migration-guide-type-renames.md | 2 +- 15 files changed, 603 insertions(+), 61 deletions(-) create mode 100644 v9-to-v10-migration-guide-dates.md diff --git a/CLAUDE.md b/CLAUDE.md index f6c4be067..b6760f201 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`) 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. `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/src/LiveLocationManager.ts b/src/LiveLocationManager.ts index db960912f..0d49457a9 100644 --- a/src/LiveLocationManager.ts +++ b/src/LiveLocationManager.ts @@ -52,7 +52,7 @@ 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) + if (!message || message.type === 'deleted' || message.shared_location?.end_at == null) return false; return !isExpiredLocation(message.shared_location as SharedLiveLocationResponse); diff --git a/src/channel.ts b/src/channel.ts index d78b7392d..ff704279f 100644 --- a/src/channel.ts +++ b/src/channel.ts @@ -1949,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++; @@ -2738,10 +2738,9 @@ export class Channel extends ChannelApi { } } break; - case 'channel.truncated': - if (event.channel?.truncated_at) { - const truncatedAt = event.channel.truncated_at; - + 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 @@ -2763,6 +2762,7 @@ export class Channel extends ChannelApi { } break; + } case 'member.added': case 'member.updated': { const memberCopy: ChannelMemberResponse = { @@ -3031,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 || nowNs(); + const last_read = this.messagePaginator.lastMessageAt ?? nowNs(); if (user) { readUpdates[user.id] = { user: user as UserResponse, diff --git a/src/client.ts b/src/client.ts index 32c2ac6dc..1c7d3dd49 100644 --- a/src/client.ts +++ b/src/client.ts @@ -904,7 +904,7 @@ export class StreamChat extends ChatApi { } dispatchEvent = (event: Event) => { - if (!event.received_at) event.received_at = nowNs(); + if (event.received_at == null) event.received_at = nowNs(); // client event handlers const postListenerCallbacks = this._handleClientEvent(event as WSEvent); @@ -1226,7 +1226,7 @@ export class StreamChat extends ChatApi { muteStatus = { // `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 ? mute.expires > nowNs() : true, + muted: mute.expires != null ? mute.expires > nowNs() : true, createdAt: mute.created_at ?? nowNs(), expiresAt: mute.expires ?? null, }; diff --git a/src/messageComposer/messageComposer.ts b/src/messageComposer/messageComposer.ts index b9cf0c089..4c38def16 100644 --- a/src/messageComposer/messageComposer.ts +++ b/src/messageComposer/messageComposer.ts @@ -491,7 +491,7 @@ 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 && + this.editedMessage?.updated_at != null && this.editedMessage.updated_at < this.lastChange.stateUpdate; const draftWasChanged = @@ -1051,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, diff --git a/src/offline-support/offline_support_api.ts b/src/offline-support/offline_support_api.ts index 8d5d1a91d..c4031928b 100644 --- a/src/offline-support/offline_support_api.ts +++ b/src/offline-support/offline_support_api.ts @@ -1152,7 +1152,7 @@ export abstract class AbstractOfflineDB implements OfflineDBApi { const ownReads = activeChannel.state.read[userId]; let unreadCount = 0; - if (truncated_at) { + if (truncated_at != null) { unreadCount = activeChannel.countUnread(truncated_at); } diff --git a/src/pagination/paginators/MessagePaginator.ts b/src/pagination/paginators/MessagePaginator.ts index 430ce6c06..cbf641ba9 100644 --- a/src/pagination/paginators/MessagePaginator.ts +++ b/src/pagination/paginators/MessagePaginator.ts @@ -245,7 +245,7 @@ export class MessagePaginator extends MessageIntervalPaginator { * past it (e.g. from ingested messages), so seed order does not matter. */ seedLastMessageAt(value: number | null | undefined) { - if (!value || !Number.isFinite(value)) return; + if (value == null || !Number.isFinite(value)) return; const current = this.aggregateState.getLatestValue().seededLastMessageAt; if (current !== null && value <= current) return; this.aggregateState.partialNext({ seededLastMessageAt: value }); diff --git a/src/utils.ts b/src/utils.ts index b7d89ff62..bf1e7caef 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -13,7 +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, nsToRfc3339 } from './utils/time'; +import { nowNs, nsToDate } from './utils/time'; const logger = chatLoggerSystem.getLogger('utils'); @@ -409,9 +409,14 @@ export function messageWithReactionRemoved( }; } -// Generated as `Date`, but `Date` is millisecond-only and the API takes nanosecond RFC3339. -const toRequestDate = (ns: number) => nsToRfc3339(ns) as unknown as Date; - +/** + * 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, @@ -420,8 +425,8 @@ const toRequestDateFields = ({ MessageRequest, 'pinned_at' | 'pin_expires' | 'shared_location' > => ({ - ...(pinned_at != null ? { pinned_at: toRequestDate(pinned_at) } : {}), - ...(pin_expires != null ? { pin_expires: toRequestDate(pin_expires) } : {}), + ...(pinned_at != null ? { pinned_at: nsToDate(pinned_at) } : {}), + ...(pin_expires != null ? { pin_expires: nsToDate(pin_expires) } : {}), ...(shared_location ? { shared_location: { @@ -429,7 +434,7 @@ const toRequestDateFields = ({ longitude: shared_location.longitude, created_by_device_id: shared_location.created_by_device_id, ...(shared_location.end_at != null - ? { end_at: toRequestDate(shared_location.end_at) } + ? { end_at: nsToDate(shared_location.end_at) } : {}), }, } diff --git a/src/utils/time.ts b/src/utils/time.ts index d738352f3..fe153f5c7 100644 --- a/src/utils/time.ts +++ b/src/utils/time.ts @@ -8,11 +8,13 @@ * * Two consequences make the helpers below mandatory rather than convenient: * - * - `new Date(ns)` is **out of range**. `Date` tops out around 8.64e15 ms while a current - * nanosecond timestamp is ~1.79e18, so the constructor yields `Invalid Date` and - * `.toISOString()` on it throws `RangeError`. Neither is a type error, so nothing warns you. - * - Date libraries read a bare number as milliseconds. `dayjs(ns)` renders a date roughly 50,000 - * years out, silently. + * - **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. diff --git a/test/unit/utils.test.ts b/test/unit/utils.test.ts index 435eec861..324ddb9c9 100644 --- a/test/unit/utils.test.ts +++ b/test/unit/utils.test.ts @@ -21,6 +21,7 @@ import { sleep, computeOwnReactions, } from '../../src/utils'; +import { nsToMs } from '../../src/utils/time'; import type { ChannelFilters, @@ -645,11 +646,16 @@ describe('userHasReadReceipts', () => { }); describe('request-payload date direction', () => { - // Both payload builders emit full-precision RFC3339 rather than a `Date`, which is - // millisecond-only. A real on-device magnitude, so a regression shows up as an `Invalid Date`. + // 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 wire = (value: unknown) => value as unknown as string; - const NS_RFC3339 = /^\d{4}-\d{2}-\d{2}T[\d:]{8}\.\d{9}Z$/; + 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', () => { @@ -663,36 +669,42 @@ describe('request-payload date direction', () => { ), ); - expect(wire(payload.pinned_at)).toMatch(NS_RFC3339); - expect(wire(payload.pin_expires)).toMatch(NS_RFC3339); + 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 number. - for (const key of [ - 'created_at', - 'updated_at', - 'deleted_at', - 'pinned_at', - 'pin_expires', - ]) { - expect(typeof (payload as Record)[key]).not.toBe('number'); + // 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('keeps the sub-millisecond part a Date would have dropped', () => { - const withSubMs = 1786219962651957000 + 123000; + 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 })), ); - const emitted = wire(payload.pinned_at); - expect(emitted).toMatch(NS_RFC3339); - expect(emitted.slice(-10, -1)).toBe( - String(Math.floor(withSubMs / 1e6) % 1000).padStart(3, '0') + - String(withSubMs - Math.floor(withSubMs / 1e6) * 1e6).padStart(6, '0'), + 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$/, ); - expect(new Date(Math.floor(withSubMs / 1e6)).toISOString()).not.toBe(emitted); }); it('narrows shared_location to the request shape and converts end_at', () => { @@ -713,9 +725,12 @@ describe('request-payload date direction', () => { ), ); - expect(wire(payload.shared_location?.end_at)).toMatch(NS_RFC3339); + 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', () => { @@ -724,6 +739,15 @@ describe('request-payload date direction', () => { 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', () => { @@ -740,7 +764,8 @@ describe('request-payload date direction', () => { ]) { expect(payload).not.toHaveProperty(key); } - expect(wire(payload.pinned_at)).toMatch(NS_RFC3339); + 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', () => { @@ -749,7 +774,8 @@ describe('request-payload date direction', () => { generateMsg({ pinned_at: NANOS, pin_expires: NANOS + 3600e9 }), ); - expect(wire(payload.pin_expires)).toMatch(NS_RFC3339); + expect(payload.pin_expires).toBeInstanceOf(Date); + expect(payload.pin_expires?.getTime()).toBe(nsToMs(NANOS + 3600e9)); }); it('SENDS shared_location, narrowed to the request shape', () => { @@ -767,7 +793,8 @@ describe('request-payload date direction', () => { }), ); - expect(wire(payload.shared_location?.end_at)).toMatch(NS_RFC3339); + 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'); }); diff --git a/v9-to-v10-migration-guide-dates.md b/v9-to-v10-migration-guide-dates.md new file mode 100644 index 000000000..141236ec6 --- /dev/null +++ b/v9-to-v10-migration-guide-dates.md @@ -0,0 +1,431 @@ +# 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 read a bare number as nanoseconds + +Filter and sort operands are normalised to nanoseconds. A `Date` and an ISO string are converted; a +bare `number` is taken to **already be** the wire unit. If you were passing epoch milliseconds — which +worked in v9 — the bound now resolves to 1970. + +```ts +// WRONG in v10 — epoch milliseconds read as nanoseconds. +{ + created_at: { + $gt: 1700000000000; + } +} +// RIGHT — pass a Date, an ISO string, or 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 48733e051..97d8c9090 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 b22fff7b7..a9f1ac99e 100644 --- a/v9-to-v10-migration-guide-methods.md +++ b/v9-to-v10-migration-guide-methods.md @@ -850,6 +850,9 @@ 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: number | null; expiresAt: number | null }; diff --git a/v9-to-v10-migration-guide-other.md b/v9-to-v10-migration-guide-other.md index 8c21717ca..c01a1f725 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,7 +37,7 @@ - `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`. @@ -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 `