@ttt-productions/chat-react 0.13.7 → 0.13.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.
Files changed (74) hide show
  1. package/dist/context/ChatNameResolverContext.d.ts +24 -16
  2. package/dist/context/ChatNameResolverContext.d.ts.map +1 -1
  3. package/dist/context/ChatNameResolverContext.js +15 -19
  4. package/dist/context/ChatNameResolverContext.js.map +1 -1
  5. package/dist/hooks/useChatMessages.d.ts +9 -0
  6. package/dist/hooks/useChatMessages.d.ts.map +1 -1
  7. package/dist/hooks/useChatMessages.js +10 -10
  8. package/dist/hooks/useChatMessages.js.map +1 -1
  9. package/dist/index.d.ts +6 -6
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +3 -3
  12. package/dist/index.js.map +1 -1
  13. package/dist/realtime/channel-client.d.ts +32 -0
  14. package/dist/realtime/channel-client.d.ts.map +1 -1
  15. package/dist/realtime/channel-client.js +114 -15
  16. package/dist/realtime/channel-client.js.map +1 -1
  17. package/dist/realtime/inbox-client.d.ts +50 -6
  18. package/dist/realtime/inbox-client.d.ts.map +1 -1
  19. package/dist/realtime/inbox-client.js +133 -18
  20. package/dist/realtime/inbox-client.js.map +1 -1
  21. package/dist/realtime/index.d.ts +3 -3
  22. package/dist/realtime/index.d.ts.map +1 -1
  23. package/dist/realtime/index.js +1 -1
  24. package/dist/realtime/index.js.map +1 -1
  25. package/dist/realtime/initial-load-budget.d.ts +31 -0
  26. package/dist/realtime/initial-load-budget.d.ts.map +1 -0
  27. package/dist/realtime/initial-load-budget.js +69 -0
  28. package/dist/realtime/initial-load-budget.js.map +1 -0
  29. package/dist/realtime/map.d.ts.map +1 -1
  30. package/dist/realtime/map.js +3 -2
  31. package/dist/realtime/map.js.map +1 -1
  32. package/dist/realtime/shared.d.ts +13 -0
  33. package/dist/realtime/shared.d.ts.map +1 -1
  34. package/dist/realtime/shared.js +18 -0
  35. package/dist/realtime/shared.js.map +1 -1
  36. package/dist/realtime/transport.d.ts +6 -3
  37. package/dist/realtime/transport.d.ts.map +1 -1
  38. package/dist/realtime/transport.js +1 -0
  39. package/dist/realtime/transport.js.map +1 -1
  40. package/dist/realtime/useRealtimeChatMessages.d.ts +4 -0
  41. package/dist/realtime/useRealtimeChatMessages.d.ts.map +1 -1
  42. package/dist/realtime/useRealtimeChatMessages.js +7 -8
  43. package/dist/realtime/useRealtimeChatMessages.js.map +1 -1
  44. package/dist/realtime/wire.d.ts +11 -6
  45. package/dist/realtime/wire.d.ts.map +1 -1
  46. package/dist/realtime/wire.js +2 -2
  47. package/dist/realtime/wire.js.map +1 -1
  48. package/dist/types.d.ts +16 -19
  49. package/dist/types.d.ts.map +1 -1
  50. package/dist/types.js.map +1 -1
  51. package/dist/ui/ChatShell.d.ts +27 -1
  52. package/dist/ui/ChatShell.d.ts.map +1 -1
  53. package/dist/ui/ChatShell.js +27 -7
  54. package/dist/ui/ChatShell.js.map +1 -1
  55. package/dist/ui/Composer.d.ts.map +1 -1
  56. package/dist/ui/Composer.js +4 -2
  57. package/dist/ui/Composer.js.map +1 -1
  58. package/dist/ui/MessageItemDefault.d.ts +1 -1
  59. package/dist/ui/MessageItemDefault.d.ts.map +1 -1
  60. package/dist/ui/MessageItemDefault.js +10 -6
  61. package/dist/ui/MessageItemDefault.js.map +1 -1
  62. package/dist/ui/MessageList.d.ts +7 -1
  63. package/dist/ui/MessageList.d.ts.map +1 -1
  64. package/dist/ui/MessageList.js +5 -4
  65. package/dist/ui/MessageList.js.map +1 -1
  66. package/dist/ui/SenderName.d.ts +9 -0
  67. package/dist/ui/SenderName.d.ts.map +1 -0
  68. package/dist/ui/SenderName.js +16 -0
  69. package/dist/ui/SenderName.js.map +1 -0
  70. package/package.json +9 -10
  71. package/dist/hooks/useChatThreadAccess.d.ts +0 -8
  72. package/dist/hooks/useChatThreadAccess.d.ts.map +0 -1
  73. package/dist/hooks/useChatThreadAccess.js +0 -19
  74. package/dist/hooks/useChatThreadAccess.js.map +0 -1
@@ -19,6 +19,7 @@ import { CLIENT_FRAME, CHAT_CLOSE_CODES, buildFrame, parseFrame, isInboxSnapshot
19
19
  import { wireRowToMessage, optimisticMessage, seqToMessageId, applyModerationOverlay, overlayFromRow, } from './map.js';
20
20
  import { createDiagnosticsEmitter, safeDiagnosticLabel, CHAT_CLIENT_DIAGNOSTIC_EVENTS as DIAG, } from './diagnostics.js';
21
21
  import { defaultTimers, isChatAccessDeniedError, isTerminalErrorCode, TERMINAL_ERROR_CODE, HEARTBEAT_MS, TYPING_COALESCE_MS, HISTORY_PAGE_MAX, MAX_PENDING_SEND_ATTEMPTS, PENDING_SEND_MAX_AGE_MS, SERVER_RETRYABLE_MAX_AGE_MS, SEND_RETRY_MIN_DELAY_MS, SEND_RETRY_MAX_DELAY_MS, } from './shared.js';
22
+ import { InitialLoadBudget } from './initial-load-budget.js';
22
23
  const INITIAL = {
23
24
  status: 'idle',
24
25
  messages: [],
@@ -28,6 +29,7 @@ const INITIAL = {
28
29
  hasOlder: true,
29
30
  lastErrorCode: null,
30
31
  hasLoadedInitialData: false,
32
+ initialLoadFailed: false,
31
33
  };
32
34
  /**
33
35
  * Retryable error-frame code the channel DO sends when a send arrives BEFORE the
@@ -99,6 +101,15 @@ export class ChannelClient {
99
101
  /** Single scheduled re-send for the membership-pending window (never stacks). */
100
102
  membershipRetryTimer = null;
101
103
  closedByUs = false;
104
+ /**
105
+ * The current lifecycle's number, advanced by every `connect()` and `close()`. A grant
106
+ * that resolves, or a timer that fires, for an older lifecycle is dropped — so a
107
+ * close → connect while an earlier grant is still minting can never open a second
108
+ * socket or touch the new lifecycle's state (FRONTEND-108).
109
+ */
110
+ lifecycle = 0;
111
+ /** Reports the first open as failed once its attempts have failed for the budget. */
112
+ initialLoad;
102
113
  /**
103
114
  * Per-seq moderation overlay (the max-revision wins). A `revision` frame writes
104
115
  * here keyed by `messageSeq` so it applies whether it arrives BEFORE its base
@@ -138,6 +149,7 @@ export class ChannelClient {
138
149
  this.timers = config.timers ?? defaultTimers;
139
150
  this.controller = createReconnectController(config.reconnect ?? {});
140
151
  this.diag = createDiagnosticsEmitter(config.diagnostics);
152
+ this.initialLoad = new InitialLoadBudget(this.timers, () => this.reportInitialLoadFailed());
141
153
  }
142
154
  // ---- observable store ----
143
155
  getState() {
@@ -154,6 +166,37 @@ export class ChannelClient {
154
166
  }
155
167
  // ---- connection lifecycle ----
156
168
  async connect() {
169
+ return this.startLifecycle(true);
170
+ }
171
+ /**
172
+ * Retry now (the first-open failure's Retry). While a reconnect is waiting out its
173
+ * backoff, the wait is cancelled and a socket opens at once; a lifecycle that stopped
174
+ * (reconnect gave up, or the socket was revoked) starts again; an attempt already in
175
+ * flight is left to finish. Either way the first-open failure clears, so the opening state
176
+ * shows while the retry runs. The spent budget stays spent: a retry that fails is reported
177
+ * again at once, and one that hangs a full budget later.
178
+ */
179
+ retryNow() {
180
+ if (!this.state.hasLoadedInitialData)
181
+ this.initialLoad.manualRetry();
182
+ if (this.state.status === 'closed') {
183
+ void this.startLifecycle(false);
184
+ return;
185
+ }
186
+ if (this.reconnectTimer == null) {
187
+ if (this.state.initialLoadFailed)
188
+ this.setState({ initialLoadFailed: false });
189
+ return;
190
+ }
191
+ this.timers.clearTimeout(this.reconnectTimer);
192
+ this.reconnectTimer = null;
193
+ this.controller.reset();
194
+ this.controller.start();
195
+ this.reconnectCause = 'manual-retry';
196
+ this.setState({ status: 'reconnecting', initialLoadFailed: false });
197
+ void this.openSocket();
198
+ }
199
+ async startLifecycle(freshBudget) {
157
200
  // Idempotency guard (C-B7): connect() only starts a lifecycle from a fresh
158
201
  // (idle) or fully torn-down (closed) state. A second connect() while one is
159
202
  // already connecting / open / reconnecting is a no-op, so a stray double-owner
@@ -180,12 +223,27 @@ export class ChannelClient {
180
223
  if (isTerminalErrorCode(this.state.lastErrorCode))
181
224
  this.setState({ lastErrorCode: null });
182
225
  this.closedByUs = false;
226
+ this.lifecycle += 1;
183
227
  this.reconnectCause = 'initial';
228
+ if (!this.state.hasLoadedInitialData)
229
+ this.initialLoad.begin(freshBudget);
184
230
  this.controller.start();
185
- this.setState({ status: 'connecting' });
231
+ this.setState({ status: 'connecting', initialLoadFailed: false });
186
232
  await this.openSocket();
187
233
  }
234
+ /** The first open has failed for long enough to say so (see `initialLoadFailed`). */
235
+ reportInitialLoadFailed() {
236
+ if (this.state.hasLoadedInitialData || this.state.initialLoadFailed)
237
+ return;
238
+ this.setState({ initialLoadFailed: true });
239
+ }
240
+ /** The first authoritative data is applied; it ends any first-open failure. */
241
+ initialDataLoaded() {
242
+ this.initialLoad.loaded();
243
+ return { hasLoadedInitialData: true, initialLoadFailed: false };
244
+ }
188
245
  async openSocket() {
246
+ const lifecycle = this.lifecycle;
189
247
  this.connectAttempt += 1;
190
248
  this.diag?.(DIAG.CONNECT_ATTEMPT, {
191
249
  attempt: this.connectAttempt,
@@ -199,6 +257,9 @@ export class ChannelClient {
199
257
  grantToken = await this.config.grantProvider();
200
258
  }
201
259
  catch (err) {
260
+ // A grant for a lifecycle that has since been closed or restarted answers nobody.
261
+ if (lifecycle !== this.lifecycle)
262
+ return;
202
263
  // A TERMINAL access denial (the app's grant provider translated an
203
264
  // authoritative policy "no" into the package-owned ChatAccessDeniedError)
204
265
  // must NOT reconnect — the next mint would just re-deny, stranding the shell
@@ -210,24 +271,44 @@ export class ChannelClient {
210
271
  if (terminal)
211
272
  return this.denyAccessTerminally();
212
273
  this.reconnectCause = 'grant-error';
274
+ if (!this.state.hasLoadedInitialData)
275
+ this.initialLoad.noteFailedAttempt();
213
276
  return this.scheduleReconnect();
214
277
  }
215
- if (this.closedByUs)
278
+ // A grant minted for an earlier lifecycle must never open a socket in this one.
279
+ if (lifecycle !== this.lifecycle || this.closedByUs)
216
280
  return;
217
281
  // Socket REPLACEMENT: the previous socket's liveness record dies with it, so
218
282
  // the watchdog never judges a new socket against an old deadline.
219
283
  this.lastInboundAt = null;
220
284
  const url = `${this.config.endpoint.replace(/\/$/, '')}/channel`;
221
- this.socket = this.config.socketFactory({
285
+ // Every callback is fenced to THIS socket: once it has been replaced or torn down,
286
+ // its late open, frames, and close are ignored.
287
+ let socket = null;
288
+ const isCurrent = () => socket !== null && this.socket === socket;
289
+ socket = this.config.socketFactory({
222
290
  url,
223
291
  grantToken,
224
292
  handlers: {
225
- onOpen: () => this.onOpen(),
226
- onMessage: (data) => this.onMessage(data),
227
- onClose: (code, reason) => this.onClose(code, reason),
228
- onError: () => this.onError(),
293
+ onOpen: () => {
294
+ if (isCurrent())
295
+ this.onOpen();
296
+ },
297
+ onMessage: (data) => {
298
+ if (isCurrent())
299
+ this.onMessage(data);
300
+ },
301
+ onClose: (code, reason) => {
302
+ if (isCurrent())
303
+ this.onClose(code, reason);
304
+ },
305
+ onError: () => {
306
+ if (isCurrent())
307
+ this.onError();
308
+ },
229
309
  },
230
310
  });
311
+ this.socket = socket;
231
312
  }
232
313
  onOpen() {
233
314
  this.controller.onOpen();
@@ -575,6 +656,9 @@ export class ChannelClient {
575
656
  this.socket = null;
576
657
  this.lastInboundAt = null;
577
658
  this.lastCloseCode = code;
659
+ // An older-history request in flight died with the socket; nothing will answer it.
660
+ if (this.state.isFetchingOlder)
661
+ this.setState({ isFetchingOlder: false });
578
662
  if (this.closedByUs) {
579
663
  this.diag?.(DIAG.SOCKET_CLOSE, { code, closedByUs: true, outcome: 'closed', resumeSeq: this.resumeSeq });
580
664
  this.setState({ status: 'closed' });
@@ -599,9 +683,13 @@ export class ChannelClient {
599
683
  this.diag?.(DIAG.SOCKET_CLOSE, { code, closedByUs: false, outcome: 'revoked', resumeSeq: this.resumeSeq });
600
684
  this.failAllPendingSends();
601
685
  this.setState({ status: 'closed', lastErrorCode: TERMINAL_ERROR_CODE.REVOKED });
686
+ // Revoked before anything loaded: the first open has failed, and nothing retries it.
687
+ this.reportInitialLoadFailed();
602
688
  return;
603
689
  }
604
690
  this.reconnectCause = 'transient-close';
691
+ if (!this.state.hasLoadedInitialData)
692
+ this.initialLoad.noteFailedAttempt();
605
693
  this.diag?.(DIAG.SOCKET_CLOSE, { code, closedByUs: false, outcome: 'reconnect', resumeSeq: this.resumeSeq });
606
694
  this.scheduleReconnect();
607
695
  }
@@ -618,12 +706,15 @@ export class ChannelClient {
618
706
  // an eternal "Sending…"; flip them to the visible failed/retry state.
619
707
  this.failAllPendingSends();
620
708
  this.setState({ status: 'closed' });
709
+ // Gave up before anything loaded: nothing retries the first open any more.
710
+ this.reportInitialLoadFailed();
621
711
  return;
622
712
  }
623
713
  this.setState({ status: 'reconnecting' });
714
+ const lifecycle = this.lifecycle;
624
715
  this.reconnectTimer = this.timers.setTimeout(() => {
625
716
  this.reconnectTimer = null;
626
- if (this.closedByUs)
717
+ if (this.closedByUs || lifecycle !== this.lifecycle)
627
718
  return;
628
719
  void this.openSocket();
629
720
  }, delay);
@@ -640,6 +731,7 @@ export class ChannelClient {
640
731
  denyAccessTerminally() {
641
732
  this.closedByUs = true;
642
733
  this.controller.close();
734
+ this.initialLoad.dispose();
643
735
  this.failAllPendingSends();
644
736
  this.setState({ status: 'closed', lastErrorCode: TERMINAL_ERROR_CODE.ACCESS_DENIED });
645
737
  }
@@ -838,7 +930,7 @@ export class ChannelClient {
838
930
  // caught up) — end initial loading. A resync snapshot instead defers to the
839
931
  // re-paged first history page below, so it must NOT end it here.
840
932
  if (!this.state.hasLoadedInitialData)
841
- this.setState({ hasLoadedInitialData: true });
933
+ this.setState(this.initialDataLoaded());
842
934
  return;
843
935
  }
844
936
  // Resync: drop the local tail and re-page from the top per the pinned contract.
@@ -1003,9 +1095,9 @@ export class ChannelClient {
1003
1095
  isFetchingOlder: false,
1004
1096
  hasOlder: rows.length >= HISTORY_PAGE_MAX,
1005
1097
  // The first history page — INCLUDING an empty one — is a successful authoritative
1006
- // load, so it ends initial loading. (Older-page fetches redundantly re-set true,
1007
- // which is a harmless no-op since the flag never flips back to false.)
1008
- hasLoadedInitialData: true,
1098
+ // load, so it ends initial loading. An older page arrives only after it, so this
1099
+ // never runs a second time.
1100
+ ...(this.state.hasLoadedInitialData ? {} : this.initialDataLoaded()),
1009
1101
  });
1010
1102
  if (!this.diag)
1011
1103
  return;
@@ -1126,11 +1218,14 @@ export class ChannelClient {
1126
1218
  const oldest = this.state.messages.find((m) => typeof m.meta?.seq === 'number');
1127
1219
  const beforeSeq = oldest && typeof oldest.meta?.seq === 'number' ? oldest.meta.seq : null;
1128
1220
  this.setState({ isFetchingOlder: true });
1129
- this.requestHistory(beforeSeq);
1221
+ // Unsent (the socket is down): nothing will answer, so it is not in flight.
1222
+ if (!this.requestHistory(beforeSeq))
1223
+ this.setState({ isFetchingOlder: false });
1130
1224
  }
1131
1225
  requestHistory(beforeSeq) {
1132
1226
  const sent = this.sendFrame(CLIENT_FRAME.HISTORY, { beforeSeq, limit: HISTORY_PAGE_MAX });
1133
1227
  this.diag?.(DIAG.HISTORY_REQUEST, { beforeSeq, limit: HISTORY_PAGE_MAX, sent });
1228
+ return sent;
1134
1229
  }
1135
1230
  // ---- heartbeat (20s) + dead-socket watchdog ----
1136
1231
  startHeartbeat() {
@@ -1187,7 +1282,9 @@ export class ChannelClient {
1187
1282
  /** Permanently close this client (auth-user switch / unmount). Idempotent. */
1188
1283
  close() {
1189
1284
  this.closedByUs = true;
1285
+ this.lifecycle += 1;
1190
1286
  this.controller.close();
1287
+ this.initialLoad.dispose();
1191
1288
  this.stopHeartbeat();
1192
1289
  // The heartbeat timer IS the watchdog's timer — stopping it above ends both.
1193
1290
  // Clearing the record too means a later connect() starts from a clean deadline.
@@ -1210,10 +1307,12 @@ export class ChannelClient {
1210
1307
  this.membershipRetryTimer = null;
1211
1308
  }
1212
1309
  if (this.socket) {
1213
- this.socket.close(1000, 'client teardown');
1310
+ // Detach first: the socket's own late close is not this lifecycle's event.
1311
+ const socket = this.socket;
1214
1312
  this.socket = null;
1313
+ socket.close(1000, 'client teardown');
1215
1314
  }
1216
- this.setState({ status: 'closed', typing: [], presence: [] });
1315
+ this.setState({ status: 'closed', typing: [], presence: [], isFetchingOlder: false });
1217
1316
  }
1218
1317
  }
1219
1318
  function sortBySeq(a, b) {