@kispi/chat 0.2.1 → 0.2.3

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,25 @@ 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;
157
+ /**
158
+ * Presses this client has made and the server has not answered yet, one
159
+ * entry per (message, emoji) pair, each remembering the last value the
160
+ * **server** gave for that pair.
161
+ *
162
+ * 되돌릴 자리로 "누르기 직전 화면값"을 쓰면 안 된다. 눌렀다 곧바로 취소하고
163
+ * 두 거절이 차례로 오면, 두 번째 거절이 첫 번째의 낙관값 — 서버가 한 번도
164
+ * 가진 적 없는 값 — 을 화면에 되살린다. 마지막으로 서버가 말한 값만이
165
+ * 되돌릴 자리다.
166
+ */
167
+ presses = /* @__PURE__ */ new Map();
168
+ pressSeq = 0;
150
169
  constructor(options) {
151
170
  this.options = options;
152
171
  }
@@ -155,6 +174,22 @@ var Timeline = class {
155
174
  this.shared = true;
156
175
  return this.items;
157
176
  }
177
+ /**
178
+ * Whether `loadOlder()` would find anything, answered without asking.
179
+ *
180
+ * `loadOlder()`의 `hasMore`와 같은 판정이다: 맨 위 행이 방의 보존 하한
181
+ * (`minSeq`, seq는 1부터 구멍 없이 붙는다)보다 위에 있고, 앞선 `loadOlder`가
182
+ * 덜 찬 페이지로 끝을 알린 적이 없으면 더 있다. 첫 `loadOlder()` 전에 "이전
183
+ * 메시지 더 보기"를 그릴지 정할 수 있게 한다. 목록이 비었으면 false다.
184
+ */
185
+ get hasOlder() {
186
+ const first = this.items[0];
187
+ return !this.exhausted && first !== void 0 && first.seq > this.floor;
188
+ }
189
+ /** Records the room's retention floor (`minSeq`). */
190
+ setFloor(minSeq) {
191
+ this.floor = Math.max(1, minSeq);
192
+ }
158
193
  /** The highest seq this timeline holds, hole or no hole. */
159
194
  get highestSeq() {
160
195
  return this.items.at(-1)?.seq ?? 0;
@@ -198,7 +233,9 @@ var Timeline = class {
198
233
  this.epoch++;
199
234
  this.fills.clear();
200
235
  this.pendingDeletes.clear();
236
+ this.presses.clear();
201
237
  this.items = [];
238
+ this.exhausted = false;
202
239
  this.shared = false;
203
240
  this.changed();
204
241
  }
@@ -224,8 +261,10 @@ var Timeline = class {
224
261
  this.epoch++;
225
262
  this.fills.clear();
226
263
  const page = [...messages].sort((a, b) => a.seq - b.seq);
264
+ for (const m of page) this.serverRendered(m.id);
227
265
  const top = page.at(-1)?.seq ?? 0;
228
266
  this.items = [...page, ...this.items.filter((m) => m.seq > top)];
267
+ this.exhausted = false;
229
268
  this.shared = false;
230
269
  for (const m of this.items) {
231
270
  if (this.pendingDeletes.has(m.id)) this.applyDelete(m.id);
@@ -276,20 +315,84 @@ var Timeline = class {
276
315
  *
277
316
  * The server sends the new `count` with the event, so this is a
278
317
  * replacement rather than an increment: two clients reacting at once
279
- * cannot drift the way `+1`/`-1` would.
318
+ * cannot drift the way `+1`/`-1` would. `count` undefined leaves the
319
+ * aggregate alone (the react ack path); `mine` undefined leaves
320
+ * `myReactions` alone (somebody else's event).
321
+ *
322
+ * This is the **authoritative** entry point: a value the server sent.
323
+ * So a press still waiting on an answer for the same pair is finished
324
+ * here -- a refusal landing afterwards must not push this value back
325
+ * to the older one it was going to restore.
326
+ */
327
+ reaction(messageId, emoji, count, mine) {
328
+ if (mine !== void 0) this.presses.delete(pairKey(messageId, emoji));
329
+ this.applyReaction(messageId, emoji, count, mine);
330
+ }
331
+ /**
332
+ * Applies this client's own react/unreact to `myReactions` **before the
333
+ * frame goes out**, and returns the press so its answer can settle it.
334
+ *
335
+ * 누른 값은 (사용자, 이모지)로 로컬에서 정해지고 엔진은 멱등이므로, 미리
336
+ * 적용해도 ack가 새로 알려 줄 것이 없다. `count`는 일부러 건드리지 않는다 —
337
+ * 그 사이 남이 같은 이모지를 누르면 로컬 +1/-1은 서버가 보낼 수와 다르고,
338
+ * 그 차이를 고칠 이벤트는 이미 지나갔다.
339
+ *
340
+ * Undefined when the row is not held: there is nothing to show now and
341
+ * nothing to put back later.
342
+ */
343
+ pressReaction(messageId, emoji, mine) {
344
+ const at = this.items.findIndex((m) => m.id === messageId);
345
+ if (at === -1) return void 0;
346
+ const key = pairKey(messageId, emoji);
347
+ const base = this.presses.get(key)?.base ?? (this.items[at].myReactions ?? []).includes(emoji);
348
+ const press = { messageId, emoji, mine, seq: ++this.pressSeq };
349
+ this.presses.set(key, { seq: press.seq, base });
350
+ this.applyReaction(messageId, emoji, void 0, mine);
351
+ return press;
352
+ }
353
+ /**
354
+ * Settles a press with what the server said.
355
+ *
356
+ * `accepted` re-asserts the value the press asked for; a refusal puts
357
+ * the last server-given value back. Either way it does nothing once
358
+ * this press no longer owns the pair -- a newer press took it over
359
+ * (and that one's answer decides), or something authoritative already
360
+ * landed on it (a `reaction.*` event carrying this user's id, a
361
+ * `reload()`, a reconnect's catch-up page). Reverting there would
362
+ * resurrect a value older than what is on screen.
280
363
  */
281
- reaction(messageId, emoji, count) {
364
+ settleReaction(press, accepted) {
365
+ const key = pairKey(press.messageId, press.emoji);
366
+ const held = this.presses.get(key);
367
+ if (held === void 0 || held.seq !== press.seq) return;
368
+ this.presses.delete(key);
369
+ this.applyReaction(press.messageId, press.emoji, void 0, accepted ? press.mine : held.base);
370
+ }
371
+ applyReaction(messageId, emoji, count, mine) {
282
372
  const at = this.items.findIndex((m) => m.id === messageId);
283
373
  if (at === -1) return;
284
374
  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);
375
+ let reactions = current.reactions;
376
+ if (count !== void 0) {
377
+ const existing = current.reactions ?? [];
378
+ const slot = existing.findIndex((r) => r.emoji === emoji);
379
+ if (count <= 0) reactions = existing.filter((r) => r.emoji !== emoji);
380
+ else if (slot === -1) reactions = [...existing, { emoji, count }];
381
+ else reactions = existing.map((r, i) => i === slot ? { emoji, count } : r);
382
+ }
383
+ let myReactions = current.myReactions;
384
+ if (mine !== void 0) {
385
+ const held = current.myReactions ?? [];
386
+ const has = held.includes(emoji);
387
+ if (mine && !has) myReactions = [...held, emoji];
388
+ else if (!mine && has) myReactions = held.filter((e) => e !== emoji);
389
+ }
390
+ if (reactions === current.reactions && myReactions === current.myReactions) return;
391
+ const next = { ...current };
392
+ if (reactions !== void 0) next.reactions = reactions;
393
+ if (myReactions !== void 0) next.myReactions = myReactions;
291
394
  this.own();
292
- this.items[at] = { ...current, reactions: next };
395
+ this.items[at] = next;
293
396
  this.changed();
294
397
  }
295
398
  /** Applies a `thread.updated` to the root message's aggregate. */
@@ -326,14 +429,16 @@ var Timeline = class {
326
429
  }
327
430
  async readOlder(limit) {
328
431
  const first = this.items[0];
329
- if (first === void 0 || first.seq <= 1) return { messages: [], hasMore: false };
432
+ if (first === void 0 || first.seq <= this.floor) return { messages: [], hasMore: false };
330
433
  const epoch = this.epoch;
331
434
  const page = await this.options.fetchRange({ after: 0, before: first.seq, limit });
332
435
  if (this.epoch !== epoch) return { messages: [], hasMore: true };
333
436
  for (const m of page) this.insert(m, false);
334
437
  if (page.length > 0) this.changed();
335
438
  const top = page[0];
336
- return { messages: page, hasMore: page.length >= limit && top !== void 0 && top.seq > 1 };
439
+ const hasMore = page.length >= limit && top !== void 0 && top.seq > this.floor;
440
+ this.exhausted = !hasMore;
441
+ return { messages: page, hasMore };
337
442
  }
338
443
  /**
339
444
  * Fetches everything between what we have and `upTo`.
@@ -380,6 +485,7 @@ var Timeline = class {
380
485
  }
381
486
  /** Inserts at the seq position, replacing an existing row with that seq. */
382
487
  insert(message, notify = true) {
488
+ this.serverRendered(message.id);
383
489
  const pending = this.pendingDeletes.has(message.id);
384
490
  if (pending) this.pendingDeletes.delete(message.id);
385
491
  const withDelete = pending ? emptied(message) : message;
@@ -404,6 +510,21 @@ var Timeline = class {
404
510
  this.changed();
405
511
  return true;
406
512
  }
513
+ /**
514
+ * Forgets the presses on a row the server has just re-rendered.
515
+ *
516
+ * 히스토리 응답의 행은 뷰어별 `myReactions`를 싣고 오므로 그 값이 서버의
517
+ * 값이다. 누름을 남겨 두면 그 뒤에 온 거절이 더 새로운 진실 위에 옛 값을
518
+ * 덮어쓴다. 행 전체 단위인 것은 페이지가 그 행의 이모지 전부를 다시 그려
519
+ * 주기 때문이다.
520
+ */
521
+ serverRendered(messageId) {
522
+ if (this.presses.size === 0) return;
523
+ const prefix = `${messageId}\0`;
524
+ for (const key of this.presses.keys()) {
525
+ if (key.startsWith(prefix)) this.presses.delete(key);
526
+ }
527
+ }
407
528
  /** Makes `items` safe to mutate: a copy, if the current one was handed out. */
408
529
  own() {
409
530
  if (!this.shared) return;
@@ -418,6 +539,9 @@ var Timeline = class {
418
539
  function emptied(message) {
419
540
  return { ...message, body: {}, deletedAt: message.deletedAt ?? Date.now() };
420
541
  }
542
+ function pairKey(messageId, emoji) {
543
+ return `${messageId}\0${emoji}`;
544
+ }
421
545
  function createTimeline(options) {
422
546
  return new Timeline(options);
423
547
  }
@@ -481,6 +605,17 @@ var Room = class extends Emitter {
481
605
  onChange: (messages) => this.emit("messages", messages)
482
606
  });
483
607
  }
608
+ /**
609
+ * Whether older history exists above the first message held -- what
610
+ * `loadOlder()` would report as `hasMore`, known before calling it.
611
+ *
612
+ * Derived from the first row's seq against the room's retention floor
613
+ * (the subscribe ack's `minSeq`), and false once a `loadOlder()` came
614
+ * back short. False while nothing is loaded. Re-read it on `messages`.
615
+ */
616
+ get hasOlder() {
617
+ return this.timeline.hasOlder;
618
+ }
484
619
  /** Everything this client knows about the room, in seq order. A new array whenever it changes. */
485
620
  get messages() {
486
621
  return this.timeline.messages;
@@ -559,6 +694,7 @@ var Room = class extends Emitter {
559
694
  this.id = ack.roomId;
560
695
  this.chat.registerRoomId(ack.roomId, this);
561
696
  this.lastSeq = ack.lastSeq;
697
+ if (typeof ack.minSeq === "number") this.timeline.setFloor(ack.minSeq);
562
698
  if (ack.presence !== void 0) this.presence = ack.presence;
563
699
  if (attempt.wasReset) {
564
700
  await this.loadRecent(current);
@@ -602,12 +738,52 @@ var Room = class extends Emitter {
602
738
  });
603
739
  return page.messages;
604
740
  }
605
- /** Adds a reaction. Idempotent, like the frame. */
741
+ /**
742
+ * Adds a reaction. Idempotent, like the frame.
743
+ *
744
+ * The row's `myReactions` moves **before the frame goes out** and rolls
745
+ * back if the server refuses; its count stays the server's. See
746
+ * `toggleReaction`.
747
+ */
606
748
  async react(messageId, emoji) {
607
- await this.chat.send("react", { roomId: await this.resolveId("reacting"), messageId, emoji, op: "add" });
749
+ await this.toggleReaction(messageId, emoji, true);
608
750
  }
751
+ /** Removes a reaction. Idempotent, and optimistic the same way `react` is. */
609
752
  async unreact(messageId, emoji) {
610
- await this.chat.send("react", { roomId: await this.resolveId("reacting"), messageId, emoji, op: "remove" });
753
+ await this.toggleReaction(messageId, emoji, false);
754
+ }
755
+ /**
756
+ * Moves `myReactions` now, sends the frame after.
757
+ *
758
+ * 누른 값은 (사용자, 이모지)로 로컬에서 정해지고 엔진은 멱등이므로 ack를
759
+ * 기다릴 이유가 없다 — 기다리면 누른 느낌이 왕복 시간만큼 늦는다. 소비자가
760
+ * 자기 낙관 계층을 덧대면 이 필드의 주인이 둘이 되므로, SDK가 한다.
761
+ * **발행은 다르다**: `before_publish` 훅이 본문을 바꿀 수 있어 미리 그릴 값이
762
+ * 로컬에 없다. 그래서 `send()`는 낙관적이지 않다.
763
+ *
764
+ * 개수는 옮기지 않는다. ack가 오기 전에 남이 같은 이모지를 누르면 로컬
765
+ * +1/-1은 서버가 보낸 수와 다른 값이 되고, 그것을 고칠 이벤트는 이미
766
+ * 지나갔다. 또 ack와 방 프레임은 다른 길로 와 순서가 없으므로, 늦게 온 내
767
+ * ack(count 1)가 앞서 온 남의 이벤트(count 2)를 되돌린다.
768
+ *
769
+ * 거절·타임아웃·끊김이면 되돌리지만, 그 사이 더 새로운 값이 자리를 차지했다면
770
+ * 두고 나온다(`Timeline.settleReaction`).
771
+ */
772
+ async toggleReaction(messageId, emoji, mine) {
773
+ const press = this.timeline.pressReaction(messageId, emoji, mine);
774
+ try {
775
+ await this.chat.send("react", {
776
+ roomId: await this.resolveId("reacting"),
777
+ messageId,
778
+ emoji,
779
+ op: mine ? "add" : "remove"
780
+ });
781
+ } catch (err) {
782
+ if (press !== void 0) this.timeline.settleReaction(press, false);
783
+ throw err;
784
+ }
785
+ if (press !== void 0) this.timeline.settleReaction(press, true);
786
+ else this.timeline.reaction(messageId, emoji, void 0, mine);
611
787
  }
612
788
  /**
613
789
  * Moves this user's read cursor.
@@ -802,7 +978,9 @@ var Room = class extends Emitter {
802
978
  case "reaction.added":
803
979
  case "reaction.removed": {
804
980
  const r = data;
805
- this.timeline.reaction(r.messageId, r.emoji, r.count);
981
+ const me = this.chat.user?.id;
982
+ const mine = me !== void 0 && r.userId === me ? type === "reaction.added" : void 0;
983
+ this.timeline.reaction(r.messageId, r.emoji, r.count, mine);
806
984
  break;
807
985
  }
808
986
  case "thread.updated": {
@@ -1051,6 +1229,48 @@ var ChatClient = class extends Emitter {
1051
1229
  });
1052
1230
  return this.opening;
1053
1231
  }
1232
+ /**
1233
+ * Re-authenticates: calls `token()` again and replaces the socket, keeping
1234
+ * every room handle and every subscription.
1235
+ *
1236
+ * For when the identity the server holds has to change mid-session --
1237
+ * a nickname change, a new avatar, fresh claims. The server reads the
1238
+ * token once, at `auth`, so `sender.name` on the next message is the old
1239
+ * one until the connection is re-authenticated. `close()` + `connect()`
1240
+ * does that too, but passes through `closed` (a consumer's "you are
1241
+ * offline" screen) and tears down the page listeners.
1242
+ *
1243
+ * States: `open` → `reconnecting` → `open`, never `closed`. Rooms
1244
+ * resubscribe from what they hold and catch up the gap, as after any
1245
+ * drop; `messages` is kept. Frames awaiting an ack on the old socket are
1246
+ * rejected with `closed`. `chat.user` is the new identity once this
1247
+ * resolves. If `token()` fails the client keeps retrying in the
1248
+ * background like any reconnect (and this rejects); a `ChatError` from
1249
+ * `token()` stops it at `closed`, as on connect.
1250
+ *
1251
+ * On a client that is not connected (never connected, or `close()`d) it
1252
+ * is `connect()`.
1253
+ */
1254
+ async reconnect() {
1255
+ if (this.opening !== void 0) await this.opening.catch(() => {
1256
+ });
1257
+ const socket = this.socket;
1258
+ if (socket === void 0 || this.state !== "open") return this.connect();
1259
+ this.clearTimers();
1260
+ this.retired.add(socket);
1261
+ this.socket = void 0;
1262
+ this.failPending(new ChatError("closed", "the connection was replaced by reconnect()"));
1263
+ try {
1264
+ socket.close(1e3, "client reconnecting");
1265
+ } catch {
1266
+ }
1267
+ this.opening = this.openOnce("reconnecting").finally(() => {
1268
+ this.opening = void 0;
1269
+ });
1270
+ return this.opening;
1271
+ }
1272
+ /** Sockets `reconnect()` replaced: their late events are not this client's any more. */
1273
+ retired = /* @__PURE__ */ new WeakSet();
1054
1274
  /**
1055
1275
  * Closes for good.
1056
1276
  *
@@ -1164,8 +1384,8 @@ var ChatClient = class extends Emitter {
1164
1384
  this.state = next;
1165
1385
  this.emit("state", next);
1166
1386
  }
1167
- async openOnce() {
1168
- this.setState(this.attempt === 0 ? "connecting" : "reconnecting");
1387
+ async openOnce(state = this.attempt === 0 ? "connecting" : "reconnecting") {
1388
+ this.setState(state);
1169
1389
  let authData;
1170
1390
  try {
1171
1391
  authData = await this.authData();
@@ -1191,6 +1411,7 @@ var ChatClient = class extends Emitter {
1191
1411
  else reject(err);
1192
1412
  };
1193
1413
  socket.addEventListener("message", (ev) => {
1414
+ if (this.retired.has(socket)) return;
1194
1415
  const frame = JSON.parse(String(ev.data));
1195
1416
  if (frame.type === "hello") {
1196
1417
  this.onHello(frame.data);
@@ -1211,6 +1432,7 @@ var ChatClient = class extends Emitter {
1211
1432
  this.onFrame(frame);
1212
1433
  });
1213
1434
  socket.addEventListener("close", () => {
1435
+ if (this.retired.has(socket)) return;
1214
1436
  if (this.socket !== void 0 && this.socket !== socket) return;
1215
1437
  this.clearTimers();
1216
1438
  this.socket = void 0;
@@ -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
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kispi/chat",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "Client SDK for the chat server: one WebSocket, many rooms, ordered history.",
5
5
  "license": "MIT",
6
6
  "type": "module",