@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 +82 -5
- package/dist/chat.store-TA6G7PD6.js +7 -0
- package/dist/chunk-QHELYVNT.js +109 -0
- package/dist/chunk-QHELYVNT.js.map +1 -0
- package/dist/{chunk-WUNH3UTE.js → chunk-U637W5MD.js} +62 -13
- package/dist/chunk-U637W5MD.js.map +1 -0
- package/dist/index.cjs +182 -25
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +60 -3
- package/dist/index.d.ts +60 -3
- package/dist/index.js +70 -9
- package/dist/index.js.map +1 -1
- package/dist/internal.cjs.map +1 -1
- package/dist/internal.js +1 -1
- package/docs/integration-guide.html +170 -9
- package/package.json +1 -1
- package/dist/chat.store-UVTDBPEC.js +0 -7
- package/dist/chunk-UIYJAOGL.js +0 -62
- package/dist/chunk-UIYJAOGL.js.map +0 -1
- package/dist/chunk-WUNH3UTE.js.map +0 -1
- /package/dist/{chat.store-UVTDBPEC.js.map → chat.store-TA6G7PD6.js.map} +0 -0
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.
|
|
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,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
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
560
|
+
await new Promise((r) => setTimeout(r, waitMs));
|
|
514
561
|
}
|
|
515
562
|
console.error(
|
|
516
|
-
"[AntzChat] transit handshake could not establish a session
|
|
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-
|
|
917
|
+
//# sourceMappingURL=chunk-U637W5MD.js.map
|