@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 +18 -4
- 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/index.cjs +116 -10
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +9 -1
- package/dist/index.d.ts +9 -1
- package/dist/index.js +62 -5
- package/dist/index.js.map +1 -1
- package/docs/integration-guide.html +19 -5
- 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/{chat.store-UVTDBPEC.js.map → chat.store-TA6G7PD6.js.map} +0 -0
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,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
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
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,
|