@antzsoft/chat-core 1.4.5 → 1.4.7

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/README.md CHANGED
@@ -380,9 +380,48 @@ interface AntzChatConfig {
380
380
  * Optional — sensible defaults are applied for all sub-fields.
381
381
  */
382
382
  upload?: UploadConfig;
383
+
384
+ /**
385
+ * Called when sending a message ultimately fails, after the SDK has already
386
+ * updated the timeline (removed the bubble for permanent failures, flagged it
387
+ * retryable otherwise). Use it to surface a toast or error banner. v1.4.5+.
388
+ * Optional.
389
+ */
390
+ onSendError?: (error: Error, context: SendErrorContext) => void;
391
+ }
392
+ ```
393
+
394
+ ### `SendErrorContext` (v1.4.5+)
395
+
396
+ Passed as the second argument to `onSendError`. The callback fires *after* the SDK has reconciled the timeline, so it is for user-facing notification only — you do not need to remove or flag the bubble yourself.
397
+
398
+ ```typescript
399
+ interface SendErrorContext {
400
+ conversationId: string;
401
+ /** tempId of the optimistic message the failure belongs to. */
402
+ tempId: string;
403
+ /**
404
+ * True when resending the identical payload cannot succeed (file size/type
405
+ * limits, message too long, no longer a participant, ...). The SDK has removed
406
+ * the optimistic message from the timeline. False for transient failures
407
+ * (network / socket / generic server error), where a "! Retry" bubble stays.
408
+ */
409
+ permanent: boolean;
383
410
  }
384
411
  ```
385
412
 
413
+ ```typescript
414
+ const client = new AntzChatClient({
415
+ apiUrl, persistStorage, platformUploadFn,
416
+ onSendError: (error, { permanent }) => {
417
+ if (permanent) toast.error(error.message);
418
+ else toast.warn('Message not sent — tap Retry on the message.');
419
+ },
420
+ });
421
+ ```
422
+
423
+ Pair `permanent: false` with `useChat().retrySendMessage(messageId)` (v1.4.4+) if you want a retry affordance outside the built-in `MessageItem`.
424
+
386
425
  ### `PersistStorage`
387
426
 
388
427
  Supports both synchronous (localStorage) and asynchronous (AsyncStorage) storage backends.
@@ -2420,10 +2459,10 @@ All emit methods that have server responses use a 5-second ack timeout and retur
2420
2459
  | `removeReaction` | `(messageId: string, emoji: string) => Promise<unknown>` | Remove a reaction. Ack-based. |
2421
2460
  | `pinMessage` | `(messageId: string) => Promise<unknown>` | Pin a message. Ack-based. |
2422
2461
  | `unpinMessage` | `(messageId: string) => Promise<unknown>` | Unpin a message. Ack-based. |
2423
- | `typing` | `(conversationId: string, isTyping: boolean) => void` | Broadcast typing status. Fire-and-forget. |
2462
+ | `typing` | `(conversationId: string, isTyping: boolean) => void` | Broadcast typing status. Fire-and-forget. **Leading-throttled since v1.4.7**: `true` emits at most once per 3s per conversation, `false` always emits and resets the window. Safe to call on every keystroke. |
2424
2463
  | `markRead` | `(conversationId: string, messageId?: string) => void` | Mark messages read. Fire-and-forget. |
2425
2464
  | `getOnlineUsers` | `(userIds: string[]) => Promise<string[]>` | Query which of the given user IDs are online. Returns the online subset. |
2426
- | `getTypingUsers` | `(conversationId: string) => Promise<unknown>` | Fetch users currently typing in a conversation. Ack-based. |
2465
+ | `getTypingUsers` | `(conversationId: string) => Promise<unknown>` | Fetch users currently typing in a conversation. Ack-based. Not used by the shipped UI SDKs — useful if you want a client joining a room mid-typing to see the current state rather than waiting for the next event. |
2427
2466
 
2428
2467
  ```typescript
2429
2468
  // Join before sending
@@ -2436,7 +2475,9 @@ await socketEmit.sendMessage({
2436
2475
  tempId: crypto.randomUUID(),
2437
2476
  });
2438
2477
 
2439
- // Typing indicator
2478
+ // Typing indicator — call `true` freely (per keystroke is fine); core
2479
+ // leading-throttles it to one emit per 3s per conversation. The `false`
2480
+ // edge always goes out, so the indicator clears promptly on every peer.
2440
2481
  socketEmit.typing('conv-abc', true);
2441
2482
  // ... user stops typing
2442
2483
  socketEmit.typing('conv-abc', false);
@@ -2608,7 +2649,7 @@ import { useChatStore } from '@antzsoft/chat-core';
2608
2649
  |---|---|---|
2609
2650
  | `activeConversationId` | `string \| null` | The currently open conversation. |
2610
2651
  | `pendingTarget` | `{ conversationId: string; messageId: string } \| null` | Scroll-to target for deep-linked messages. |
2611
- | `typingUsers` | `Record<string, TypingUser[]>` | Map of conversationId → users currently typing. |
2652
+ | `typingUsers` | `Record<string, TypingUser[]>` | Map of conversationId → users currently typing. Each entry self-expires after 6s without a refreshing event (v1.4.7), so a lost `isTyping:false` can no longer leave an indicator stuck. Clear all with `clearTypingUsers()`. |
2612
2653
  | `onlineUsers` | `string[]` | Array of user IDs currently online. |
2613
2654
  | `lastRead` | `Record<string, LastReadEntry>` | Map of conversationId → `{ messageId, readAt }` — the current user's last-read pointer per conversation. Hydrated by `read_receipt` socket events automatically. |
2614
2655
  | `lastSeen` | `Record<string, string>` | Map of userId → ISO timestamp — each user's last-seen time. Hydrated by `user_offline` socket events automatically. |
@@ -3244,6 +3285,40 @@ document.querySelectorAll('[data-conv-id]').forEach((el) => {
3244
3285
 
3245
3286
  ## Changelog
3246
3287
 
3288
+ > **Full history lives in [CHANGELOG.md](CHANGELOG.md).** That file is the source of truth and covers every release. This section carries only the headline notes for recent versions; v1.4.2, v1.4.3 and v1.4.4 (message forwarding, per-attachment forwarding, and send/forward retry-safety) are documented there rather than duplicated here.
3289
+
3290
+ ### v1.4.7
3291
+
3292
+ - **Changed: `socketEmit.typing` is leading-throttled — ~94.5% fewer typing emits.** The composer calls it per keystroke and every call used to become a wire emit: ~200 for a 40-word message, of which one carried information. Each cost a round trip plus two MongoDB queries server-side and, with transit on, one Redis lookup + one AES encryption *per recipient socket*. Now `isTyping:true` passes at most once per 3s per conversation, while `isTyping:false` always passes and resets the window. Measured: 201 emits → 11 for a 200-keystroke message.
3293
+
3294
+ **No integration changes required** — keep calling `startTyping()` per keystroke; the throttle is inside core. Do not add a second throttle in your own composer. Trade-off: a re-assert after a pause can lag up to 3s; typing *start* is still immediate.
3295
+
3296
+ - **Fixed: typing indicators can no longer get stuck on screen.** The only things that cleared one were an explicit `isTyping:false` and a local socket disconnect, so any lost `false` edge left "X is typing…" up indefinitely — reachable when the sender's tab is killed while their other devices stay connected (the server's disconnect cleanup only fires on their *last* socket). `addTypingUser` now arms a per-user 6s expiry timer, re-armed by each refreshing event; 6s exceeds the 3s throttle window, so a still-typing peer always re-asserts before their indicator lapses.
3297
+
3298
+ - **Added: `clearTypingUsers()`** on the chat store (drops all indicators + cancels timers), **`resetTypingThrottle()`**, and **`registerTeardownHook(hook)`**. All additive. `disconnectSocket()` wires the first two for you.
3299
+
3300
+ - **Server-side, no SDK action needed:** chat-server now runs the typing path with **zero MongoDB queries**, broadcasts `typing_indicator` unencrypted as one adapter publish (metadata only — no message content), rate-limits it per user, and skips it in rooms above `TYPING_MAX_ROOM_SOCKETS` sockets (default 30). The plaintext switch is safe on **every already-deployed SDK version**: `secureOn()` decrypts only what matches `isTransitEnvelope()` (`{v:1, iv, tag, ct}`), so a plain payload passes through untouched. Deploy the server independently; clients upgrade when convenient.
3301
+
3302
+ ### v1.4.5
3303
+
3304
+ Published as `1.4.5`; built and reviewed internally as `1.4.7` (same code — there is no `1.4.6`, and no separate `1.4.7` will be published).
3305
+
3306
+ - **Fix: transit encryption no longer degrades to plaintext or wedges after a socket drop.** Six related fixes, all of them closing a path where the SDK either sent an unencrypted request the server was certain to reject with `403 "Transit encryption required"`, or blocked forever on a handshake nothing was driving:
3307
+ - The socket `disconnect` handler cleared the REST transit session on *every* websocket blip (network hiccup, backgrounded tab, server redeploy), after which every chat REST call 403'd until a full page reload. The REST session is independent of the socket transport and is now torn down only on an explicit `disconnectSocket()` or auth change.
3308
+ - `clearTransitSession()` left `sessionEverEstablished = true`, making `waitForTransitReady()` ("don't block") and `isTransitEnabled()` ("don't encrypt") permanently disagree — requests went out unencrypted and 403'd with no recovery path. The flag now resets with the session.
3309
+ - The 5s `transit_session` safety timeout called `configureTransit(false)`, turning a transient stall into plaintext for the rest of the session. A timeout now leaves transit required; only `GET /crypto/pubkey` returning `enabled: false` can authoritatively disable it.
3310
+ - The interceptor gated its transit wait on the presence of an auth token, so pre-auth calls (`GET /app/config` before an async `authProvider` resolves) raced ahead and 403'd. It now gates on whether transit is configured.
3311
+ - A `join_room` dropped during the handshake gap left the client outside the server-side room with no error and no retry, silently killing `new_message` delivery for that conversation. Joined rooms are now tracked and re-emitted whenever a transit session (re-)establishes or the socket reconnects.
3312
+ - The per-conversation send queue awaited the full server ack before starting the next message. It now awaits only the emit phase, preserving on-the-wire ordering without paying round-trip latency.
3313
+
3314
+ - **Changed: requests fail loudly instead of hanging or downgrading.** With transit required and no session key, a REST request waits up to 30s for an in-flight handshake, then throws a **retryable** `AntzChatNetworkError` with code `TRANSIT_NOT_READY`; socket emits do the same on a 4s budget. Neither ever falls through to plaintext. The REST handshake retries itself up to 5 times with exponential backoff.
3315
+
3316
+ - **New: `onSendError`.** A config callback — `(error, { conversationId, tempId, permanent })` — fired when a send ultimately fails, after the SDK has updated the timeline, so you can raise a toast. `permanent: true` means a retry of the identical payload cannot succeed and the optimistic bubble was removed; `false` leaves a retryable "! Retry" bubble. New type: `SendErrorContext`.
3317
+
3318
+ - **New exports:** `setAuthReadyPromise()` (gate the interceptor until your auth token resolves), `resetTrackedRooms()` (call on a user/tenant switch so the reconnect re-flush cannot re-join the previous user's rooms — a token refresh is *not* an identity change), and `isApiClientConfigured()`.
3319
+
3320
+ **Backward compatible at the API level** — additive exports only, no signature changes, no call-site updates required. **One behavioral change needs your attention:** a call that previously went out as plaintext and returned `403` now throws a retryable error instead. Audit any REST call you fire and forget during startup — `getMe().then(...).catch(() => {})` will swallow `TRANSIT_NOT_READY`, leaving auth unresolved, the socket unconnected, and the app stuck on a loading screen with no visible cause. See [CHANGELOG.md](CHANGELOG.md) → *Upgrading from 1.4.4* for the retry patterns.
3321
+
3247
3322
  ### v1.4.1
3248
3323
 
3249
3324
  - **New: manual "mark as unread."** A conversation can now be flagged unread independently of `unreadCount` — the same UX as WhatsApp/Telegram's "mark as unread": re-flag a conversation you've already read so it stands out again, without fabricating unread messages.
@@ -3258,7 +3333,9 @@ document.querySelectorAll('[data-conv-id]').forEach((el) => {
3258
3333
 
3259
3334
  - **Fix: stale transit encryption key after token refresh, causing continuous `"Transit decryption failed for event: user_online"` spam.** `reconnectSocket()` called `socket.connect()` to apply a refreshed token while preserving the transit session — but socket.io-client's `connect()` is a no-op on an already-connected socket. A token refresh fires while the user is actively on a chat screen (socket still connected), so the new auth was never actually sent; the socket kept running on the stale token until the server eventually dropped it, and the disconnect handler then cleared the transit session, desyncing the client's key from the server's.
3260
3335
 
3261
- **Fix:** in the transit branch of `reconnectSocket()`, force a real transport re-cycle (disconnect → connect) so the fresh auth (token + `transitSessionId`) is actually sent and the server re-links the *same* session (same key), instead of silently continuing on stale auth. A new internal `_preservingSession` guard stops the disconnect handler from wiping the key being deliberately carried forward. Non-transit and already-disconnected paths are unchanged.
3336
+ **Fix:** in the transit branch of `reconnectSocket()`, force a real transport re-cycle (disconnect → connect) so the fresh auth (token + `transitSessionId`) is actually sent and the server re-links the *same* session (same key), instead of silently continuing on stale auth. Non-transit and already-disconnected paths are unchanged.
3337
+
3338
+ > **Superseded in v1.4.5.** This fix originally added an internal `_preservingSession` guard to stop the disconnect handler from wiping the key being carried through the re-cycle. v1.4.5 removed that guard: the disconnect handler no longer clears the transit session on a transient drop at all, so there is nothing to guard against. The transport re-cycle described above is unchanged.
3262
3339
 
3263
3340
  **Backward compatible.** Only affects transit-encryption deployments that refresh auth tokens while the socket is connected. **No integration changes required** — the fix is entirely internal to `reconnectSocket()`.
3264
3341
 
@@ -0,0 +1,7 @@
1
+ import {
2
+ useChatStore
3
+ } from "./chunk-QHELYVNT.js";
4
+ export {
5
+ useChatStore
6
+ };
7
+ //# sourceMappingURL=chat.store-TA6G7PD6.js.map
@@ -0,0 +1,109 @@
1
+ // src/stores/chat.store.ts
2
+ import { create } from "zustand";
3
+ var TYPING_EXPIRY_MS = 6e3;
4
+ var _typingExpiryTimers = /* @__PURE__ */ new Map();
5
+ var typingKey = (conversationId, userId) => `${conversationId}\0${userId}`;
6
+ function clearTypingExpiry(conversationId, userId) {
7
+ const key = typingKey(conversationId, userId);
8
+ const timer = _typingExpiryTimers.get(key);
9
+ if (timer) {
10
+ clearTimeout(timer);
11
+ _typingExpiryTimers.delete(key);
12
+ }
13
+ }
14
+ function clearAllTypingExpiry() {
15
+ _typingExpiryTimers.forEach((timer) => clearTimeout(timer));
16
+ _typingExpiryTimers.clear();
17
+ }
18
+ function scheduleTypingExpiry(conversationId, userId) {
19
+ const key = typingKey(conversationId, userId);
20
+ const existing = _typingExpiryTimers.get(key);
21
+ if (existing) clearTimeout(existing);
22
+ const timer = setTimeout(() => {
23
+ _typingExpiryTimers.delete(key);
24
+ useChatStore.getState().removeTypingUser(conversationId, userId);
25
+ }, TYPING_EXPIRY_MS);
26
+ timer.unref?.();
27
+ _typingExpiryTimers.set(key, timer);
28
+ }
29
+ var useChatStore = create((set) => ({
30
+ activeConversationId: null,
31
+ pendingTarget: null,
32
+ typingUsers: {},
33
+ onlineUsers: [],
34
+ lastRead: {},
35
+ lastSeen: {},
36
+ replyingTo: null,
37
+ editingMessage: null,
38
+ forwardingMessage: null,
39
+ isSidebarOpen: true,
40
+ isGroupInfoOpen: false,
41
+ isStarredPanelOpen: false,
42
+ messageInfoId: null,
43
+ setActiveConversation: (id) => set({ activeConversationId: id, replyingTo: null, editingMessage: null, forwardingMessage: null }),
44
+ setPendingTarget: (target) => set({ pendingTarget: target }),
45
+ // Each typing user carries a self-expiring timer, so an indicator can never
46
+ // outlive the evidence for it. Previously the ONLY things that cleared an
47
+ // indicator were an explicit isTyping:false from the sender and a local
48
+ // socket disconnect — so any lost false edge left "is typing…" on screen
49
+ // indefinitely. That is reachable in normal use: a sender whose tab is killed
50
+ // while their other devices stay connected never triggers the server's
51
+ // disconnect cleanup (it only fires when the user's LAST socket goes), and
52
+ // the false edge is fire-and-forget so it is never retried.
53
+ //
54
+ // The timer is the authority on liveness; the sender's false edge is now just
55
+ // a fast path. TYPING_EXPIRY_MS must exceed chat-core's outbound throttle
56
+ // window (3s) by enough that a still-typing peer always re-asserts before it
57
+ // fires, otherwise indicators would visibly flicker mid-typing.
58
+ addTypingUser: (conversationId, user) => set((state) => {
59
+ scheduleTypingExpiry(conversationId, user.userId);
60
+ const existing = state.typingUsers[conversationId] ?? [];
61
+ const deduped = existing.filter((u) => u.userId !== user.userId);
62
+ return { typingUsers: { ...state.typingUsers, [conversationId]: [...deduped, user] } };
63
+ }),
64
+ removeTypingUser: (conversationId, userId) => set((state) => {
65
+ clearTypingExpiry(conversationId, userId);
66
+ const existing = state.typingUsers[conversationId];
67
+ if (!existing || !existing.some((u) => u.userId === userId)) return state;
68
+ return {
69
+ typingUsers: {
70
+ ...state.typingUsers,
71
+ [conversationId]: existing.filter((u) => u.userId !== userId)
72
+ }
73
+ };
74
+ }),
75
+ clearTypingUsers: () => {
76
+ clearAllTypingExpiry();
77
+ set({ typingUsers: {} });
78
+ },
79
+ setUserOnline: (userId) => set((state) => ({
80
+ onlineUsers: state.onlineUsers.includes(userId) ? state.onlineUsers : [...state.onlineUsers, userId]
81
+ })),
82
+ setUserOffline: (userId) => set((state) => ({ onlineUsers: state.onlineUsers.filter((id) => id !== userId) })),
83
+ setOnlineUsers: (userIds) => set({ onlineUsers: userIds }),
84
+ setLastRead: (conversationId, messageId, readAt) => set((state) => ({
85
+ lastRead: { ...state.lastRead, [conversationId]: { messageId, readAt } }
86
+ })),
87
+ setLastSeen: (userId, lastSeenAt) => set((state) => {
88
+ if (lastSeenAt === null) {
89
+ const { [userId]: _, ...rest } = state.lastSeen;
90
+ return { lastSeen: rest };
91
+ }
92
+ return { lastSeen: { ...state.lastSeen, [userId]: lastSeenAt } };
93
+ }),
94
+ setReplyingTo: (message) => set({ replyingTo: message, editingMessage: null }),
95
+ setEditingMessage: (message) => set({ editingMessage: message, replyingTo: null }),
96
+ setForwardingMessage: (message) => set({ forwardingMessage: message }),
97
+ toggleSidebar: () => set((state) => ({ isSidebarOpen: !state.isSidebarOpen })),
98
+ setSidebarOpen: (open) => set({ isSidebarOpen: open }),
99
+ toggleGroupInfo: () => set((state) => ({ isGroupInfoOpen: !state.isGroupInfoOpen })),
100
+ setGroupInfoOpen: (open) => set({ isGroupInfoOpen: open }),
101
+ toggleStarredPanel: () => set((state) => ({ isStarredPanelOpen: !state.isStarredPanelOpen })),
102
+ setStarredPanelOpen: (open) => set({ isStarredPanelOpen: open }),
103
+ setMessageInfoId: (id) => set({ messageInfoId: id })
104
+ }));
105
+
106
+ export {
107
+ useChatStore
108
+ };
109
+ //# sourceMappingURL=chunk-QHELYVNT.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/stores/chat.store.ts"],"sourcesContent":["import { create } from 'zustand';\nimport type { Message } from '../types/index.js';\n\ninterface TypingUser {\n userId: string;\n displayName: string;\n avatarUrl?: string;\n}\n\nexport interface LastReadEntry {\n messageId: string;\n readAt: string;\n}\n\ninterface ChatState {\n activeConversationId: string | null;\n pendingTarget: { conversationId: string; messageId: string } | null;\n typingUsers: Record<string, TypingUser[]>;\n onlineUsers: string[];\n /** keyed by conversationId — current user's last read pointer per conversation */\n lastRead: Record<string, LastReadEntry>;\n /** keyed by userId — last seen timestamp for each user */\n lastSeen: Record<string, string>;\n replyingTo: Message | null;\n editingMessage: Message | null;\n /** Message staged in the forward picker, null = picker closed. Unlike replyingTo,\n * this does not fold into the next composer send — forwarding targets a different\n * conversation and needs its own conversation-picker UI. */\n forwardingMessage: Message | null;\n isSidebarOpen: boolean;\n isGroupInfoOpen: boolean;\n isStarredPanelOpen: boolean;\n /** messageId currently shown in the Message Info panel, null = closed */\n messageInfoId: string | null;\n\n setActiveConversation: (id: string | null) => void;\n setPendingTarget: (target: { conversationId: string; messageId: string } | null) => void;\n addTypingUser: (conversationId: string, user: TypingUser) => void;\n removeTypingUser: (conversationId: string, userId: string) => void;\n /** Drops every typing indicator and cancels their expiry timers. For teardown\n * and identity change — a socket drop no longer needs it, since indicators\n * expire on their own. */\n clearTypingUsers: () => void;\n setUserOnline: (userId: string) => void;\n setUserOffline: (userId: string) => void;\n setOnlineUsers: (userIds: string[]) => void;\n setLastRead: (conversationId: string, messageId: string, readAt: string) => void;\n setLastSeen: (userId: string, lastSeenAt: string | null) => void;\n setReplyingTo: (message: Message | null) => void;\n setEditingMessage: (message: Message | null) => void;\n setForwardingMessage: (message: Message | null) => void;\n toggleSidebar: () => void;\n setSidebarOpen: (open: boolean) => void;\n toggleGroupInfo: () => void;\n setGroupInfoOpen: (open: boolean) => void;\n toggleStarredPanel: () => void;\n setStarredPanelOpen: (open: boolean) => void;\n setMessageInfoId: (id: string | null) => void;\n}\n\n/**\n * How long a typing indicator survives without a refreshing event.\n *\n * Must be > chat-core's outbound typing throttle (3s) plus network slack, so a\n * peer who is still typing always re-asserts before their indicator expires.\n * Must also be short enough that a genuinely stale indicator disappears within\n * a couple of seconds of belief becoming wrong.\n */\nconst TYPING_EXPIRY_MS = 6_000;\n\n/** `${conversationId}\\u0000${userId}` → pending expiry timer. */\nconst _typingExpiryTimers = new Map<string, ReturnType<typeof setTimeout>>();\n\nconst typingKey = (conversationId: string, userId: string) => `${conversationId}\\u0000${userId}`;\n\nfunction clearTypingExpiry(conversationId: string, userId: string): void {\n const key = typingKey(conversationId, userId);\n const timer = _typingExpiryTimers.get(key);\n if (timer) {\n clearTimeout(timer);\n _typingExpiryTimers.delete(key);\n }\n}\n\nfunction clearAllTypingExpiry(): void {\n _typingExpiryTimers.forEach((timer) => clearTimeout(timer));\n _typingExpiryTimers.clear();\n}\n\n/** (Re)arms the expiry for one typing user. Each refreshing event pushes the\n * deadline out, so the indicator lives exactly as long as events keep coming. */\nfunction scheduleTypingExpiry(conversationId: string, userId: string): void {\n const key = typingKey(conversationId, userId);\n const existing = _typingExpiryTimers.get(key);\n if (existing) clearTimeout(existing);\n const timer = setTimeout(() => {\n _typingExpiryTimers.delete(key);\n useChatStore.getState().removeTypingUser(conversationId, userId);\n }, TYPING_EXPIRY_MS);\n // Never hold a Node process open for an indicator (SSR / tests).\n (timer as unknown as { unref?: () => void }).unref?.();\n _typingExpiryTimers.set(key, timer);\n}\n\nexport const useChatStore = create<ChatState>((set) => ({\n activeConversationId: null,\n pendingTarget: null,\n typingUsers: {},\n onlineUsers: [],\n lastRead: {},\n lastSeen: {},\n replyingTo: null,\n editingMessage: null,\n forwardingMessage: null,\n isSidebarOpen: true,\n isGroupInfoOpen: false,\n isStarredPanelOpen: false,\n messageInfoId: null,\n\n setActiveConversation: (id) =>\n set({ activeConversationId: id, replyingTo: null, editingMessage: null, forwardingMessage: null }),\n\n setPendingTarget: (target) => set({ pendingTarget: target }),\n\n // Each typing user carries a self-expiring timer, so an indicator can never\n // outlive the evidence for it. Previously the ONLY things that cleared an\n // indicator were an explicit isTyping:false from the sender and a local\n // socket disconnect — so any lost false edge left \"is typing…\" on screen\n // indefinitely. That is reachable in normal use: a sender whose tab is killed\n // while their other devices stay connected never triggers the server's\n // disconnect cleanup (it only fires when the user's LAST socket goes), and\n // the false edge is fire-and-forget so it is never retried.\n //\n // The timer is the authority on liveness; the sender's false edge is now just\n // a fast path. TYPING_EXPIRY_MS must exceed chat-core's outbound throttle\n // window (3s) by enough that a still-typing peer always re-asserts before it\n // fires, otherwise indicators would visibly flicker mid-typing.\n addTypingUser: (conversationId, user) =>\n set((state) => {\n scheduleTypingExpiry(conversationId, user.userId);\n const existing = state.typingUsers[conversationId] ?? [];\n const deduped = existing.filter((u) => u.userId !== user.userId);\n return { typingUsers: { ...state.typingUsers, [conversationId]: [...deduped, user] } };\n }),\n\n removeTypingUser: (conversationId, userId) =>\n set((state) => {\n clearTypingExpiry(conversationId, userId);\n const existing = state.typingUsers[conversationId];\n // Nothing to remove — return the SAME state object so subscribers don't\n // re-render. Without this guard the expiry timer and a real false edge\n // racing on the same user would publish a new (identical) map twice.\n if (!existing || !existing.some((u) => u.userId === userId)) return state;\n return {\n typingUsers: {\n ...state.typingUsers,\n [conversationId]: existing.filter((u) => u.userId !== userId),\n },\n };\n }),\n\n clearTypingUsers: () => {\n clearAllTypingExpiry();\n set({ typingUsers: {} });\n },\n\n setUserOnline: (userId) =>\n set((state) => ({\n onlineUsers: state.onlineUsers.includes(userId)\n ? state.onlineUsers\n : [...state.onlineUsers, userId],\n })),\n\n setUserOffline: (userId) =>\n set((state) => ({ onlineUsers: state.onlineUsers.filter((id) => id !== userId) })),\n\n setOnlineUsers: (userIds) => set({ onlineUsers: userIds }),\n\n setLastRead: (conversationId, messageId, readAt) =>\n set((state) => ({\n lastRead: { ...state.lastRead, [conversationId]: { messageId, readAt } },\n })),\n\n setLastSeen: (userId, lastSeenAt) =>\n set((state) => {\n if (lastSeenAt === null) {\n const { [userId]: _, ...rest } = state.lastSeen;\n return { lastSeen: rest };\n }\n return { lastSeen: { ...state.lastSeen, [userId]: lastSeenAt } };\n }),\n\n setReplyingTo: (message) => set({ replyingTo: message, editingMessage: null }),\n\n setEditingMessage: (message) => set({ editingMessage: message, replyingTo: null }),\n\n setForwardingMessage: (message) => set({ forwardingMessage: message }),\n\n toggleSidebar: () => set((state) => ({ isSidebarOpen: !state.isSidebarOpen })),\n setSidebarOpen: (open) => set({ isSidebarOpen: open }),\n\n toggleGroupInfo: () => set((state) => ({ isGroupInfoOpen: !state.isGroupInfoOpen })),\n setGroupInfoOpen: (open) => set({ isGroupInfoOpen: open }),\n\n toggleStarredPanel: () => set((state) => ({ isStarredPanelOpen: !state.isStarredPanelOpen })),\n setStarredPanelOpen: (open) => set({ isStarredPanelOpen: open }),\n\n setMessageInfoId: (id: string | null) => set({ messageInfoId: id }),\n}));\n"],"mappings":"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","names":[]}
@@ -263,11 +263,36 @@ function resetAlgoCache() {
263
263
  }
264
264
 
265
265
  // src/crypto/handshake.ts
266
+ function readRetryAfterMs(headers) {
267
+ const parse = (raw) => {
268
+ if (!raw) return void 0;
269
+ const secs = Number(raw);
270
+ if (Number.isFinite(secs)) return Math.max(0, secs * 1e3);
271
+ const when = Date.parse(raw);
272
+ return Number.isNaN(when) ? void 0 : Math.max(0, when - Date.now());
273
+ };
274
+ const found = ["Retry-After-identity", "Retry-After-ip", "Retry-After"].map((name) => parse(headers.get(name))).filter((ms) => ms != null);
275
+ return found.length > 0 ? Math.max(...found) : void 0;
276
+ }
277
+ var TransitRateLimitedError = class extends Error {
278
+ constructor(retryAfterMs) {
279
+ super("[AntzChat] transit handshake rate-limited (429)");
280
+ this.name = "TransitRateLimitedError";
281
+ this.retryAfterMs = retryAfterMs;
282
+ }
283
+ };
284
+ function identityHeaders(identity) {
285
+ const headers = {};
286
+ if (identity?.userId) headers["x-user-id"] = identity.userId;
287
+ if (identity?.tenantId) headers["X-Tenant-ID"] = identity.tenantId;
288
+ return headers;
289
+ }
266
290
  function hasWebCrypto2() {
267
291
  return typeof globalThis.crypto?.subtle !== "undefined";
268
292
  }
269
- async function fetchServerKeys(apiUrl) {
270
- const res = await fetch(`${apiUrl}/crypto/pubkey`);
293
+ async function fetchServerKeys(apiUrl, identity) {
294
+ const res = await fetch(`${apiUrl}/crypto/pubkey`, { headers: identityHeaders(identity) });
295
+ if (res.status === 429) throw new TransitRateLimitedError(readRetryAfterMs(res.headers));
271
296
  if (!res.ok) throw new Error(`[AntzChat] Failed to fetch server public key: ${res.status}`);
272
297
  const body = await res.json();
273
298
  return body?.data ?? body;
@@ -278,24 +303,26 @@ async function generateEphemeralKey(algo, serverKeys) {
278
303
  }
279
304
  return generateNobleEphemeralKey(serverKeys);
280
305
  }
281
- async function createRestTransitSession(apiUrl) {
306
+ async function createRestTransitSession(apiUrl, identity) {
282
307
  try {
283
- const serverKeys = await fetchServerKeys(apiUrl);
308
+ const serverKeys = await fetchServerKeys(apiUrl, identity);
284
309
  if (!serverKeys.enabled) return null;
285
310
  const algo = hasWebCrypto2() ? await detectTransitAlgo() : "x25519";
286
311
  const { ephemeralPubB64, deriveSessionKey } = await generateEphemeralKey(algo, serverKeys);
287
312
  const res = await fetch(`${apiUrl}/crypto/session`, {
288
313
  method: "POST",
289
- headers: { "Content-Type": "application/json" },
314
+ headers: { "Content-Type": "application/json", ...identityHeaders(identity) },
290
315
  body: JSON.stringify({ ephemeralPub: ephemeralPubB64, algo })
291
316
  });
317
+ if (res.status === 429) throw new TransitRateLimitedError(readRetryAfterMs(res.headers));
292
318
  if (!res.ok) return null;
293
319
  const body = await res.json();
294
320
  const sessionId = (body?.data ?? body)?.sessionId ?? body?.sessionId;
295
321
  if (!sessionId) return null;
296
322
  const sessionKey = await deriveSessionKey(sessionId);
297
323
  return { sessionId, sessionKey };
298
- } catch {
324
+ } catch (err) {
325
+ if (err instanceof TransitRateLimitedError) throw err;
299
326
  return null;
300
327
  }
301
328
  }
@@ -494,26 +521,46 @@ function ensureRestTransitHandshake() {
494
521
  const apiUrl = _config.apiUrl;
495
522
  _transitHandshakePromise = (async () => {
496
523
  try {
497
- for (let attempt = 0; attempt < 5; attempt++) {
524
+ const MAX_FAILURES = 5;
525
+ const BACKSTOP_MS = 2 * 6e4;
526
+ const deadline = Date.now() + BACKSTOP_MS;
527
+ let failures = 0;
528
+ let rateLimitHits = 0;
529
+ while (failures < MAX_FAILURES && Date.now() < deadline) {
498
530
  if (getTransitSession()) return;
531
+ let waitMs;
499
532
  try {
500
- const keys = await fetchServerKeys(apiUrl);
533
+ const identity = { userId: _config?.userId, tenantId: _config?.tenantId };
534
+ const keys = await fetchServerKeys(apiUrl, identity);
501
535
  if (!keys?.enabled) {
502
536
  configureTransit(false);
503
537
  return;
504
538
  }
505
- const session = await createRestTransitSession(apiUrl);
539
+ const session = await createRestTransitSession(apiUrl, identity);
506
540
  if (session && !getTransitSession()) {
507
541
  const algo = typeof globalThis.crypto?.subtle !== "undefined" ? await detectTransitAlgo() : "x25519";
508
542
  setTransitSession({ sessionKey: session.sessionKey, algo, sessionId: session.sessionId, enabled: true });
509
543
  return;
510
544
  }
511
- } catch {
545
+ failures++;
546
+ waitMs = Math.min(500 * 2 ** failures, 8e3);
547
+ } catch (err) {
548
+ if (err instanceof TransitRateLimitedError) {
549
+ const blind = Math.min(15e3 * 2 ** rateLimitHits, 6e4);
550
+ waitMs = err.retryAfterMs != null ? Math.min(Math.max(err.retryAfterMs, 1e3), 6e4) : blind;
551
+ rateLimitHits++;
552
+ console.warn(
553
+ `[AntzChat] transit handshake rate-limited (429) \u2014 retrying in ${Math.round(waitMs / 1e3)}s${err.retryAfterMs != null ? " (per Retry-After)" : ""}.`
554
+ );
555
+ } else {
556
+ failures++;
557
+ waitMs = Math.min(500 * 2 ** failures, 8e3);
558
+ }
512
559
  }
513
- await new Promise((r) => setTimeout(r, Math.min(500 * 2 ** attempt, 8e3)));
560
+ await new Promise((r) => setTimeout(r, waitMs));
514
561
  }
515
562
  console.error(
516
- "[AntzChat] transit handshake could not establish a session after 5 attempts \u2014 chat requests stay gated until one succeeds (server requires transit)."
563
+ "[AntzChat] transit handshake could not establish a session \u2014 chat requests stay gated until one succeeds (server requires transit)."
517
564
  );
518
565
  } finally {
519
566
  _transitHandshakePromise = null;
@@ -842,6 +889,8 @@ export {
842
889
  getSessionId,
843
890
  detectTransitAlgo,
844
891
  resetAlgoCache,
892
+ readRetryAfterMs,
893
+ TransitRateLimitedError,
845
894
  fetchServerKeys,
846
895
  generateEphemeralKey,
847
896
  createRestTransitSession,
@@ -865,4 +914,4 @@ export {
865
914
  uploadBatch,
866
915
  uploadBatchWithSlots
867
916
  };
868
- //# sourceMappingURL=chunk-WUNH3UTE.js.map
917
+ //# sourceMappingURL=chunk-U637W5MD.js.map