@antzsoft/chat-core 1.4.6 → 1.4.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.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
  })),
@@ -111,6 +158,7 @@ __export(src_exports, {
111
158
  HIGHLY_FORWARDED_DEPTH_THRESHOLD: () => HIGHLY_FORWARDED_DEPTH_THRESHOLD,
112
159
  MAX_FORWARD_TARGETS: () => MAX_FORWARD_TARGETS,
113
160
  MENTION_ALL_ID: () => MENTION_ALL_ID,
161
+ TRANSIT_OPTIONAL_HEADER: () => TRANSIT_OPTIONAL_HEADER,
114
162
  TransitRateLimitedError: () => TransitRateLimitedError,
115
163
  appConfigApi: () => appConfigApi,
116
164
  authApi: () => authApi,
@@ -147,9 +195,11 @@ __export(src_exports, {
147
195
  readRetryAfterMs: () => readRetryAfterMs,
148
196
  reconnectSocket: () => reconnectSocket,
149
197
  refreshSocketAuth: () => refreshSocketAuth,
198
+ registerTeardownHook: () => registerTeardownHook,
150
199
  renderMentionParts: () => renderMentionParts,
151
200
  resetAuthStore: () => resetAuthStore,
152
201
  resetTrackedRooms: () => resetTrackedRooms,
202
+ resetTypingThrottle: () => resetTypingThrottle,
153
203
  resolveConfig: () => resolveConfig,
154
204
  resolveSystemMessageText: () => resolveSystemMessageText,
155
205
  setApiClientInstance: () => setApiClientInstance,
@@ -776,8 +826,9 @@ function ensureRestTransitHandshake() {
776
826
  waitMs = Math.min(500 * 2 ** failures, 8e3);
777
827
  } catch (err) {
778
828
  if (err instanceof TransitRateLimitedError) {
779
- const blind = Math.min(15e3 * 2 ** rateLimitHits, 6e4);
780
- waitMs = err.retryAfterMs != null ? Math.min(Math.max(err.retryAfterMs, 1e3), 6e4) : blind;
829
+ const ceiling = Math.min(6e4, TRANSIT_GATE_MAX_WAIT_MS);
830
+ const blind = Math.min(15e3 * 2 ** rateLimitHits, ceiling);
831
+ waitMs = err.retryAfterMs != null ? Math.min(Math.max(err.retryAfterMs, 1e3), ceiling) : blind;
781
832
  rateLimitHits++;
782
833
  console.warn(
783
834
  `[AntzChat] transit handshake rate-limited (429) \u2014 retrying in ${Math.round(waitMs / 1e3)}s${err.retryAfterMs != null ? " (per Retry-After)" : ""}.`
@@ -797,6 +848,12 @@ function ensureRestTransitHandshake() {
797
848
  }
798
849
  })();
799
850
  }
851
+ var TRANSIT_OPTIONAL_HEADER = "x-antz-transit-optional";
852
+ function isTransitOptional(req) {
853
+ const present = req.headers?.[TRANSIT_OPTIONAL_HEADER] != null;
854
+ if (present) delete req.headers[TRANSIT_OPTIONAL_HEADER];
855
+ return present;
856
+ }
800
857
  function initApiClient(config, tokenStore) {
801
858
  _config = config;
802
859
  _tokenStore = tokenStore;
@@ -818,7 +875,7 @@ function initApiClient(config, tokenStore) {
818
875
  else if (_config.avatar.url) req.headers["x-avatar-url"] = _config.avatar.url;
819
876
  _avatarSent = true;
820
877
  }
821
- if (_config?.transitEncryption) {
878
+ if (_config?.transitEncryption && !isTransitOptional(req)) {
822
879
  if (!getTransitSession()) ensureRestTransitHandshake();
823
880
  const ready = await awaitTransitReadyOr(TRANSIT_GATE_MAX_WAIT_MS);
824
881
  if (!ready) {
@@ -926,6 +983,7 @@ function getApiClient() {
926
983
  }
927
984
 
928
985
  // src/api/auth.ts
986
+ var teardownRequest = { headers: { [TRANSIT_OPTIONAL_HEADER]: "1" } };
929
987
  var authApi = {
930
988
  async login(credentials) {
931
989
  const { data } = await getApiClient().post("/auth/login", credentials);
@@ -939,11 +997,22 @@ var authApi = {
939
997
  const { data } = await getApiClient().post("/auth/refresh", { refreshToken });
940
998
  return data;
941
999
  },
1000
+ /**
1001
+ * Ends the session. Never blocks on the transit handshake: logout has to work
1002
+ * on a degraded channel, which is exactly when it matters most.
1003
+ *
1004
+ * The refreshToken is dropped when no transit session exists, so it is never
1005
+ * sent in the clear. Losing it costs nothing meaningful — the server revokes
1006
+ * against the JWT's own user id either way; passing it only lets the server
1007
+ * target that one refresh token instead of the caller's session set.
1008
+ */
942
1009
  async logout(refreshToken) {
943
- await getApiClient().post("/auth/logout", refreshToken ? { refreshToken } : {});
1010
+ const encrypted = getTransitSession() != null;
1011
+ const body = refreshToken && encrypted ? { refreshToken } : {};
1012
+ await getApiClient().post("/auth/logout", body, teardownRequest);
944
1013
  },
945
1014
  async logoutAll() {
946
- await getApiClient().post("/auth/logout-all");
1015
+ await getApiClient().post("/auth/logout-all", {}, teardownRequest);
947
1016
  },
948
1017
  async getMe() {
949
1018
  const { data } = await getApiClient().get("/users/me");
@@ -1237,6 +1306,19 @@ function secureOn(socket, event, handler) {
1237
1306
  handler(raw);
1238
1307
  });
1239
1308
  }
1309
+ var _teardownHooks = /* @__PURE__ */ new Set();
1310
+ function registerTeardownHook(hook) {
1311
+ _teardownHooks.add(hook);
1312
+ return () => _teardownHooks.delete(hook);
1313
+ }
1314
+ function runTeardownHooks() {
1315
+ _teardownHooks.forEach((hook) => {
1316
+ try {
1317
+ hook();
1318
+ } catch {
1319
+ }
1320
+ });
1321
+ }
1240
1322
  var _joinedRooms = /* @__PURE__ */ new Set();
1241
1323
  function trackRoomJoin(conversationId) {
1242
1324
  _joinedRooms.add(conversationId);
@@ -1432,6 +1514,10 @@ function disconnectSocket() {
1432
1514
  }
1433
1515
  clearTransitSession();
1434
1516
  resetAlgoCache();
1517
+ runTeardownHooks();
1518
+ Promise.resolve().then(() => (init_chat_store(), chat_store_exports)).then(({ useChatStore: useChatStore2 }) => {
1519
+ useChatStore2.getState().clearTypingUsers();
1520
+ });
1435
1521
  _getToken = null;
1436
1522
  _userId = void 0;
1437
1523
  _tenantId = void 0;
@@ -1483,6 +1569,12 @@ var RECONNECT_WAIT_TIMEOUT = 15e3;
1483
1569
  var QUEUE_MAX_SIZE = 100;
1484
1570
  var QUEUE_ENTRY_TTL = 3e4;
1485
1571
  var sendQueues = /* @__PURE__ */ new Map();
1572
+ var TYPING_THROTTLE_MS = 3e3;
1573
+ var _lastTypingSentAt = /* @__PURE__ */ new Map();
1574
+ function resetTypingThrottle() {
1575
+ _lastTypingSentAt.clear();
1576
+ }
1577
+ registerTeardownHook(resetTypingThrottle);
1486
1578
  var sendQueueRunning = /* @__PURE__ */ new Map();
1487
1579
  async function drainSendQueue(conversationId) {
1488
1580
  if (sendQueueRunning.get(conversationId)) return;
@@ -1613,8 +1705,40 @@ var socketEmit = {
1613
1705
  return withAck("unpin_message", { messageId });
1614
1706
  },
1615
1707
  // markRead and typing are best-effort — silently dropped if socket not ready
1708
+ //
1709
+ // Typing is additionally LEADING-THROTTLED here rather than in the UI layer,
1710
+ // so every consumer (both UI SDKs and any host calling socketEmit directly)
1711
+ // gets the same emit budget and none can bypass it.
1712
+ //
1713
+ // The composer calls this on EVERY keystroke. A 40-word message is ~200 calls
1714
+ // of which exactly one carries information: "this person started typing".
1715
+ // Each one previously cost a round trip plus real server work, making typing
1716
+ // the single most expensive event in the system per unit of user value.
1717
+ //
1718
+ // Shape of the throttle:
1719
+ // isTyping:true — passed through at most once per TYPING_THROTTLE_MS per
1720
+ // conversation. A continuous typist emits ~20/min instead
1721
+ // of ~200/min.
1722
+ // isTyping:false — ALWAYS passed through, and resets the window. It is the
1723
+ // edge that clears the indicator on every peer, it is
1724
+ // already debounced by the composer, and dropping it is
1725
+ // exactly the failure that leaves "is typing…" stuck.
1726
+ //
1727
+ // The server refreshes its typing key on each true edge (10s TTL) and
1728
+ // receivers expire their own indicators after TYPING_EXPIRY_MS, both of which
1729
+ // are comfortably longer than TYPING_THROTTLE_MS — so a throttled-away event
1730
+ // never lets an indicator lapse mid-typing.
1616
1731
  typing(conversationId, isTyping) {
1617
- fireAndForget("typing", { conversationId, isTyping });
1732
+ if (!isTyping) {
1733
+ _lastTypingSentAt.delete(conversationId);
1734
+ fireAndForget("typing", { conversationId, isTyping: false });
1735
+ return;
1736
+ }
1737
+ const now = Date.now();
1738
+ const last = _lastTypingSentAt.get(conversationId) ?? 0;
1739
+ if (now - last < TYPING_THROTTLE_MS) return;
1740
+ _lastTypingSentAt.set(conversationId, now);
1741
+ fireAndForget("typing", { conversationId, isTyping: true });
1618
1742
  },
1619
1743
  markRead(conversationId, messageId) {
1620
1744
  fireAndForget("mark_read", { conversationId, ...messageId ? { messageId } : {} });
@@ -2142,7 +2266,9 @@ var devicesApi = {
2142
2266
  * Call this on logout so the user stops receiving push notifications on this device.
2143
2267
  */
2144
2268
  async remove(deviceId) {
2145
- await getApiClient().post(`/users/me/devices/${deviceId}/remove`);
2269
+ await getApiClient().post(`/users/me/devices/${deviceId}/remove`, {}, {
2270
+ headers: { [TRANSIT_OPTIONAL_HEADER]: "1" }
2271
+ });
2146
2272
  }
2147
2273
  };
2148
2274
 
@@ -2332,6 +2458,7 @@ var AntzChatClient = class {
2332
2458
  HIGHLY_FORWARDED_DEPTH_THRESHOLD,
2333
2459
  MAX_FORWARD_TARGETS,
2334
2460
  MENTION_ALL_ID,
2461
+ TRANSIT_OPTIONAL_HEADER,
2335
2462
  TransitRateLimitedError,
2336
2463
  appConfigApi,
2337
2464
  authApi,
@@ -2368,9 +2495,11 @@ var AntzChatClient = class {
2368
2495
  readRetryAfterMs,
2369
2496
  reconnectSocket,
2370
2497
  refreshSocketAuth,
2498
+ registerTeardownHook,
2371
2499
  renderMentionParts,
2372
2500
  resetAuthStore,
2373
2501
  resetTrackedRooms,
2502
+ resetTypingThrottle,
2374
2503
  resolveConfig,
2375
2504
  resolveSystemMessageText,
2376
2505
  setApiClientInstance,