@kispi/chat 0.2.0 → 0.2.2

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.js CHANGED
@@ -147,6 +147,13 @@ var Timeline = class {
147
147
  shared = false;
148
148
  /** The `loadOlder` in flight, so a scroll handler firing twice asks once. */
149
149
  older;
150
+ /**
151
+ * The lowest seq the room still keeps (`minSeq` from the subscribe ack;
152
+ * 1 until told). Below it there is nothing to read, whatever seq says.
153
+ */
154
+ floor = 1;
155
+ /** A `loadOlder` came back short: the top of what the server keeps was reached. */
156
+ exhausted = false;
150
157
  constructor(options) {
151
158
  this.options = options;
152
159
  }
@@ -155,6 +162,22 @@ var Timeline = class {
155
162
  this.shared = true;
156
163
  return this.items;
157
164
  }
165
+ /**
166
+ * Whether `loadOlder()` would find anything, answered without asking.
167
+ *
168
+ * `loadOlder()`의 `hasMore`와 같은 판정이다: 맨 위 행이 방의 보존 하한
169
+ * (`minSeq`, seq는 1부터 구멍 없이 붙는다)보다 위에 있고, 앞선 `loadOlder`가
170
+ * 덜 찬 페이지로 끝을 알린 적이 없으면 더 있다. 첫 `loadOlder()` 전에 "이전
171
+ * 메시지 더 보기"를 그릴지 정할 수 있게 한다. 목록이 비었으면 false다.
172
+ */
173
+ get hasOlder() {
174
+ const first = this.items[0];
175
+ return !this.exhausted && first !== void 0 && first.seq > this.floor;
176
+ }
177
+ /** Records the room's retention floor (`minSeq`). */
178
+ setFloor(minSeq) {
179
+ this.floor = Math.max(1, minSeq);
180
+ }
158
181
  /** The highest seq this timeline holds, hole or no hole. */
159
182
  get highestSeq() {
160
183
  return this.items.at(-1)?.seq ?? 0;
@@ -199,6 +222,7 @@ var Timeline = class {
199
222
  this.fills.clear();
200
223
  this.pendingDeletes.clear();
201
224
  this.items = [];
225
+ this.exhausted = false;
202
226
  this.shared = false;
203
227
  this.changed();
204
228
  }
@@ -226,6 +250,7 @@ var Timeline = class {
226
250
  const page = [...messages].sort((a, b) => a.seq - b.seq);
227
251
  const top = page.at(-1)?.seq ?? 0;
228
252
  this.items = [...page, ...this.items.filter((m) => m.seq > top)];
253
+ this.exhausted = false;
229
254
  this.shared = false;
230
255
  for (const m of this.items) {
231
256
  if (this.pendingDeletes.has(m.id)) this.applyDelete(m.id);
@@ -276,20 +301,35 @@ var Timeline = class {
276
301
  *
277
302
  * The server sends the new `count` with the event, so this is a
278
303
  * replacement rather than an increment: two clients reacting at once
279
- * cannot drift the way `+1`/`-1` would.
304
+ * cannot drift the way `+1`/`-1` would. `count` undefined leaves the
305
+ * aggregate alone (the react ack path); `mine` undefined leaves
306
+ * `myReactions` alone (somebody else's event).
280
307
  */
281
- reaction(messageId, emoji, count) {
308
+ reaction(messageId, emoji, count, mine) {
282
309
  const at = this.items.findIndex((m) => m.id === messageId);
283
310
  if (at === -1) return;
284
311
  const current = this.items[at];
285
- const existing = current.reactions ?? [];
286
- const slot = existing.findIndex((r) => r.emoji === emoji);
287
- let next;
288
- if (count <= 0) next = existing.filter((r) => r.emoji !== emoji);
289
- else if (slot === -1) next = [...existing, { emoji, count }];
290
- else next = existing.map((r, i) => i === slot ? { emoji, count } : r);
312
+ let reactions = current.reactions;
313
+ if (count !== void 0) {
314
+ const existing = current.reactions ?? [];
315
+ const slot = existing.findIndex((r) => r.emoji === emoji);
316
+ if (count <= 0) reactions = existing.filter((r) => r.emoji !== emoji);
317
+ else if (slot === -1) reactions = [...existing, { emoji, count }];
318
+ else reactions = existing.map((r, i) => i === slot ? { emoji, count } : r);
319
+ }
320
+ let myReactions = current.myReactions;
321
+ if (mine !== void 0) {
322
+ const held = current.myReactions ?? [];
323
+ const has = held.includes(emoji);
324
+ if (mine && !has) myReactions = [...held, emoji];
325
+ else if (!mine && has) myReactions = held.filter((e) => e !== emoji);
326
+ }
327
+ if (reactions === current.reactions && myReactions === current.myReactions) return;
328
+ const next = { ...current };
329
+ if (reactions !== void 0) next.reactions = reactions;
330
+ if (myReactions !== void 0) next.myReactions = myReactions;
291
331
  this.own();
292
- this.items[at] = { ...current, reactions: next };
332
+ this.items[at] = next;
293
333
  this.changed();
294
334
  }
295
335
  /** Applies a `thread.updated` to the root message's aggregate. */
@@ -326,14 +366,16 @@ var Timeline = class {
326
366
  }
327
367
  async readOlder(limit) {
328
368
  const first = this.items[0];
329
- if (first === void 0 || first.seq <= 1) return { messages: [], hasMore: false };
369
+ if (first === void 0 || first.seq <= this.floor) return { messages: [], hasMore: false };
330
370
  const epoch = this.epoch;
331
371
  const page = await this.options.fetchRange({ after: 0, before: first.seq, limit });
332
372
  if (this.epoch !== epoch) return { messages: [], hasMore: true };
333
373
  for (const m of page) this.insert(m, false);
334
374
  if (page.length > 0) this.changed();
335
375
  const top = page[0];
336
- return { messages: page, hasMore: page.length >= limit && top !== void 0 && top.seq > 1 };
376
+ const hasMore = page.length >= limit && top !== void 0 && top.seq > this.floor;
377
+ this.exhausted = !hasMore;
378
+ return { messages: page, hasMore };
337
379
  }
338
380
  /**
339
381
  * Fetches everything between what we have and `upTo`.
@@ -424,6 +466,8 @@ function createTimeline(options) {
424
466
 
425
467
  // src/room.ts
426
468
  var HistoryPageSize = 100;
469
+ var MaxRepairs = 5;
470
+ var RepairBaseMs = 1e3;
427
471
  var Room = class extends Emitter {
428
472
  /** Resolved on subscribe when the room was addressed by key. */
429
473
  id;
@@ -461,6 +505,9 @@ var Room = class extends Emitter {
461
505
  */
462
506
  wanted = false;
463
507
  subscribing;
508
+ /** Self-repair: the armed retry, and how many have run since the last success. */
509
+ repairTimer;
510
+ repairs = 0;
464
511
  constructor(chat, rest, address) {
465
512
  super();
466
513
  this.chat = chat;
@@ -476,6 +523,17 @@ var Room = class extends Emitter {
476
523
  onChange: (messages) => this.emit("messages", messages)
477
524
  });
478
525
  }
526
+ /**
527
+ * Whether older history exists above the first message held -- what
528
+ * `loadOlder()` would report as `hasMore`, known before calling it.
529
+ *
530
+ * Derived from the first row's seq against the room's retention floor
531
+ * (the subscribe ack's `minSeq`), and false once a `loadOlder()` came
532
+ * back short. False while nothing is loaded. Re-read it on `messages`.
533
+ */
534
+ get hasOlder() {
535
+ return this.timeline.hasOlder;
536
+ }
479
537
  /** Everything this client knows about the room, in seq order. A new array whenever it changes. */
480
538
  get messages() {
481
539
  return this.timeline.messages;
@@ -527,7 +585,12 @@ var Room = class extends Emitter {
527
585
  this.wanted = true;
528
586
  if (this.subscribing !== void 0) return this.subscribing;
529
587
  if (this.subscribed) return;
530
- const attempt = this.doSubscribe().finally(() => {
588
+ const attempt = this.doSubscribe().then(() => {
589
+ const successor = this.subscribing;
590
+ if (successor !== void 0 && successor !== attempt) return successor;
591
+ if (this.subscribed) this.repairs = 0;
592
+ return void 0;
593
+ }).finally(() => {
531
594
  if (this.subscribing === attempt) this.subscribing = void 0;
532
595
  });
533
596
  this.subscribing = attempt;
@@ -536,6 +599,10 @@ var Room = class extends Emitter {
536
599
  async doSubscribe() {
537
600
  const gen = this.generation;
538
601
  const current = () => gen === this.generation;
602
+ if (this.chat.state !== "open") {
603
+ await this.chat.whenOpen();
604
+ if (!current()) return;
605
+ }
539
606
  const data = this.id !== void 0 ? { roomId: this.id } : { key: this.key };
540
607
  const since = this.timeline.contiguousSeq;
541
608
  if (since > 0) data["since"] = since;
@@ -545,6 +612,7 @@ var Room = class extends Emitter {
545
612
  this.id = ack.roomId;
546
613
  this.chat.registerRoomId(ack.roomId, this);
547
614
  this.lastSeq = ack.lastSeq;
615
+ if (typeof ack.minSeq === "number") this.timeline.setFloor(ack.minSeq);
548
616
  if (ack.presence !== void 0) this.presence = ack.presence;
549
617
  if (attempt.wasReset) {
550
618
  await this.loadRecent(current);
@@ -588,12 +656,20 @@ var Room = class extends Emitter {
588
656
  });
589
657
  return page.messages;
590
658
  }
591
- /** Adds a reaction. Idempotent, like the frame. */
659
+ /**
660
+ * Adds a reaction. Idempotent, like the frame.
661
+ *
662
+ * The ack updates the row's `myReactions` (not its count -- see below),
663
+ * so the "I reacted" state is right even if the broadcast is missed.
664
+ */
592
665
  async react(messageId, emoji) {
593
666
  await this.chat.send("react", { roomId: await this.resolveId("reacting"), messageId, emoji, op: "add" });
667
+ this.timeline.reaction(messageId, emoji, void 0, true);
594
668
  }
669
+ /** Removes a reaction. Idempotent; the ack clears it from `myReactions`. */
595
670
  async unreact(messageId, emoji) {
596
671
  await this.chat.send("react", { roomId: await this.resolveId("reacting"), messageId, emoji, op: "remove" });
672
+ this.timeline.reaction(messageId, emoji, void 0, false);
597
673
  }
598
674
  /**
599
675
  * Moves this user's read cursor.
@@ -665,13 +741,33 @@ var Room = class extends Emitter {
665
741
  * transient 500 takes a room out of the live feed for the life of the
666
742
  * page.
667
743
  */
668
- async resume() {
744
+ async resume(restart = true) {
669
745
  if (!this.wanted) return;
746
+ if (!restart) return this.subscribe();
670
747
  this.generation++;
671
748
  this.subscribing = void 0;
672
749
  this.subscribed = false;
673
750
  await this.subscribe();
674
751
  }
752
+ /**
753
+ * Reports a failure no caller was waiting for, and repairs.
754
+ *
755
+ * 백그라운드 실패(재접속 뒤 다시 구독, 구멍 메우기)에는 거절을 받을 호출자가
756
+ * 없다. 이벤트만 내고 두면 방은 다음 재접속이나 탭 복귀까지 구멍을 안고 —
757
+ * 구독 프레임부터 실패했다면 라이브 피드 밖에서 — 머문다. 그래서 스스로 다시
758
+ * 구독한다. 횟수를 묶는 것은 고칠 수 없는 실패(권한, 없어진 방)가 서버를
759
+ * 계속 두드리지 않게 하려는 것이고, 그 뒤는 재접속과 `subscribe()`의 몫이다.
760
+ */
761
+ failed(err) {
762
+ this.emit("error", err instanceof Error ? err : new Error(String(err)));
763
+ if (this.repairTimer !== void 0 || this.repairs >= MaxRepairs) return;
764
+ const delay = RepairBaseMs * 2 ** this.repairs++;
765
+ this.repairTimer = setTimeout(() => {
766
+ this.repairTimer = void 0;
767
+ if (!this.wanted || this.subscribed || this.chat.state !== "open") return;
768
+ this.subscribe().catch((e) => this.failed(e));
769
+ }, delay);
770
+ }
675
771
  /**
676
772
  * Sends `subscribe` and recovers from the one error it has an answer
677
773
  * for.
@@ -753,7 +849,7 @@ var Room = class extends Emitter {
753
849
  if (data.seq > this.lastSeq) this.lastSeq = data.seq;
754
850
  this.timeline.add(data).catch((err) => {
755
851
  this.subscribed = false;
756
- this.emit("error", err instanceof Error ? err : new Error(String(err)));
852
+ this.failed(err);
757
853
  });
758
854
  break;
759
855
  case "message.updated":
@@ -768,7 +864,9 @@ var Room = class extends Emitter {
768
864
  case "reaction.added":
769
865
  case "reaction.removed": {
770
866
  const r = data;
771
- this.timeline.reaction(r.messageId, r.emoji, r.count);
867
+ const me = this.chat.user?.id;
868
+ const mine = me !== void 0 && r.userId === me ? type === "reaction.added" : void 0;
869
+ this.timeline.reaction(r.messageId, r.emoji, r.count, mine);
772
870
  break;
773
871
  }
774
872
  case "thread.updated": {
@@ -822,8 +920,11 @@ var ChatClient = class extends Emitter {
822
920
  heartbeat;
823
921
  retryTimer;
824
922
  attempt = 0;
825
- /** Set by close(), and the only thing that stops the reconnect loop. */
826
- closedByCaller = false;
923
+ /**
924
+ * The reconnect loop is off: set by close(), and by a failure that
925
+ * retrying cannot fix (see `stop`). Cleared by connect().
926
+ */
927
+ stopped = false;
827
928
  nextFrameId = 0;
828
929
  opening;
829
930
  rest;
@@ -901,7 +1002,7 @@ var ChatClient = class extends Emitter {
901
1002
  * gap-filled via REST.
902
1003
  */
903
1004
  async resume() {
904
- if (this.closedByCaller) return;
1005
+ if (this.stopped) return;
905
1006
  if (this.state !== "open" || this.socket === void 0) {
906
1007
  this.clearTimers();
907
1008
  return this.connect();
@@ -913,9 +1014,7 @@ var ChatClient = class extends Emitter {
913
1014
  return this.connect();
914
1015
  }
915
1016
  for (const room of this.handles.values()) {
916
- void room.resume().catch((err) => {
917
- room.emit("error", err instanceof Error ? err : new Error(String(err)));
918
- });
1017
+ void room.resume().catch((err) => room.failed(err));
919
1018
  }
920
1019
  }
921
1020
  /** The account-level routes: the rooms this user is in, and unread. */
@@ -1007,7 +1106,7 @@ var ChatClient = class extends Emitter {
1007
1106
  }
1008
1107
  /** Opens the connection and resolves when `hello` arrives. */
1009
1108
  async connect() {
1010
- this.closedByCaller = false;
1109
+ this.stopped = false;
1011
1110
  if (this.opening !== void 0) return this.opening;
1012
1111
  if (this.state === "open" && this.socket !== void 0) return;
1013
1112
  this.clearTimers();
@@ -1016,15 +1115,58 @@ var ChatClient = class extends Emitter {
1016
1115
  });
1017
1116
  return this.opening;
1018
1117
  }
1118
+ /**
1119
+ * Re-authenticates: calls `token()` again and replaces the socket, keeping
1120
+ * every room handle and every subscription.
1121
+ *
1122
+ * For when the identity the server holds has to change mid-session --
1123
+ * a nickname change, a new avatar, fresh claims. The server reads the
1124
+ * token once, at `auth`, so `sender.name` on the next message is the old
1125
+ * one until the connection is re-authenticated. `close()` + `connect()`
1126
+ * does that too, but passes through `closed` (a consumer's "you are
1127
+ * offline" screen) and tears down the page listeners.
1128
+ *
1129
+ * States: `open` → `reconnecting` → `open`, never `closed`. Rooms
1130
+ * resubscribe from what they hold and catch up the gap, as after any
1131
+ * drop; `messages` is kept. Frames awaiting an ack on the old socket are
1132
+ * rejected with `closed`. `chat.user` is the new identity once this
1133
+ * resolves. If `token()` fails the client keeps retrying in the
1134
+ * background like any reconnect (and this rejects); a `ChatError` from
1135
+ * `token()` stops it at `closed`, as on connect.
1136
+ *
1137
+ * On a client that is not connected (never connected, or `close()`d) it
1138
+ * is `connect()`.
1139
+ */
1140
+ async reconnect() {
1141
+ if (this.opening !== void 0) await this.opening.catch(() => {
1142
+ });
1143
+ const socket = this.socket;
1144
+ if (socket === void 0 || this.state !== "open") return this.connect();
1145
+ this.clearTimers();
1146
+ this.retired.add(socket);
1147
+ this.socket = void 0;
1148
+ this.failPending(new ChatError("closed", "the connection was replaced by reconnect()"));
1149
+ try {
1150
+ socket.close(1e3, "client reconnecting");
1151
+ } catch {
1152
+ }
1153
+ this.opening = this.openOnce("reconnecting").finally(() => {
1154
+ this.opening = void 0;
1155
+ });
1156
+ return this.opening;
1157
+ }
1158
+ /** Sockets `reconnect()` replaced: their late events are not this client's any more. */
1159
+ retired = /* @__PURE__ */ new WeakSet();
1019
1160
  /**
1020
1161
  * Closes for good.
1021
1162
  *
1022
1163
  * The distinction from a dropped socket is the whole point of the state
1023
- * machine: this is the only path that reaches `closed`, and a consumer
1024
- * showing "disconnected, retrying" versus "disconnected" needs it.
1164
+ * machine: `closed` means nothing is coming back, and a consumer showing
1165
+ * "disconnected, retrying" versus "disconnected" needs it. The other
1166
+ * ways to reach it are refusals retrying cannot fix -- see `stop`.
1025
1167
  */
1026
1168
  async close() {
1027
- this.closedByCaller = true;
1169
+ this.stopped = true;
1028
1170
  this.teardowns.forEach((t) => {
1029
1171
  try {
1030
1172
  t();
@@ -1041,7 +1183,53 @@ var ChatClient = class extends Emitter {
1041
1183
  } catch {
1042
1184
  }
1043
1185
  }
1186
+ this.settleOpenWaiters(new ChatError("closed", "the connection is closed"));
1187
+ this.setState("closed");
1188
+ }
1189
+ /**
1190
+ * Stops for a reason retrying cannot fix, and says which.
1191
+ *
1192
+ * `close()` without the teardown of the page listeners -- a consumer
1193
+ * that logs back in calls `connect()` on the same client -- and with an
1194
+ * `error` event, because on a reconnect there is no caller to reject.
1195
+ */
1196
+ stop(err) {
1197
+ this.stopped = true;
1198
+ this.clearTimers();
1199
+ const socket = this.socket;
1200
+ this.socket = void 0;
1201
+ if (socket !== void 0) {
1202
+ try {
1203
+ socket.close(1e3, "client stopped");
1204
+ } catch {
1205
+ }
1206
+ }
1207
+ this.failPending(err);
1208
+ this.settleOpenWaiters(new ChatError("closed", "the connection is closed"));
1044
1209
  this.setState("closed");
1210
+ this.emit("error", err);
1211
+ }
1212
+ /**
1213
+ * Resolves once the connection is open. Used by `Room.subscribe`.
1214
+ *
1215
+ * 연결 전에 부른 `subscribe()`를 `closed`로 거절하면 소비자는 "언제 다시
1216
+ * 부를지"를 스스로 알아내야 하고, 첫 connect가 실패했다면 그 때는 영영 오지
1217
+ * 않는다. 붙을 때까지 기다리면 그 신호가 필요 없다. 멈춘 클라이언트(close,
1218
+ * 인증 거절)는 붙을 일이 없으니 거절한다.
1219
+ */
1220
+ whenOpen() {
1221
+ if (this.state === "open" && this.socket !== void 0) return Promise.resolve();
1222
+ if (this.stopped) return Promise.reject(new ChatError("closed", "the connection is closed"));
1223
+ return new Promise((resolve, reject) => void this.openWaiters.push({ resolve, reject }));
1224
+ }
1225
+ openWaiters = [];
1226
+ settleOpenWaiters(err) {
1227
+ const waiters = this.openWaiters;
1228
+ this.openWaiters = [];
1229
+ for (const w of waiters) {
1230
+ if (err === void 0) w.resolve();
1231
+ else w.reject(err);
1232
+ }
1045
1233
  }
1046
1234
  /** Sends a frame and resolves with its ack, or rejects with its error. */
1047
1235
  send(type, data, timeoutMs = 15e3) {
@@ -1082,16 +1270,20 @@ var ChatClient = class extends Emitter {
1082
1270
  this.state = next;
1083
1271
  this.emit("state", next);
1084
1272
  }
1085
- async openOnce() {
1086
- this.setState(this.attempt === 0 ? "connecting" : "reconnecting");
1273
+ async openOnce(state = this.attempt === 0 ? "connecting" : "reconnecting") {
1274
+ this.setState(state);
1087
1275
  let authData;
1088
1276
  try {
1089
1277
  authData = await this.authData();
1090
1278
  } catch (err) {
1279
+ if (isChatError(err)) {
1280
+ this.stop(err);
1281
+ throw err;
1282
+ }
1091
1283
  this.scheduleReconnect();
1092
1284
  throw err;
1093
1285
  }
1094
- if (this.closedByCaller) {
1286
+ if (this.stopped) {
1095
1287
  throw new ChatError("closed", "the client was closed while connecting");
1096
1288
  }
1097
1289
  const socket = new this.makeSocket(`${this.options.url}/v1/ws?v=1`);
@@ -1105,6 +1297,7 @@ var ChatClient = class extends Emitter {
1105
1297
  else reject(err);
1106
1298
  };
1107
1299
  socket.addEventListener("message", (ev) => {
1300
+ if (this.retired.has(socket)) return;
1108
1301
  const frame = JSON.parse(String(ev.data));
1109
1302
  if (frame.type === "hello") {
1110
1303
  this.onHello(frame.data);
@@ -1112,14 +1305,20 @@ var ChatClient = class extends Emitter {
1112
1305
  return;
1113
1306
  }
1114
1307
  if (frame.type === "error" && !settled) {
1115
- this.closedByCaller = true;
1116
- this.setState("closed");
1117
- finish(errorFrom(frame.data));
1308
+ const err = errorFrom(frame.data);
1309
+ if (err.code === "rate_limited" || err.code === "internal") {
1310
+ if (err.retryAfterMs !== void 0 && err.retryAfterMs > 0) this.plannedDelayMs = err.retryAfterMs;
1311
+ finish(err);
1312
+ return;
1313
+ }
1314
+ finish(err);
1315
+ this.stop(err);
1118
1316
  return;
1119
1317
  }
1120
1318
  this.onFrame(frame);
1121
1319
  });
1122
1320
  socket.addEventListener("close", () => {
1321
+ if (this.retired.has(socket)) return;
1123
1322
  if (this.socket !== void 0 && this.socket !== socket) return;
1124
1323
  this.clearTimers();
1125
1324
  this.socket = void 0;
@@ -1147,12 +1346,11 @@ var ChatClient = class extends Emitter {
1147
1346
  const reconnected = this.everOpened;
1148
1347
  this.everOpened = true;
1149
1348
  this.setState("open");
1150
- if (reconnected) {
1151
- for (const room of this.handles.values()) {
1152
- void room.resume().catch((err) => {
1153
- room.emit("error", err instanceof Error ? err : new Error(String(err)));
1154
- });
1155
- }
1349
+ this.settleOpenWaiters();
1350
+ for (const room of this.handles.values()) {
1351
+ void room.resume(reconnected).catch((err) => {
1352
+ room.failed(err);
1353
+ });
1156
1354
  }
1157
1355
  }
1158
1356
  onFrame(frame) {
@@ -1222,7 +1420,7 @@ var ChatClient = class extends Emitter {
1222
1420
  }, Math.max(1e3, intervalMs));
1223
1421
  }
1224
1422
  scheduleReconnect() {
1225
- if (this.closedByCaller) return;
1423
+ if (this.stopped) return;
1226
1424
  this.setState("reconnecting");
1227
1425
  const delay = this.nextDelay();
1228
1426
  this.retryTimer = setTimeout(() => {
@@ -1274,6 +1472,9 @@ var ChatClient = class extends Emitter {
1274
1472
  const token = await this.options.token();
1275
1473
  this.lastToken = token;
1276
1474
  return token;
1475
+ } catch (err) {
1476
+ if (isChatError(err)) this.stop(err);
1477
+ throw err;
1277
1478
  } finally {
1278
1479
  this.refreshing = void 0;
1279
1480
  }
@@ -1295,6 +1496,9 @@ var ChatClient = class extends Emitter {
1295
1496
  this.retryTimer = void 0;
1296
1497
  }
1297
1498
  };
1499
+ function isChatError(err) {
1500
+ return err instanceof ChatError || err instanceof Error && err.name === "ChatError";
1501
+ }
1298
1502
  function createChatClient(options) {
1299
1503
  return new ChatClient(options);
1300
1504
  }
@@ -274,6 +274,7 @@ function createChatServer(options) {
274
274
  custom: async (roomId, payload) => {
275
275
  await rest.call("POST", `/v1/rooms/${roomId}/custom`, { payload });
276
276
  },
277
+ presence: (roomId, o) => rest.call("GET", `/v1/rooms/${roomId}/presence${rest.query({ full: o?.full ? "true" : void 0 })}`),
277
278
  members: {
278
279
  list: (roomId, o) => rest.call("GET", `/v1/rooms/${roomId}/members${rest.query({ cursor: o?.cursor, limit: o?.limit })}`),
279
280
  add: async (roomId, userId, role) => {
@@ -320,6 +321,7 @@ function createChatServer(options) {
320
321
  // the messages left standing. Nothing failed; the caller was told
321
322
  // it had worked.
322
323
  delete: (userId, o) => rest.call("DELETE", `/v1/users/${userId}${rest.query({ purge_messages: o?.purgeMessages ? "true" : void 0 })}`),
324
+ purgeMessages: (userId) => rest.call("DELETE", `/v1/users/${userId}/messages`),
323
325
  revokeTokens: async (userId) => {
324
326
  await rest.call("POST", `/v1/users/${userId}/revoke-tokens`);
325
327
  },
@@ -109,6 +109,25 @@ type ServerSendInput = {
109
109
  replyTo?: string;
110
110
  threadId?: string;
111
111
  };
112
+ type RoomPresence = {
113
+ count: number;
114
+ /** Only with `full: true`. */
115
+ users?: {
116
+ id: string;
117
+ name: string;
118
+ avatar?: string;
119
+ meta?: Record<string, unknown>;
120
+ }[];
121
+ capped?: boolean;
122
+ };
123
+ /** What `users.purgeMessages` and the withdrawal purge report. */
124
+ type PurgeMessagesResult = {
125
+ userId: string;
126
+ /** Messages this call turned into tombstones. 0 on a repeat call after a finished one. */
127
+ purgedMessages: number;
128
+ /** The per-call cap was hit: **call again** until this is false. */
129
+ purgeCapped: boolean;
130
+ };
112
131
  type GuestInput = Omit<TokenInput, 'userId'> & {
113
132
  /** What `guest()` returned last time, as the browser kept it. Missing or invalid means a new guest. */
114
133
  credential?: string | null;
@@ -169,6 +188,14 @@ type ChatServer = {
169
188
  * in one frame). `sk_` only, like every other route on this object.
170
189
  */
171
190
  custom: (roomId: string, payload: Record<string, unknown>) => Promise<void>;
191
+ /**
192
+ * Who is in the room right now. `{count}` by default; `full: true` adds
193
+ * `users` (at most 1000, `capped: true` when cut). An empty room is
194
+ * `{count: 0}`, not a 404.
195
+ */
196
+ presence: (roomId: string, options?: {
197
+ full?: boolean;
198
+ }) => Promise<RoomPresence>;
172
199
  members: {
173
200
  list: (roomId: string, options?: {
174
201
  cursor?: string;
@@ -216,6 +243,17 @@ type ChatServer = {
216
243
  avatar?: string;
217
244
  meta?: Record<string, unknown>;
218
245
  }) => Promise<unknown>;
246
+ /**
247
+ * Moderation: deletes every live message this user sent in the app
248
+ * (tombstones -- seq stays gap-free, clients get `message.deleted`),
249
+ * **without** withdrawing them. The name stays on the tombstones.
250
+ *
251
+ * Ban first (`ban`), then call this: a message posted while it runs is
252
+ * not in its listing. Bounded per call -- loop while `purgeCapped`.
253
+ * A withdrawn user is 404; their messages go with
254
+ * `delete(userId, { purgeMessages: true })`.
255
+ */
256
+ purgeMessages: (userId: string) => Promise<PurgeMessagesResult>;
219
257
  /** Withdrawal: anonymises rather than deleting rows. */
220
258
  delete: (userId: string, options?: {
221
259
  purgeMessages?: boolean;
@@ -256,4 +294,4 @@ type ChatServer = {
256
294
  };
257
295
  declare function createChatServer(options: ChatServerOptions): ChatServer;
258
296
 
259
- export { type ChatServer, type ChatServerOptions, DefaultTokenTtlSeconds, type EnsureRoomInput, GuestIdPrefix, type GuestIdentity, type GuestInput, MaxTokenTtlSeconds, type ServerSendInput, SignatureToleranceMs, type TokenInput, type VerifiedDelivery, type VerifyOptions, createChatServer };
297
+ export { type ChatServer, type ChatServerOptions, DefaultTokenTtlSeconds, type EnsureRoomInput, GuestIdPrefix, type GuestIdentity, type GuestInput, MaxTokenTtlSeconds, type PurgeMessagesResult, type RoomPresence, type ServerSendInput, SignatureToleranceMs, type TokenInput, type VerifiedDelivery, type VerifyOptions, createChatServer };
@@ -109,6 +109,25 @@ type ServerSendInput = {
109
109
  replyTo?: string;
110
110
  threadId?: string;
111
111
  };
112
+ type RoomPresence = {
113
+ count: number;
114
+ /** Only with `full: true`. */
115
+ users?: {
116
+ id: string;
117
+ name: string;
118
+ avatar?: string;
119
+ meta?: Record<string, unknown>;
120
+ }[];
121
+ capped?: boolean;
122
+ };
123
+ /** What `users.purgeMessages` and the withdrawal purge report. */
124
+ type PurgeMessagesResult = {
125
+ userId: string;
126
+ /** Messages this call turned into tombstones. 0 on a repeat call after a finished one. */
127
+ purgedMessages: number;
128
+ /** The per-call cap was hit: **call again** until this is false. */
129
+ purgeCapped: boolean;
130
+ };
112
131
  type GuestInput = Omit<TokenInput, 'userId'> & {
113
132
  /** What `guest()` returned last time, as the browser kept it. Missing or invalid means a new guest. */
114
133
  credential?: string | null;
@@ -169,6 +188,14 @@ type ChatServer = {
169
188
  * in one frame). `sk_` only, like every other route on this object.
170
189
  */
171
190
  custom: (roomId: string, payload: Record<string, unknown>) => Promise<void>;
191
+ /**
192
+ * Who is in the room right now. `{count}` by default; `full: true` adds
193
+ * `users` (at most 1000, `capped: true` when cut). An empty room is
194
+ * `{count: 0}`, not a 404.
195
+ */
196
+ presence: (roomId: string, options?: {
197
+ full?: boolean;
198
+ }) => Promise<RoomPresence>;
172
199
  members: {
173
200
  list: (roomId: string, options?: {
174
201
  cursor?: string;
@@ -216,6 +243,17 @@ type ChatServer = {
216
243
  avatar?: string;
217
244
  meta?: Record<string, unknown>;
218
245
  }) => Promise<unknown>;
246
+ /**
247
+ * Moderation: deletes every live message this user sent in the app
248
+ * (tombstones -- seq stays gap-free, clients get `message.deleted`),
249
+ * **without** withdrawing them. The name stays on the tombstones.
250
+ *
251
+ * Ban first (`ban`), then call this: a message posted while it runs is
252
+ * not in its listing. Bounded per call -- loop while `purgeCapped`.
253
+ * A withdrawn user is 404; their messages go with
254
+ * `delete(userId, { purgeMessages: true })`.
255
+ */
256
+ purgeMessages: (userId: string) => Promise<PurgeMessagesResult>;
219
257
  /** Withdrawal: anonymises rather than deleting rows. */
220
258
  delete: (userId: string, options?: {
221
259
  purgeMessages?: boolean;
@@ -256,4 +294,4 @@ type ChatServer = {
256
294
  };
257
295
  declare function createChatServer(options: ChatServerOptions): ChatServer;
258
296
 
259
- export { type ChatServer, type ChatServerOptions, DefaultTokenTtlSeconds, type EnsureRoomInput, GuestIdPrefix, type GuestIdentity, type GuestInput, MaxTokenTtlSeconds, type ServerSendInput, SignatureToleranceMs, type TokenInput, type VerifiedDelivery, type VerifyOptions, createChatServer };
297
+ export { type ChatServer, type ChatServerOptions, DefaultTokenTtlSeconds, type EnsureRoomInput, GuestIdPrefix, type GuestIdentity, type GuestInput, MaxTokenTtlSeconds, type PurgeMessagesResult, type RoomPresence, type ServerSendInput, SignatureToleranceMs, type TokenInput, type VerifiedDelivery, type VerifyOptions, createChatServer };
@@ -217,6 +217,7 @@ function createChatServer(options) {
217
217
  custom: async (roomId, payload) => {
218
218
  await rest.call("POST", `/v1/rooms/${roomId}/custom`, { payload });
219
219
  },
220
+ presence: (roomId, o) => rest.call("GET", `/v1/rooms/${roomId}/presence${rest.query({ full: o?.full ? "true" : void 0 })}`),
220
221
  members: {
221
222
  list: (roomId, o) => rest.call("GET", `/v1/rooms/${roomId}/members${rest.query({ cursor: o?.cursor, limit: o?.limit })}`),
222
223
  add: async (roomId, userId, role) => {
@@ -263,6 +264,7 @@ function createChatServer(options) {
263
264
  // the messages left standing. Nothing failed; the caller was told
264
265
  // it had worked.
265
266
  delete: (userId, o) => rest.call("DELETE", `/v1/users/${userId}${rest.query({ purge_messages: o?.purgeMessages ? "true" : void 0 })}`),
267
+ purgeMessages: (userId) => rest.call("DELETE", `/v1/users/${userId}/messages`),
266
268
  revokeTokens: async (userId) => {
267
269
  await rest.call("POST", `/v1/users/${userId}/revoke-tokens`);
268
270
  },