@antzsoft/chat-core 1.4.6 → 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
@@ -2459,10 +2459,10 @@ All emit methods that have server responses use a 5-second ack timeout and retur
2459
2459
  | `removeReaction` | `(messageId: string, emoji: string) => Promise<unknown>` | Remove a reaction. Ack-based. |
2460
2460
  | `pinMessage` | `(messageId: string) => Promise<unknown>` | Pin a message. Ack-based. |
2461
2461
  | `unpinMessage` | `(messageId: string) => Promise<unknown>` | Unpin a message. Ack-based. |
2462
- | `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. |
2463
2463
  | `markRead` | `(conversationId: string, messageId?: string) => void` | Mark messages read. Fire-and-forget. |
2464
2464
  | `getOnlineUsers` | `(userIds: string[]) => Promise<string[]>` | Query which of the given user IDs are online. Returns the online subset. |
2465
- | `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. |
2466
2466
 
2467
2467
  ```typescript
2468
2468
  // Join before sending
@@ -2475,7 +2475,9 @@ await socketEmit.sendMessage({
2475
2475
  tempId: crypto.randomUUID(),
2476
2476
  });
2477
2477
 
2478
- // 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.
2479
2481
  socketEmit.typing('conv-abc', true);
2480
2482
  // ... user stops typing
2481
2483
  socketEmit.typing('conv-abc', false);
@@ -2647,7 +2649,7 @@ import { useChatStore } from '@antzsoft/chat-core';
2647
2649
  |---|---|---|
2648
2650
  | `activeConversationId` | `string \| null` | The currently open conversation. |
2649
2651
  | `pendingTarget` | `{ conversationId: string; messageId: string } \| null` | Scroll-to target for deep-linked messages. |
2650
- | `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()`. |
2651
2653
  | `onlineUsers` | `string[]` | Array of user IDs currently online. |
2652
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. |
2653
2655
  | `lastSeen` | `Record<string, string>` | Map of userId → ISO timestamp — each user's last-seen time. Hydrated by `user_offline` socket events automatically. |
@@ -3285,6 +3287,18 @@ document.querySelectorAll('[data-conv-id]').forEach((el) => {
3285
3287
 
3286
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.
3287
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
+
3288
3302
  ### v1.4.5
3289
3303
 
3290
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).
@@ -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":[]}
package/dist/index.cjs CHANGED
@@ -35,11 +35,37 @@ var chat_store_exports = {};
35
35
  __export(chat_store_exports, {
36
36
  useChatStore: () => useChatStore
37
37
  });
38
- var import_zustand2, useChatStore;
38
+ function clearTypingExpiry(conversationId, userId) {
39
+ const key = typingKey(conversationId, userId);
40
+ const timer = _typingExpiryTimers.get(key);
41
+ if (timer) {
42
+ clearTimeout(timer);
43
+ _typingExpiryTimers.delete(key);
44
+ }
45
+ }
46
+ function clearAllTypingExpiry() {
47
+ _typingExpiryTimers.forEach((timer) => clearTimeout(timer));
48
+ _typingExpiryTimers.clear();
49
+ }
50
+ function scheduleTypingExpiry(conversationId, userId) {
51
+ const key = typingKey(conversationId, userId);
52
+ const existing = _typingExpiryTimers.get(key);
53
+ if (existing) clearTimeout(existing);
54
+ const timer = setTimeout(() => {
55
+ _typingExpiryTimers.delete(key);
56
+ useChatStore.getState().removeTypingUser(conversationId, userId);
57
+ }, TYPING_EXPIRY_MS);
58
+ timer.unref?.();
59
+ _typingExpiryTimers.set(key, timer);
60
+ }
61
+ var import_zustand2, TYPING_EXPIRY_MS, _typingExpiryTimers, typingKey, useChatStore;
39
62
  var init_chat_store = __esm({
40
63
  "src/stores/chat.store.ts"() {
41
64
  "use strict";
42
65
  import_zustand2 = require("zustand");
66
+ TYPING_EXPIRY_MS = 6e3;
67
+ _typingExpiryTimers = /* @__PURE__ */ new Map();
68
+ typingKey = (conversationId, userId) => `${conversationId}\0${userId}`;
43
69
  useChatStore = (0, import_zustand2.create)((set) => ({
44
70
  activeConversationId: null,
45
71
  pendingTarget: null,
@@ -56,19 +82,40 @@ var init_chat_store = __esm({
56
82
  messageInfoId: null,
57
83
  setActiveConversation: (id) => set({ activeConversationId: id, replyingTo: null, editingMessage: null, forwardingMessage: null }),
58
84
  setPendingTarget: (target) => set({ pendingTarget: target }),
85
+ // Each typing user carries a self-expiring timer, so an indicator can never
86
+ // outlive the evidence for it. Previously the ONLY things that cleared an
87
+ // indicator were an explicit isTyping:false from the sender and a local
88
+ // socket disconnect — so any lost false edge left "is typing…" on screen
89
+ // indefinitely. That is reachable in normal use: a sender whose tab is killed
90
+ // while their other devices stay connected never triggers the server's
91
+ // disconnect cleanup (it only fires when the user's LAST socket goes), and
92
+ // the false edge is fire-and-forget so it is never retried.
93
+ //
94
+ // The timer is the authority on liveness; the sender's false edge is now just
95
+ // a fast path. TYPING_EXPIRY_MS must exceed chat-core's outbound throttle
96
+ // window (3s) by enough that a still-typing peer always re-asserts before it
97
+ // fires, otherwise indicators would visibly flicker mid-typing.
59
98
  addTypingUser: (conversationId, user) => set((state) => {
99
+ scheduleTypingExpiry(conversationId, user.userId);
60
100
  const existing = state.typingUsers[conversationId] ?? [];
61
101
  const deduped = existing.filter((u) => u.userId !== user.userId);
62
102
  return { typingUsers: { ...state.typingUsers, [conversationId]: [...deduped, user] } };
63
103
  }),
64
- removeTypingUser: (conversationId, userId) => set((state) => ({
65
- typingUsers: {
66
- ...state.typingUsers,
67
- [conversationId]: (state.typingUsers[conversationId] ?? []).filter(
68
- (u) => u.userId !== userId
69
- )
70
- }
71
- })),
104
+ removeTypingUser: (conversationId, userId) => set((state) => {
105
+ clearTypingExpiry(conversationId, userId);
106
+ const existing = state.typingUsers[conversationId];
107
+ if (!existing || !existing.some((u) => u.userId === userId)) return state;
108
+ return {
109
+ typingUsers: {
110
+ ...state.typingUsers,
111
+ [conversationId]: existing.filter((u) => u.userId !== userId)
112
+ }
113
+ };
114
+ }),
115
+ clearTypingUsers: () => {
116
+ clearAllTypingExpiry();
117
+ set({ typingUsers: {} });
118
+ },
72
119
  setUserOnline: (userId) => set((state) => ({
73
120
  onlineUsers: state.onlineUsers.includes(userId) ? state.onlineUsers : [...state.onlineUsers, userId]
74
121
  })),
@@ -147,9 +194,11 @@ __export(src_exports, {
147
194
  readRetryAfterMs: () => readRetryAfterMs,
148
195
  reconnectSocket: () => reconnectSocket,
149
196
  refreshSocketAuth: () => refreshSocketAuth,
197
+ registerTeardownHook: () => registerTeardownHook,
150
198
  renderMentionParts: () => renderMentionParts,
151
199
  resetAuthStore: () => resetAuthStore,
152
200
  resetTrackedRooms: () => resetTrackedRooms,
201
+ resetTypingThrottle: () => resetTypingThrottle,
153
202
  resolveConfig: () => resolveConfig,
154
203
  resolveSystemMessageText: () => resolveSystemMessageText,
155
204
  setApiClientInstance: () => setApiClientInstance,
@@ -1237,6 +1286,19 @@ function secureOn(socket, event, handler) {
1237
1286
  handler(raw);
1238
1287
  });
1239
1288
  }
1289
+ var _teardownHooks = /* @__PURE__ */ new Set();
1290
+ function registerTeardownHook(hook) {
1291
+ _teardownHooks.add(hook);
1292
+ return () => _teardownHooks.delete(hook);
1293
+ }
1294
+ function runTeardownHooks() {
1295
+ _teardownHooks.forEach((hook) => {
1296
+ try {
1297
+ hook();
1298
+ } catch {
1299
+ }
1300
+ });
1301
+ }
1240
1302
  var _joinedRooms = /* @__PURE__ */ new Set();
1241
1303
  function trackRoomJoin(conversationId) {
1242
1304
  _joinedRooms.add(conversationId);
@@ -1432,6 +1494,10 @@ function disconnectSocket() {
1432
1494
  }
1433
1495
  clearTransitSession();
1434
1496
  resetAlgoCache();
1497
+ runTeardownHooks();
1498
+ Promise.resolve().then(() => (init_chat_store(), chat_store_exports)).then(({ useChatStore: useChatStore2 }) => {
1499
+ useChatStore2.getState().clearTypingUsers();
1500
+ });
1435
1501
  _getToken = null;
1436
1502
  _userId = void 0;
1437
1503
  _tenantId = void 0;
@@ -1483,6 +1549,12 @@ var RECONNECT_WAIT_TIMEOUT = 15e3;
1483
1549
  var QUEUE_MAX_SIZE = 100;
1484
1550
  var QUEUE_ENTRY_TTL = 3e4;
1485
1551
  var sendQueues = /* @__PURE__ */ new Map();
1552
+ var TYPING_THROTTLE_MS = 3e3;
1553
+ var _lastTypingSentAt = /* @__PURE__ */ new Map();
1554
+ function resetTypingThrottle() {
1555
+ _lastTypingSentAt.clear();
1556
+ }
1557
+ registerTeardownHook(resetTypingThrottle);
1486
1558
  var sendQueueRunning = /* @__PURE__ */ new Map();
1487
1559
  async function drainSendQueue(conversationId) {
1488
1560
  if (sendQueueRunning.get(conversationId)) return;
@@ -1613,8 +1685,40 @@ var socketEmit = {
1613
1685
  return withAck("unpin_message", { messageId });
1614
1686
  },
1615
1687
  // markRead and typing are best-effort — silently dropped if socket not ready
1688
+ //
1689
+ // Typing is additionally LEADING-THROTTLED here rather than in the UI layer,
1690
+ // so every consumer (both UI SDKs and any host calling socketEmit directly)
1691
+ // gets the same emit budget and none can bypass it.
1692
+ //
1693
+ // The composer calls this on EVERY keystroke. A 40-word message is ~200 calls
1694
+ // of which exactly one carries information: "this person started typing".
1695
+ // Each one previously cost a round trip plus real server work, making typing
1696
+ // the single most expensive event in the system per unit of user value.
1697
+ //
1698
+ // Shape of the throttle:
1699
+ // isTyping:true — passed through at most once per TYPING_THROTTLE_MS per
1700
+ // conversation. A continuous typist emits ~20/min instead
1701
+ // of ~200/min.
1702
+ // isTyping:false — ALWAYS passed through, and resets the window. It is the
1703
+ // edge that clears the indicator on every peer, it is
1704
+ // already debounced by the composer, and dropping it is
1705
+ // exactly the failure that leaves "is typing…" stuck.
1706
+ //
1707
+ // The server refreshes its typing key on each true edge (10s TTL) and
1708
+ // receivers expire their own indicators after TYPING_EXPIRY_MS, both of which
1709
+ // are comfortably longer than TYPING_THROTTLE_MS — so a throttled-away event
1710
+ // never lets an indicator lapse mid-typing.
1616
1711
  typing(conversationId, isTyping) {
1617
- fireAndForget("typing", { conversationId, isTyping });
1712
+ if (!isTyping) {
1713
+ _lastTypingSentAt.delete(conversationId);
1714
+ fireAndForget("typing", { conversationId, isTyping: false });
1715
+ return;
1716
+ }
1717
+ const now = Date.now();
1718
+ const last = _lastTypingSentAt.get(conversationId) ?? 0;
1719
+ if (now - last < TYPING_THROTTLE_MS) return;
1720
+ _lastTypingSentAt.set(conversationId, now);
1721
+ fireAndForget("typing", { conversationId, isTyping: true });
1618
1722
  },
1619
1723
  markRead(conversationId, messageId) {
1620
1724
  fireAndForget("mark_read", { conversationId, ...messageId ? { messageId } : {} });
@@ -2368,9 +2472,11 @@ var AntzChatClient = class {
2368
2472
  readRetryAfterMs,
2369
2473
  reconnectSocket,
2370
2474
  refreshSocketAuth,
2475
+ registerTeardownHook,
2371
2476
  renderMentionParts,
2372
2477
  resetAuthStore,
2373
2478
  resetTrackedRooms,
2479
+ resetTypingThrottle,
2374
2480
  resolveConfig,
2375
2481
  resolveSystemMessageText,
2376
2482
  setApiClientInstance,