@antzsoft/chat-core 1.4.6 → 1.4.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.cts CHANGED
@@ -12,6 +12,15 @@ declare const authApi: {
12
12
  login(credentials: LoginCredentials): Promise<AuthResponse>;
13
13
  register(payload: RegisterData): Promise<AuthResponse>;
14
14
  refresh(refreshToken: string): Promise<AuthTokens>;
15
+ /**
16
+ * Ends the session. Never blocks on the transit handshake: logout has to work
17
+ * on a degraded channel, which is exactly when it matters most.
18
+ *
19
+ * The refreshToken is dropped when no transit session exists, so it is never
20
+ * sent in the clear. Losing it costs nothing meaningful — the server revokes
21
+ * against the JWT's own user id either way; passing it only lets the server
22
+ * target that one refresh token instead of the caller's session set.
23
+ */
15
24
  logout(refreshToken?: string): Promise<void>;
16
25
  logoutAll(): Promise<void>;
17
26
  getMe(): Promise<User>;
@@ -327,6 +336,28 @@ type TokenStore = {
327
336
  };
328
337
  declare function setAuthReadyPromise(promise: Promise<unknown> | null): void;
329
338
  declare function isApiClientConfigured(): boolean;
339
+ /**
340
+ * Per-request opt-out of the transit GATE (not of transit itself).
341
+ *
342
+ * Set via `{ headers: { [TRANSIT_OPTIONAL_HEADER]: '1' } }` on the axios call.
343
+ * A request marked this way still encrypts normally whenever a session happens
344
+ * to be ready — it simply refuses to BLOCK waiting for one, and goes out
345
+ * unencrypted rather than failing when the channel is down.
346
+ *
347
+ * This exists for teardown calls (logout, logout-all, device de-registration).
348
+ * Those must succeed precisely when the secure channel is degraded: a control
349
+ * that prevents a user from ending their own session is inverted. Their server
350
+ * routes are marked @PreTransit() so they accept an unencrypted request, and
351
+ * JwtAuthGuard still authenticates them — transit is a confidentiality layer,
352
+ * never an authenticity one (POST /crypto/session is itself unauthenticated, so
353
+ * holding a transit session proves nothing about the caller).
354
+ *
355
+ * Do NOT add this to ordinary routes: they are not @PreTransit() server-side and
356
+ * would 403 with "Transit encryption required" the moment the gate is skipped.
357
+ *
358
+ * The marker header is stripped before the request leaves the client.
359
+ */
360
+ declare const TRANSIT_OPTIONAL_HEADER = "x-antz-transit-optional";
330
361
  declare function initApiClient(config: ResolvedConfig, tokenStore: TokenStore): AxiosInstance;
331
362
  declare function setApiClientInstance(instance: AxiosInstance): void;
332
363
  declare function getApiClient(): AxiosInstance;
@@ -339,6 +370,7 @@ declare function getSocket(): Socket;
339
370
  declare function tryGetSocket(): Socket | null;
340
371
  declare function getSocketStatus(): SocketStatus;
341
372
  declare function onSocketStatus(listener: StatusListener): () => void;
373
+ declare function registerTeardownHook(hook: () => void): () => void;
342
374
  /** Drop all tracked rooms. Call on a genuine identity change (user/tenant
343
375
  * switch) so the reconnect re-flush can't re-join the previous user's rooms.
344
376
  * NOT called from disconnectSocket() — a token refresh is not an intent change. */
@@ -353,6 +385,9 @@ declare function reconnectSocket(token: string, userId?: string, tenantId?: stri
353
385
  */
354
386
  declare function refreshSocketAuth(): boolean;
355
387
 
388
+ /** Clears the typing throttle state. Call on teardown/identity change so a new
389
+ * session cannot inherit the previous one's suppression window. */
390
+ declare function resetTypingThrottle(): void;
356
391
  declare const socketEmit: {
357
392
  joinRoom(conversationId: string): void;
358
393
  leaveRoom(conversationId: string): void;
@@ -584,6 +619,10 @@ interface ChatState {
584
619
  } | null) => void;
585
620
  addTypingUser: (conversationId: string, user: TypingUser) => void;
586
621
  removeTypingUser: (conversationId: string, userId: string) => void;
622
+ /** Drops every typing indicator and cancels their expiry timers. For teardown
623
+ * and identity change — a socket drop no longer needs it, since indicators
624
+ * expire on their own. */
625
+ clearTypingUsers: () => void;
587
626
  setUserOnline: (userId: string) => void;
588
627
  setUserOffline: (userId: string) => void;
589
628
  setOnlineUsers: (userIds: string[]) => void;
@@ -848,4 +887,4 @@ declare class AntzChatClient {
848
887
  uploadIcon(conversationId: string, file: UploadableFile): Promise<Conversation>;
849
888
  }
850
889
 
851
- export { AntzChatAuthError, AntzChatClient, AntzChatConfig, AntzChatError, AntzChatNetworkError, AntzChatPermissionError, AntzChatServerError, AntzChatValidationError, AppConfig, AuthResponse, AuthTokens, BatchUploadResult, CompletedPart, Conversation, ConversationListParams, ConversationSyncResponse, ConversationUnreadCount, type CreateDirectData, type CreateGroupData, CrossConversationSyncResponse, CursorPaginatedResponse, FileResponse, FileType, ForwardAckPayload, ForwardMessagePayload, type ForwardResult, HIGHLY_FORWARDED_DEPTH_THRESHOLD, type LastReadEntry, type ListMessagesParams, LoginCredentials, MAX_FORWARD_TARGETS, type MentionPart, type MentionSegment, Message, MessageReactionsResponse, MessageReceiptsResponse, type MobileDeviceToken, PaginatedResponse, type ParsedMention, Participant, PersistStorage, PresignedUrlRequest, PresignedUrlResponse, RegisterData, type RegisterDeviceTokenPayload, ResolvedCompressionConfig, ResolvedConfig, type SearchParams, type SendData, SendMessagePayload, type SocketStatus, type StatusListener, type TokenStore, type TransitIdentity, TransitRateLimitedError, UnreadSummary, type UpdateConversationData, type UpdateProfilePayload, UploadableFile, User, UserPreferences, type WebPushDeviceToken, appConfigApi, authApi, buildMentionText, connectSocket, conversationsApi, createAuthStore, createRestTransitSession, decryptPayload, devicesApi, disconnectSocket, encryptPayload, extractMentionIds, fetchServerKeys, generateEphemeralKey, getApiClient, getAuthStore, getCompressionStrategy, getSessionId, getSessionKey, getSocket, getSocketStatus, initApiClient, initAuthStore, isApiClientConfigured, isMentionAll, isTransitEnvelope, messagesApi, normalizeAxiosError, normalizeConversation, onSocketStatus, parseMentions, performHandshake, readRetryAfterMs, reconnectSocket, refreshSocketAuth, renderMentionParts, resetAuthStore, resetTrackedRooms, resolveSystemMessageText, setApiClientInstance, setAuthReadyPromise, setTransitSession, socketEmit, syncApi, tryGetSocket, useChatStore, usersApi };
890
+ export { AntzChatAuthError, AntzChatClient, AntzChatConfig, AntzChatError, AntzChatNetworkError, AntzChatPermissionError, AntzChatServerError, AntzChatValidationError, AppConfig, AuthResponse, AuthTokens, BatchUploadResult, CompletedPart, Conversation, ConversationListParams, ConversationSyncResponse, ConversationUnreadCount, type CreateDirectData, type CreateGroupData, CrossConversationSyncResponse, CursorPaginatedResponse, FileResponse, FileType, ForwardAckPayload, ForwardMessagePayload, type ForwardResult, HIGHLY_FORWARDED_DEPTH_THRESHOLD, type LastReadEntry, type ListMessagesParams, LoginCredentials, MAX_FORWARD_TARGETS, type MentionPart, type MentionSegment, Message, MessageReactionsResponse, MessageReceiptsResponse, type MobileDeviceToken, PaginatedResponse, type ParsedMention, Participant, PersistStorage, PresignedUrlRequest, PresignedUrlResponse, RegisterData, type RegisterDeviceTokenPayload, ResolvedCompressionConfig, ResolvedConfig, type SearchParams, type SendData, SendMessagePayload, type SocketStatus, type StatusListener, TRANSIT_OPTIONAL_HEADER, type TokenStore, type TransitIdentity, TransitRateLimitedError, UnreadSummary, type UpdateConversationData, type UpdateProfilePayload, UploadableFile, User, UserPreferences, type WebPushDeviceToken, appConfigApi, authApi, buildMentionText, connectSocket, conversationsApi, createAuthStore, createRestTransitSession, decryptPayload, devicesApi, disconnectSocket, encryptPayload, extractMentionIds, fetchServerKeys, generateEphemeralKey, getApiClient, getAuthStore, getCompressionStrategy, getSessionId, getSessionKey, getSocket, getSocketStatus, initApiClient, initAuthStore, isApiClientConfigured, isMentionAll, isTransitEnvelope, messagesApi, normalizeAxiosError, normalizeConversation, onSocketStatus, parseMentions, performHandshake, readRetryAfterMs, reconnectSocket, refreshSocketAuth, registerTeardownHook, renderMentionParts, resetAuthStore, resetTrackedRooms, resetTypingThrottle, resolveSystemMessageText, setApiClientInstance, setAuthReadyPromise, setTransitSession, socketEmit, syncApi, tryGetSocket, useChatStore, usersApi };
package/dist/index.d.ts CHANGED
@@ -12,6 +12,15 @@ declare const authApi: {
12
12
  login(credentials: LoginCredentials): Promise<AuthResponse>;
13
13
  register(payload: RegisterData): Promise<AuthResponse>;
14
14
  refresh(refreshToken: string): Promise<AuthTokens>;
15
+ /**
16
+ * Ends the session. Never blocks on the transit handshake: logout has to work
17
+ * on a degraded channel, which is exactly when it matters most.
18
+ *
19
+ * The refreshToken is dropped when no transit session exists, so it is never
20
+ * sent in the clear. Losing it costs nothing meaningful — the server revokes
21
+ * against the JWT's own user id either way; passing it only lets the server
22
+ * target that one refresh token instead of the caller's session set.
23
+ */
15
24
  logout(refreshToken?: string): Promise<void>;
16
25
  logoutAll(): Promise<void>;
17
26
  getMe(): Promise<User>;
@@ -327,6 +336,28 @@ type TokenStore = {
327
336
  };
328
337
  declare function setAuthReadyPromise(promise: Promise<unknown> | null): void;
329
338
  declare function isApiClientConfigured(): boolean;
339
+ /**
340
+ * Per-request opt-out of the transit GATE (not of transit itself).
341
+ *
342
+ * Set via `{ headers: { [TRANSIT_OPTIONAL_HEADER]: '1' } }` on the axios call.
343
+ * A request marked this way still encrypts normally whenever a session happens
344
+ * to be ready — it simply refuses to BLOCK waiting for one, and goes out
345
+ * unencrypted rather than failing when the channel is down.
346
+ *
347
+ * This exists for teardown calls (logout, logout-all, device de-registration).
348
+ * Those must succeed precisely when the secure channel is degraded: a control
349
+ * that prevents a user from ending their own session is inverted. Their server
350
+ * routes are marked @PreTransit() so they accept an unencrypted request, and
351
+ * JwtAuthGuard still authenticates them — transit is a confidentiality layer,
352
+ * never an authenticity one (POST /crypto/session is itself unauthenticated, so
353
+ * holding a transit session proves nothing about the caller).
354
+ *
355
+ * Do NOT add this to ordinary routes: they are not @PreTransit() server-side and
356
+ * would 403 with "Transit encryption required" the moment the gate is skipped.
357
+ *
358
+ * The marker header is stripped before the request leaves the client.
359
+ */
360
+ declare const TRANSIT_OPTIONAL_HEADER = "x-antz-transit-optional";
330
361
  declare function initApiClient(config: ResolvedConfig, tokenStore: TokenStore): AxiosInstance;
331
362
  declare function setApiClientInstance(instance: AxiosInstance): void;
332
363
  declare function getApiClient(): AxiosInstance;
@@ -339,6 +370,7 @@ declare function getSocket(): Socket;
339
370
  declare function tryGetSocket(): Socket | null;
340
371
  declare function getSocketStatus(): SocketStatus;
341
372
  declare function onSocketStatus(listener: StatusListener): () => void;
373
+ declare function registerTeardownHook(hook: () => void): () => void;
342
374
  /** Drop all tracked rooms. Call on a genuine identity change (user/tenant
343
375
  * switch) so the reconnect re-flush can't re-join the previous user's rooms.
344
376
  * NOT called from disconnectSocket() — a token refresh is not an intent change. */
@@ -353,6 +385,9 @@ declare function reconnectSocket(token: string, userId?: string, tenantId?: stri
353
385
  */
354
386
  declare function refreshSocketAuth(): boolean;
355
387
 
388
+ /** Clears the typing throttle state. Call on teardown/identity change so a new
389
+ * session cannot inherit the previous one's suppression window. */
390
+ declare function resetTypingThrottle(): void;
356
391
  declare const socketEmit: {
357
392
  joinRoom(conversationId: string): void;
358
393
  leaveRoom(conversationId: string): void;
@@ -584,6 +619,10 @@ interface ChatState {
584
619
  } | null) => void;
585
620
  addTypingUser: (conversationId: string, user: TypingUser) => void;
586
621
  removeTypingUser: (conversationId: string, userId: string) => void;
622
+ /** Drops every typing indicator and cancels their expiry timers. For teardown
623
+ * and identity change — a socket drop no longer needs it, since indicators
624
+ * expire on their own. */
625
+ clearTypingUsers: () => void;
587
626
  setUserOnline: (userId: string) => void;
588
627
  setUserOffline: (userId: string) => void;
589
628
  setOnlineUsers: (userIds: string[]) => void;
@@ -848,4 +887,4 @@ declare class AntzChatClient {
848
887
  uploadIcon(conversationId: string, file: UploadableFile): Promise<Conversation>;
849
888
  }
850
889
 
851
- export { AntzChatAuthError, AntzChatClient, AntzChatConfig, AntzChatError, AntzChatNetworkError, AntzChatPermissionError, AntzChatServerError, AntzChatValidationError, AppConfig, AuthResponse, AuthTokens, BatchUploadResult, CompletedPart, Conversation, ConversationListParams, ConversationSyncResponse, ConversationUnreadCount, type CreateDirectData, type CreateGroupData, CrossConversationSyncResponse, CursorPaginatedResponse, FileResponse, FileType, ForwardAckPayload, ForwardMessagePayload, type ForwardResult, HIGHLY_FORWARDED_DEPTH_THRESHOLD, type LastReadEntry, type ListMessagesParams, LoginCredentials, MAX_FORWARD_TARGETS, type MentionPart, type MentionSegment, Message, MessageReactionsResponse, MessageReceiptsResponse, type MobileDeviceToken, PaginatedResponse, type ParsedMention, Participant, PersistStorage, PresignedUrlRequest, PresignedUrlResponse, RegisterData, type RegisterDeviceTokenPayload, ResolvedCompressionConfig, ResolvedConfig, type SearchParams, type SendData, SendMessagePayload, type SocketStatus, type StatusListener, type TokenStore, type TransitIdentity, TransitRateLimitedError, UnreadSummary, type UpdateConversationData, type UpdateProfilePayload, UploadableFile, User, UserPreferences, type WebPushDeviceToken, appConfigApi, authApi, buildMentionText, connectSocket, conversationsApi, createAuthStore, createRestTransitSession, decryptPayload, devicesApi, disconnectSocket, encryptPayload, extractMentionIds, fetchServerKeys, generateEphemeralKey, getApiClient, getAuthStore, getCompressionStrategy, getSessionId, getSessionKey, getSocket, getSocketStatus, initApiClient, initAuthStore, isApiClientConfigured, isMentionAll, isTransitEnvelope, messagesApi, normalizeAxiosError, normalizeConversation, onSocketStatus, parseMentions, performHandshake, readRetryAfterMs, reconnectSocket, refreshSocketAuth, renderMentionParts, resetAuthStore, resetTrackedRooms, resolveSystemMessageText, setApiClientInstance, setAuthReadyPromise, setTransitSession, socketEmit, syncApi, tryGetSocket, useChatStore, usersApi };
890
+ export { AntzChatAuthError, AntzChatClient, AntzChatConfig, AntzChatError, AntzChatNetworkError, AntzChatPermissionError, AntzChatServerError, AntzChatValidationError, AppConfig, AuthResponse, AuthTokens, BatchUploadResult, CompletedPart, Conversation, ConversationListParams, ConversationSyncResponse, ConversationUnreadCount, type CreateDirectData, type CreateGroupData, CrossConversationSyncResponse, CursorPaginatedResponse, FileResponse, FileType, ForwardAckPayload, ForwardMessagePayload, type ForwardResult, HIGHLY_FORWARDED_DEPTH_THRESHOLD, type LastReadEntry, type ListMessagesParams, LoginCredentials, MAX_FORWARD_TARGETS, type MentionPart, type MentionSegment, Message, MessageReactionsResponse, MessageReceiptsResponse, type MobileDeviceToken, PaginatedResponse, type ParsedMention, Participant, PersistStorage, PresignedUrlRequest, PresignedUrlResponse, RegisterData, type RegisterDeviceTokenPayload, ResolvedCompressionConfig, ResolvedConfig, type SearchParams, type SendData, SendMessagePayload, type SocketStatus, type StatusListener, TRANSIT_OPTIONAL_HEADER, type TokenStore, type TransitIdentity, TransitRateLimitedError, UnreadSummary, type UpdateConversationData, type UpdateProfilePayload, UploadableFile, User, UserPreferences, type WebPushDeviceToken, appConfigApi, authApi, buildMentionText, connectSocket, conversationsApi, createAuthStore, createRestTransitSession, decryptPayload, devicesApi, disconnectSocket, encryptPayload, extractMentionIds, fetchServerKeys, generateEphemeralKey, getApiClient, getAuthStore, getCompressionStrategy, getSessionId, getSessionKey, getSocket, getSocketStatus, initApiClient, initAuthStore, isApiClientConfigured, isMentionAll, isTransitEnvelope, messagesApi, normalizeAxiosError, normalizeConversation, onSocketStatus, parseMentions, performHandshake, readRetryAfterMs, reconnectSocket, refreshSocketAuth, registerTeardownHook, renderMentionParts, resetAuthStore, resetTrackedRooms, resetTypingThrottle, resolveSystemMessageText, setApiClientInstance, setAuthReadyPromise, setTransitSession, socketEmit, syncApi, tryGetSocket, useChatStore, usersApi };
package/dist/index.js CHANGED
@@ -5,6 +5,7 @@ import {
5
5
  AntzChatPermissionError,
6
6
  AntzChatServerError,
7
7
  AntzChatValidationError,
8
+ TRANSIT_OPTIONAL_HEADER,
8
9
  TransitRateLimitedError,
9
10
  awaitTransitReadyOr,
10
11
  clearTransitSession,
@@ -38,10 +39,10 @@ import {
38
39
  setTransitSession,
39
40
  storageApi,
40
41
  uploadBatch
41
- } from "./chunk-U637W5MD.js";
42
+ } from "./chunk-L537XWRD.js";
42
43
  import {
43
44
  useChatStore
44
- } from "./chunk-UIYJAOGL.js";
45
+ } from "./chunk-QHELYVNT.js";
45
46
 
46
47
  // src/config/types.ts
47
48
  function resolveConfig(config) {
@@ -105,6 +106,7 @@ function resolveConfig(config) {
105
106
  }
106
107
 
107
108
  // src/api/auth.ts
109
+ var teardownRequest = { headers: { [TRANSIT_OPTIONAL_HEADER]: "1" } };
108
110
  var authApi = {
109
111
  async login(credentials) {
110
112
  const { data } = await getApiClient().post("/auth/login", credentials);
@@ -118,11 +120,22 @@ var authApi = {
118
120
  const { data } = await getApiClient().post("/auth/refresh", { refreshToken });
119
121
  return data;
120
122
  },
123
+ /**
124
+ * Ends the session. Never blocks on the transit handshake: logout has to work
125
+ * on a degraded channel, which is exactly when it matters most.
126
+ *
127
+ * The refreshToken is dropped when no transit session exists, so it is never
128
+ * sent in the clear. Losing it costs nothing meaningful — the server revokes
129
+ * against the JWT's own user id either way; passing it only lets the server
130
+ * target that one refresh token instead of the caller's session set.
131
+ */
121
132
  async logout(refreshToken) {
122
- await getApiClient().post("/auth/logout", refreshToken ? { refreshToken } : {});
133
+ const encrypted = getTransitSession() != null;
134
+ const body = refreshToken && encrypted ? { refreshToken } : {};
135
+ await getApiClient().post("/auth/logout", body, teardownRequest);
123
136
  },
124
137
  async logoutAll() {
125
- await getApiClient().post("/auth/logout-all");
138
+ await getApiClient().post("/auth/logout-all", {}, teardownRequest);
126
139
  },
127
140
  async getMe() {
128
141
  const { data } = await getApiClient().get("/users/me");
@@ -405,6 +418,19 @@ function secureOn(socket, event, handler) {
405
418
  handler(raw);
406
419
  });
407
420
  }
421
+ var _teardownHooks = /* @__PURE__ */ new Set();
422
+ function registerTeardownHook(hook) {
423
+ _teardownHooks.add(hook);
424
+ return () => _teardownHooks.delete(hook);
425
+ }
426
+ function runTeardownHooks() {
427
+ _teardownHooks.forEach((hook) => {
428
+ try {
429
+ hook();
430
+ } catch {
431
+ }
432
+ });
433
+ }
408
434
  var _joinedRooms = /* @__PURE__ */ new Set();
409
435
  function trackRoomJoin(conversationId) {
410
436
  _joinedRooms.add(conversationId);
@@ -548,13 +574,13 @@ async function _doConnect(config, getToken) {
548
574
  });
549
575
  }
550
576
  secureOn(_socket, "read_receipt", (event) => {
551
- import("./chat.store-UVTDBPEC.js").then(({ useChatStore: useChatStore2 }) => {
577
+ import("./chat.store-TA6G7PD6.js").then(({ useChatStore: useChatStore2 }) => {
552
578
  const e = event;
553
579
  useChatStore2.getState().setLastRead(e.conversationId, e.messageId, e.readAt);
554
580
  });
555
581
  });
556
582
  secureOn(_socket, "user_online", (event) => {
557
- import("./chat.store-UVTDBPEC.js").then(({ useChatStore: useChatStore2 }) => {
583
+ import("./chat.store-TA6G7PD6.js").then(({ useChatStore: useChatStore2 }) => {
558
584
  const store = useChatStore2.getState();
559
585
  const e = event;
560
586
  store.setUserOnline(e.userId);
@@ -562,7 +588,7 @@ async function _doConnect(config, getToken) {
562
588
  });
563
589
  });
564
590
  secureOn(_socket, "user_offline", (event) => {
565
- import("./chat.store-UVTDBPEC.js").then(({ useChatStore: useChatStore2 }) => {
591
+ import("./chat.store-TA6G7PD6.js").then(({ useChatStore: useChatStore2 }) => {
566
592
  const e = event;
567
593
  const store = useChatStore2.getState();
568
594
  store.setUserOffline(e.userId);
@@ -600,6 +626,10 @@ function disconnectSocket() {
600
626
  }
601
627
  clearTransitSession();
602
628
  resetAlgoCache();
629
+ runTeardownHooks();
630
+ import("./chat.store-TA6G7PD6.js").then(({ useChatStore: useChatStore2 }) => {
631
+ useChatStore2.getState().clearTypingUsers();
632
+ });
603
633
  _getToken = null;
604
634
  _userId = void 0;
605
635
  _tenantId = void 0;
@@ -651,6 +681,12 @@ var RECONNECT_WAIT_TIMEOUT = 15e3;
651
681
  var QUEUE_MAX_SIZE = 100;
652
682
  var QUEUE_ENTRY_TTL = 3e4;
653
683
  var sendQueues = /* @__PURE__ */ new Map();
684
+ var TYPING_THROTTLE_MS = 3e3;
685
+ var _lastTypingSentAt = /* @__PURE__ */ new Map();
686
+ function resetTypingThrottle() {
687
+ _lastTypingSentAt.clear();
688
+ }
689
+ registerTeardownHook(resetTypingThrottle);
654
690
  var sendQueueRunning = /* @__PURE__ */ new Map();
655
691
  async function drainSendQueue(conversationId) {
656
692
  if (sendQueueRunning.get(conversationId)) return;
@@ -781,8 +817,40 @@ var socketEmit = {
781
817
  return withAck("unpin_message", { messageId });
782
818
  },
783
819
  // markRead and typing are best-effort — silently dropped if socket not ready
820
+ //
821
+ // Typing is additionally LEADING-THROTTLED here rather than in the UI layer,
822
+ // so every consumer (both UI SDKs and any host calling socketEmit directly)
823
+ // gets the same emit budget and none can bypass it.
824
+ //
825
+ // The composer calls this on EVERY keystroke. A 40-word message is ~200 calls
826
+ // of which exactly one carries information: "this person started typing".
827
+ // Each one previously cost a round trip plus real server work, making typing
828
+ // the single most expensive event in the system per unit of user value.
829
+ //
830
+ // Shape of the throttle:
831
+ // isTyping:true — passed through at most once per TYPING_THROTTLE_MS per
832
+ // conversation. A continuous typist emits ~20/min instead
833
+ // of ~200/min.
834
+ // isTyping:false — ALWAYS passed through, and resets the window. It is the
835
+ // edge that clears the indicator on every peer, it is
836
+ // already debounced by the composer, and dropping it is
837
+ // exactly the failure that leaves "is typing…" stuck.
838
+ //
839
+ // The server refreshes its typing key on each true edge (10s TTL) and
840
+ // receivers expire their own indicators after TYPING_EXPIRY_MS, both of which
841
+ // are comfortably longer than TYPING_THROTTLE_MS — so a throttled-away event
842
+ // never lets an indicator lapse mid-typing.
784
843
  typing(conversationId, isTyping) {
785
- fireAndForget("typing", { conversationId, isTyping });
844
+ if (!isTyping) {
845
+ _lastTypingSentAt.delete(conversationId);
846
+ fireAndForget("typing", { conversationId, isTyping: false });
847
+ return;
848
+ }
849
+ const now = Date.now();
850
+ const last = _lastTypingSentAt.get(conversationId) ?? 0;
851
+ if (now - last < TYPING_THROTTLE_MS) return;
852
+ _lastTypingSentAt.set(conversationId, now);
853
+ fireAndForget("typing", { conversationId, isTyping: true });
786
854
  },
787
855
  markRead(conversationId, messageId) {
788
856
  fireAndForget("mark_read", { conversationId, ...messageId ? { messageId } : {} });
@@ -1147,7 +1215,9 @@ var devicesApi = {
1147
1215
  * Call this on logout so the user stops receiving push notifications on this device.
1148
1216
  */
1149
1217
  async remove(deviceId) {
1150
- await getApiClient().post(`/users/me/devices/${deviceId}/remove`);
1218
+ await getApiClient().post(`/users/me/devices/${deviceId}/remove`, {}, {
1219
+ headers: { [TRANSIT_OPTIONAL_HEADER]: "1" }
1220
+ });
1151
1221
  }
1152
1222
  };
1153
1223
 
@@ -1333,6 +1403,7 @@ export {
1333
1403
  HIGHLY_FORWARDED_DEPTH_THRESHOLD,
1334
1404
  MAX_FORWARD_TARGETS,
1335
1405
  MENTION_ALL_ID,
1406
+ TRANSIT_OPTIONAL_HEADER,
1336
1407
  TransitRateLimitedError,
1337
1408
  appConfigApi,
1338
1409
  authApi,
@@ -1369,9 +1440,11 @@ export {
1369
1440
  readRetryAfterMs,
1370
1441
  reconnectSocket,
1371
1442
  refreshSocketAuth,
1443
+ registerTeardownHook,
1372
1444
  renderMentionParts,
1373
1445
  resetAuthStore,
1374
1446
  resetTrackedRooms,
1447
+ resetTypingThrottle,
1375
1448
  resolveConfig,
1376
1449
  resolveSystemMessageText,
1377
1450
  setApiClientInstance,