@kispi/chat 0.2.1 → 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/README.en.md CHANGED
@@ -36,7 +36,7 @@ is in the browser, the whole app is open.
36
36
  | Property | |
37
37
  |---|---|
38
38
  | `state` | `'connecting' \| 'open' \| 'reconnecting' \| 'closed'` |
39
- | `user` | The connected user. `{id, name, avatar?}` |
39
+ | `user` | The connected user. `{id, name, avatar?}` — **the identity the server accepted from the token** (what `hello` carried). Use this instead of parsing your token response. Set once `connect()`/`reconnect()` resolves |
40
40
  | `connectionId` | Identifier used for support requests and forced disconnects |
41
41
  | `hello` | Everything the server sent on connect (limits, total unread, warnings) |
42
42
 
@@ -44,6 +44,7 @@ is in the browser, the whole app is open.
44
44
  |---|---|
45
45
  | `connect()` | Connects. If already connected or connecting, it joins that attempt. Rejects on failure, but **retries continue in the background** (see below) |
46
46
  | `close()` | Disconnects. This one does not reconnect. Call `connect()` to come back |
47
+ | `reconnect()` | **Re-authenticates**: calls `token()` again and replaces the socket. Room handles, subscriptions and `messages` are kept, and the state goes `open → reconnecting → open` (never `closed`). Use it after a nickname or avatar change (below) |
47
48
  | `room(roomId)` | Room handle. Same id, same object |
48
49
  | `roomByKey(key)` | Handle for a room opened by key. The server tells you the id when you subscribe. **Does not create the room** — for a key that doesn't exist, `subscribe()` rejects with `not_found` (see below) |
49
50
  | `rooms.list({cursor?, limit?})` | Rooms I'm a member of + unread + last message |
@@ -76,6 +77,7 @@ off()
76
77
  | `id` / `key` | Room identifiers. If opened by key, `id` is absent until you subscribe |
77
78
  | `lastSeq` | The room's last seq as reported by the server |
78
79
  | `presence` | `{count, users?, capped?}` |
80
+ | `hasOlder` | Whether `loadOlder()` has more past to read. Lets you decide whether to draw "load earlier" before the first `loadOlder()`. The same test as `loadOlder()`'s `hasMore` (the first row is above the room's retention floor); `false` while the list is empty. Re-read it on `messages` |
79
81
 
80
82
  | Method | |
81
83
  |---|---|
@@ -85,7 +87,7 @@ off()
85
87
  | `loadOlder({limit?})` | Reads the page before the oldest message (default 100) and **prepends it to `messages`.** Returns `{messages, hasMore}`. Use it when scrolling up. If the list is empty (before subscribing), it reads nothing |
86
88
  | `history({before?, after?, limit?, view?})` | Reads the past directly. **Independent of `messages`.** `view` is `'main'`, `'all'`, or `{thread: id}` |
87
89
  | `reload()` | Re-reads the latest page |
88
- | `react(messageId, emoji)` / `unreact(...)` | Reactions. Idempotent |
90
+ | `react(messageId, emoji)` / `unreact(...)` | Reactions. Idempotent. The ack updates that row's `myReactions` |
89
91
  | `reactionsOf(messageId, {emoji?, cursor?, limit?})` | Who reacted. **Each row is a (user, emoji) pair**, so one person can appear on several rows. `limit` defaults to 50, max 100; the last page's `cursor` is an empty string |
90
92
  | `markRead(seq, {threadId?})` | Read cursor |
91
93
  | `typing()` | Typing indicator. Does not throw |
@@ -134,6 +136,13 @@ Wrapping it in `$state` (a deep proxy) or `ref` works, but does unnecessary work
134
136
  modified the same array in place and emitted it again, so on the reference-comparing side
135
137
  the screen froze.
136
138
 
139
+ **Reactions: `reactions` is everyone's counts, `myReactions` is the emoji you pressed.**
140
+ The SDK keeps both live — counts from the `count` on `reaction.*` events, `myReactions` from
141
+ those events when their `userId` is `chat.user.id` (including presses from your other tabs and
142
+ devices) and from the ack of your own `react()`/`unreact()`. Both are overwrites, not
143
+ increments, so getting the event and the ack never counts twice. Only the changed row is a new
144
+ object.
145
+
137
146
  **Attach the past to the same list with `loadOlder()`.** `history()` is a direct read that
138
147
  doesn't touch the list, so merging its results with the live list, filtering duplicates, and
139
148
  handling `reset` and reconnects all fall to the consumer. `loadOlder()` puts those rows into
@@ -143,6 +152,7 @@ like any other row. Calling it several times concurrently still makes one reques
143
152
  `reset` or `reload()` discards the attached past as well.
144
153
 
145
154
  ```ts
155
+ showLoadMore(room.hasOlder)
146
156
  const { hasMore } = await room.loadOlder()
147
157
  ```
148
158
 
@@ -198,6 +208,20 @@ The last two leave `state` at `closed` and report why on `chat.on('error')` —
198
208
  or during a REST token refresh, where there is no caller to reject. Call `connect()` to come
199
209
  back.
200
210
 
211
+ **After a nickname change, `reconnect()`.** The server reads the name and avatar from the
212
+ token's claims once, when the socket authenticates. So after your backend changes the name,
213
+ the next message on this connection still carries the old one. `reconnect()` calls `token()`
214
+ again, re-authenticates with the new claims, and the rooms carry on from what they hold.
215
+ `close()` + `connect()` works too, but passes through `closed` (your offline screen flashes)
216
+ and removes the page listeners. Frames still awaiting an ack on the old socket reject with
217
+ `closed`. When it resolves, `chat.user` is the new identity.
218
+
219
+ ```ts
220
+ await api.updateNickname(name) // so your backend puts the new name in the next token()
221
+ await chat.reconnect()
222
+ chat.user?.name // the new name
223
+ ```
224
+
201
225
  **The SDK handles token expiry.** The socket authenticates only once on connect, so it stays
202
226
  fine even when the token expires, but REST checks the token on every request. When REST gets a
203
227
  401, the SDK calls `token()` again and retries **once**. A second 401 (origin, revoked token,
@@ -340,12 +364,26 @@ spaces don't overlap.
340
364
 
341
365
  | Group | Methods |
342
366
  |---|---|
343
- | `rooms` | `ensure({key?, type, name?, meta?, members?})`, `ensureDM([a, b])`, `get`, `update`, `delete`, `custom(roomId, payload)`, `members.list/add/remove` |
367
+ | `rooms` | `ensure({key?, type, name?, meta?, members?})`, `ensureDM([a, b])`, `get`, `update`, `delete`, `custom(roomId, payload)`, `presence(roomId, {full?})`, `members.list/add/remove` |
344
368
  | `messages` | `send(roomId, {sender, text?, kind?, attachments?, appMeta?, replyTo?, threadId?, clientMessageId?})`, `list`, `delete` |
345
- | `users` | `list({cursor?, limit?})`, `update`, `delete({purgeMessages?})`, `ban({until, reason?})`, `unban`, `revokeTokens`, `disconnect` |
369
+ | `users` | `list({cursor?, limit?})`, `update`, `delete({purgeMessages?})`, `purgeMessages(userId)`, `ban({until, reason?})`, `unban`, `revokeTokens`, `disconnect` |
346
370
  | `events` | `list({after?, limit?})` — the outbox. Catches up on missed webhooks |
347
371
  | `webhooks` | `verify(headers, rawBody, {now?, tolerance?})` |
348
372
 
373
+ `rooms.presence(roomId)` is `{count}`; with `{full: true}` it adds `users` (at most 1000,
374
+ `capped: true` when cut). An empty room is `{count: 0}`, not a 404.
375
+
376
+ **`users.purgeMessages(userId)` is moderation** — it deletes every message the user sent in the
377
+ app (tombstones: seq is untouched, and watching clients get `message.deleted`) **without
378
+ withdrawing them.** The name stays. For spam, **ban first**, then call it — a message posted
379
+ while it runs is not in its listing. It does a bounded amount per call, so call again while
380
+ `purgeCapped` is `true`. A withdrawn user is a 404 (use `delete(userId, {purgeMessages: true})`).
381
+
382
+ ```ts
383
+ await chat.users.ban(userId, { until: Date.now() + 7 * 86_400_000, reason: 'spam' })
384
+ while ((await chat.users.purgeMessages(userId)).purgeCapped) {}
385
+ ```
386
+
349
387
  `users.ban`'s `until` is **unix milliseconds and required.** For an indefinite ban, pick a
350
388
  far-future time yourself — it goes into the audit log as a decision, not an accident.
351
389
 
package/README.md CHANGED
@@ -36,7 +36,7 @@ npm install @kispi/chat
36
36
  | 프로퍼티 | |
37
37
  |---|---|
38
38
  | `state` | `'connecting' \| 'open' \| 'reconnecting' \| 'closed'` |
39
- | `user` | 접속한 사람. `{id, name, avatar?}` |
39
+ | `user` | 접속한 사람. `{id, name, avatar?}` — **서버가 토큰에서 받아들인 신원**(hello가 준 것)이다. 토큰 응답을 따로 파싱하지 말고 이것을 쓴다. `connect()`/`reconnect()`가 끝나면 채워져 있다 |
40
40
  | `connectionId` | 지원 문의와 강제 종료에 쓰는 식별자 |
41
41
  | `hello` | 서버가 접속 때 준 것 전부(한도, 총 unread, 경고) |
42
42
 
@@ -44,6 +44,7 @@ npm install @kispi/chat
44
44
  |---|---|
45
45
  | `connect()` | 붙는다. 이미 붙어 있거나 붙는 중이면 그 시도에 합류한다. 실패하면 거절하지만 **재시도는 백그라운드에서 계속된다**(아래) |
46
46
  | `close()` | 끊는다. 이건 재접속하지 않는다. 다시 붙으려면 `connect()` |
47
+ | `reconnect()` | **다시 인증한다**: `token()`을 다시 불러 소켓을 바꾼다. 방 핸들과 구독, `messages`는 그대로이고 상태는 `open → reconnecting → open`이다(`closed`를 거치지 않는다). 닉네임·아바타를 바꾼 뒤에 쓴다(아래) |
47
48
  | `room(roomId)` | 방 핸들. 같은 id면 같은 객체 |
48
49
  | `roomByKey(key)` | 키로 여는 방 핸들. 구독할 때 서버가 id를 알려 준다. **방을 만들지 않는다** — 없는 키면 `subscribe()`가 `not_found`로 거절한다(아래) |
49
50
  | `rooms.list({cursor?, limit?})` | 내가 멤버인 방 + unread + 마지막 메시지 |
@@ -76,6 +77,7 @@ off()
76
77
  | `id` / `key` | 방 식별자. 키로 열었으면 구독 전까지 `id`가 없다 |
77
78
  | `lastSeq` | 서버가 알려 준 방의 마지막 seq |
78
79
  | `presence` | `{count, users?, capped?}` |
80
+ | `hasOlder` | `loadOlder()`로 더 읽을 과거가 있는지. 첫 `loadOlder()` 전에 "이전 메시지" 버튼을 그릴지 정할 때 쓴다. `loadOlder()`의 `hasMore`와 같은 판정(맨 위 행이 방의 보존 하한 위에 있는지)이고, 목록이 비었으면 `false`. `messages` 이벤트 때 다시 읽는다 |
79
81
 
80
82
  | 메서드 | |
81
83
  |---|---|
@@ -85,7 +87,7 @@ off()
85
87
  | `loadOlder({limit?})` | 가장 오래된 메시지 앞 페이지(기본 100)를 읽어 **`messages` 앞에 붙인다.** `{messages, hasMore}`. 스크롤을 올릴 때 쓴다. 목록이 비어 있으면(구독 전) 아무것도 읽지 않는다 |
86
88
  | `history({before?, after?, limit?, view?})` | 과거를 직접 읽는다. **`messages`와 따로 논다.** `view`는 `'main'`, `'all'`, `{thread: id}` |
87
89
  | `reload()` | 최근 페이지를 다시 읽는다 |
88
- | `react(messageId, emoji)` / `unreact(...)` | 리액션. 멱등 |
90
+ | `react(messageId, emoji)` / `unreact(...)` | 리액션. 멱등. ack가 그 행의 `myReactions`를 고친다 |
89
91
  | `reactionsOf(messageId, {emoji?, cursor?, limit?})` | 누가 눌렀는지. **한 행은 (유저, 이모지) 쌍**이라 한 사람이 여러 줄일 수 있다. `limit` 기본 50 최대 100, 마지막 페이지의 `cursor`는 빈 문자열 |
90
92
  | `markRead(seq, {threadId?})` | 읽음 커서 |
91
93
  | `typing()` | 입력 중. 던지지 않는다 |
@@ -132,6 +134,12 @@ room.on('messages', m => (messages.value = m))
132
134
  `$state`(깊은 프록시)나 `ref`로 감싸도 동작하지만 필요 없는 일을 한다. 0.1.x는 같은
133
135
  배열을 제자리에서 고쳐 다시 내보내서, 참조로 비교하는 쪽에서는 화면이 멈췄다.
134
136
 
137
+ **리액션: `reactions`는 모두의 개수, `myReactions`는 내가 누른 이모지다.** 둘 다
138
+ SDK가 라이브로 맞춘다 — 개수는 `reaction.*` 이벤트의 `count`로, `myReactions`는 그
139
+ 이벤트의 `userId`가 `chat.user.id`일 때(다른 탭·기기에서 누른 것 포함)와 내
140
+ `react()`/`unreact()`의 ack로. 둘 다 더하기가 아니라 덮어쓰기라 이벤트와 ack가 모두
141
+ 와도 두 번 세지 않는다. 바뀐 행만 새 객체다.
142
+
135
143
  **과거는 `loadOlder()`로 같은 목록에 붙인다.** `history()`는 목록을 건드리지 않는
136
144
  직접 읽기라, 그 결과를 라이브 목록과 합치고 중복을 거르고 `reset`과 재접속을
137
145
  처리하는 일이 전부 소비자 몫이 된다. `loadOlder()`는 그 행들을 `messages`에 넣으므로
@@ -140,6 +148,7 @@ room.on('messages', m => (messages.value = m))
140
148
  `reset`이나 `reload()`는 붙인 과거도 함께 버린다.
141
149
 
142
150
  ```ts
151
+ showLoadMore(room.hasOlder)
143
152
  const { hasMore } = await room.loadOlder()
144
153
  ```
145
154
 
@@ -191,6 +200,19 @@ token: async () => {
191
200
  뒤의 둘은 `state`를 `closed`로 두고 `chat.on('error')`로 이유를 낸다. 재접속 중이나
192
201
  REST의 토큰 갱신 중이라 거절을 받을 호출자가 없어도 그렇다. 다시 붙으려면 `connect()`.
193
202
 
203
+ **닉네임을 바꾸면 `reconnect()`.** 서버는 이름·아바타를 접속할 때 토큰 claim에서
204
+ 한 번 읽는다. 그래서 백엔드에서 이름을 바꿔도 이 접속의 다음 메시지는 옛 이름이다.
205
+ `reconnect()`는 `token()`을 다시 불러 새 claim으로 다시 인증하고, 방은 가진 것에서
206
+ 이어서 따라잡는다. `close()` + `connect()`도 되지만 `closed`를 거쳐(오프라인 화면이
207
+ 깜빡인다) 페이지 리스너까지 떼어 낸다. 옛 소켓에서 ack를 기다리던 프레임은 `closed`로
208
+ 거절된다. 끝나면 `chat.user`가 새 신원이다.
209
+
210
+ ```ts
211
+ await api.updateNickname(name) // 백엔드가 다음 token()에 새 이름을 싣게 한다
212
+ await chat.reconnect()
213
+ chat.user?.name // 새 이름
214
+ ```
215
+
194
216
  **토큰 만료는 SDK가 처리한다.** 소켓은 접속할 때 한 번만 인증하므로 토큰이 만료돼도
195
217
  멀쩡하지만, REST는 요청마다 토큰을 본다. REST가 401을 받으면 SDK가 `token()`을 다시
196
218
  불러 **한 번** 재시도한다. 두 번째 401(오리진, 폐기된 토큰, 밴)은 그대로 던진다.
@@ -331,12 +353,26 @@ app.post('/api/chat-token', (req, res) => {
331
353
 
332
354
  | 그룹 | 메서드 |
333
355
  |---|---|
334
- | `rooms` | `ensure({key?, type, name?, meta?, members?})`, `ensureDM([a, b])`, `get`, `update`, `delete`, `custom(roomId, payload)`, `members.list/add/remove` |
356
+ | `rooms` | `ensure({key?, type, name?, meta?, members?})`, `ensureDM([a, b])`, `get`, `update`, `delete`, `custom(roomId, payload)`, `presence(roomId, {full?})`, `members.list/add/remove` |
335
357
  | `messages` | `send(roomId, {sender, text?, kind?, attachments?, appMeta?, replyTo?, threadId?, clientMessageId?})`, `list`, `delete` |
336
- | `users` | `list({cursor?, limit?})`, `update`, `delete({purgeMessages?})`, `ban({until, reason?})`, `unban`, `revokeTokens`, `disconnect` |
358
+ | `users` | `list({cursor?, limit?})`, `update`, `delete({purgeMessages?})`, `purgeMessages(userId)`, `ban({until, reason?})`, `unban`, `revokeTokens`, `disconnect` |
337
359
  | `events` | `list({after?, limit?})` — outbox. 놓친 웹훅을 따라잡는다 |
338
360
  | `webhooks` | `verify(headers, rawBody, {now?, tolerance?})` |
339
361
 
362
+ `rooms.presence(roomId)`는 `{count}`이고, `{full: true}`면 `users`(최대 1000명, 잘리면
363
+ `capped: true`)가 붙는다. 빈 방은 404가 아니라 `{count: 0}`이다.
364
+
365
+ **`users.purgeMessages(userId)`는 모더레이션이다** — 그 유저가 앱에서 보낸 메시지를
366
+ 전부 지우되(tombstone: seq는 그대로, 보고 있는 클라이언트에는 `message.deleted`가
367
+ 간다) **탈퇴시키지 않는다.** 이름도 남는다. 스팸이면 **밴을 먼저** 하고 부른다 — 도는
368
+ 사이에 올라온 메시지는 목록에 없다. 한 번에 상한만큼만 하므로 `purgeCapped`가
369
+ `true`인 동안 다시 부른다. 탈퇴한 유저는 404다(그때는 `delete(userId, {purgeMessages: true})`).
370
+
371
+ ```ts
372
+ await chat.users.ban(userId, { until: Date.now() + 7 * 86_400_000, reason: 'spam' })
373
+ while ((await chat.users.purgeMessages(userId)).purgeCapped) {}
374
+ ```
375
+
340
376
  `users.ban`의 `until`은 **unix 밀리초이고 필수다.** 무기한 밴은 먼 미래 시각을
341
377
  직접 고른다 — 감사 로그에 사고가 아니라 결정으로 남는다.
342
378
 
package/dist/index.cjs CHANGED
@@ -204,6 +204,13 @@ var Timeline = class {
204
204
  shared = false;
205
205
  /** The `loadOlder` in flight, so a scroll handler firing twice asks once. */
206
206
  older;
207
+ /**
208
+ * The lowest seq the room still keeps (`minSeq` from the subscribe ack;
209
+ * 1 until told). Below it there is nothing to read, whatever seq says.
210
+ */
211
+ floor = 1;
212
+ /** A `loadOlder` came back short: the top of what the server keeps was reached. */
213
+ exhausted = false;
207
214
  constructor(options) {
208
215
  this.options = options;
209
216
  }
@@ -212,6 +219,22 @@ var Timeline = class {
212
219
  this.shared = true;
213
220
  return this.items;
214
221
  }
222
+ /**
223
+ * Whether `loadOlder()` would find anything, answered without asking.
224
+ *
225
+ * `loadOlder()`의 `hasMore`와 같은 판정이다: 맨 위 행이 방의 보존 하한
226
+ * (`minSeq`, seq는 1부터 구멍 없이 붙는다)보다 위에 있고, 앞선 `loadOlder`가
227
+ * 덜 찬 페이지로 끝을 알린 적이 없으면 더 있다. 첫 `loadOlder()` 전에 "이전
228
+ * 메시지 더 보기"를 그릴지 정할 수 있게 한다. 목록이 비었으면 false다.
229
+ */
230
+ get hasOlder() {
231
+ const first = this.items[0];
232
+ return !this.exhausted && first !== void 0 && first.seq > this.floor;
233
+ }
234
+ /** Records the room's retention floor (`minSeq`). */
235
+ setFloor(minSeq) {
236
+ this.floor = Math.max(1, minSeq);
237
+ }
215
238
  /** The highest seq this timeline holds, hole or no hole. */
216
239
  get highestSeq() {
217
240
  return this.items.at(-1)?.seq ?? 0;
@@ -256,6 +279,7 @@ var Timeline = class {
256
279
  this.fills.clear();
257
280
  this.pendingDeletes.clear();
258
281
  this.items = [];
282
+ this.exhausted = false;
259
283
  this.shared = false;
260
284
  this.changed();
261
285
  }
@@ -283,6 +307,7 @@ var Timeline = class {
283
307
  const page = [...messages].sort((a, b) => a.seq - b.seq);
284
308
  const top = page.at(-1)?.seq ?? 0;
285
309
  this.items = [...page, ...this.items.filter((m) => m.seq > top)];
310
+ this.exhausted = false;
286
311
  this.shared = false;
287
312
  for (const m of this.items) {
288
313
  if (this.pendingDeletes.has(m.id)) this.applyDelete(m.id);
@@ -333,20 +358,35 @@ var Timeline = class {
333
358
  *
334
359
  * The server sends the new `count` with the event, so this is a
335
360
  * replacement rather than an increment: two clients reacting at once
336
- * cannot drift the way `+1`/`-1` would.
361
+ * cannot drift the way `+1`/`-1` would. `count` undefined leaves the
362
+ * aggregate alone (the react ack path); `mine` undefined leaves
363
+ * `myReactions` alone (somebody else's event).
337
364
  */
338
- reaction(messageId, emoji, count) {
365
+ reaction(messageId, emoji, count, mine) {
339
366
  const at = this.items.findIndex((m) => m.id === messageId);
340
367
  if (at === -1) return;
341
368
  const current = this.items[at];
342
- const existing = current.reactions ?? [];
343
- const slot = existing.findIndex((r) => r.emoji === emoji);
344
- let next;
345
- if (count <= 0) next = existing.filter((r) => r.emoji !== emoji);
346
- else if (slot === -1) next = [...existing, { emoji, count }];
347
- else next = existing.map((r, i) => i === slot ? { emoji, count } : r);
369
+ let reactions = current.reactions;
370
+ if (count !== void 0) {
371
+ const existing = current.reactions ?? [];
372
+ const slot = existing.findIndex((r) => r.emoji === emoji);
373
+ if (count <= 0) reactions = existing.filter((r) => r.emoji !== emoji);
374
+ else if (slot === -1) reactions = [...existing, { emoji, count }];
375
+ else reactions = existing.map((r, i) => i === slot ? { emoji, count } : r);
376
+ }
377
+ let myReactions = current.myReactions;
378
+ if (mine !== void 0) {
379
+ const held = current.myReactions ?? [];
380
+ const has = held.includes(emoji);
381
+ if (mine && !has) myReactions = [...held, emoji];
382
+ else if (!mine && has) myReactions = held.filter((e) => e !== emoji);
383
+ }
384
+ if (reactions === current.reactions && myReactions === current.myReactions) return;
385
+ const next = { ...current };
386
+ if (reactions !== void 0) next.reactions = reactions;
387
+ if (myReactions !== void 0) next.myReactions = myReactions;
348
388
  this.own();
349
- this.items[at] = { ...current, reactions: next };
389
+ this.items[at] = next;
350
390
  this.changed();
351
391
  }
352
392
  /** Applies a `thread.updated` to the root message's aggregate. */
@@ -383,14 +423,16 @@ var Timeline = class {
383
423
  }
384
424
  async readOlder(limit) {
385
425
  const first = this.items[0];
386
- if (first === void 0 || first.seq <= 1) return { messages: [], hasMore: false };
426
+ if (first === void 0 || first.seq <= this.floor) return { messages: [], hasMore: false };
387
427
  const epoch = this.epoch;
388
428
  const page = await this.options.fetchRange({ after: 0, before: first.seq, limit });
389
429
  if (this.epoch !== epoch) return { messages: [], hasMore: true };
390
430
  for (const m of page) this.insert(m, false);
391
431
  if (page.length > 0) this.changed();
392
432
  const top = page[0];
393
- return { messages: page, hasMore: page.length >= limit && top !== void 0 && top.seq > 1 };
433
+ const hasMore = page.length >= limit && top !== void 0 && top.seq > this.floor;
434
+ this.exhausted = !hasMore;
435
+ return { messages: page, hasMore };
394
436
  }
395
437
  /**
396
438
  * Fetches everything between what we have and `upTo`.
@@ -538,6 +580,17 @@ var Room = class extends Emitter {
538
580
  onChange: (messages) => this.emit("messages", messages)
539
581
  });
540
582
  }
583
+ /**
584
+ * Whether older history exists above the first message held -- what
585
+ * `loadOlder()` would report as `hasMore`, known before calling it.
586
+ *
587
+ * Derived from the first row's seq against the room's retention floor
588
+ * (the subscribe ack's `minSeq`), and false once a `loadOlder()` came
589
+ * back short. False while nothing is loaded. Re-read it on `messages`.
590
+ */
591
+ get hasOlder() {
592
+ return this.timeline.hasOlder;
593
+ }
541
594
  /** Everything this client knows about the room, in seq order. A new array whenever it changes. */
542
595
  get messages() {
543
596
  return this.timeline.messages;
@@ -616,6 +669,7 @@ var Room = class extends Emitter {
616
669
  this.id = ack.roomId;
617
670
  this.chat.registerRoomId(ack.roomId, this);
618
671
  this.lastSeq = ack.lastSeq;
672
+ if (typeof ack.minSeq === "number") this.timeline.setFloor(ack.minSeq);
619
673
  if (ack.presence !== void 0) this.presence = ack.presence;
620
674
  if (attempt.wasReset) {
621
675
  await this.loadRecent(current);
@@ -659,12 +713,20 @@ var Room = class extends Emitter {
659
713
  });
660
714
  return page.messages;
661
715
  }
662
- /** Adds a reaction. Idempotent, like the frame. */
716
+ /**
717
+ * Adds a reaction. Idempotent, like the frame.
718
+ *
719
+ * The ack updates the row's `myReactions` (not its count -- see below),
720
+ * so the "I reacted" state is right even if the broadcast is missed.
721
+ */
663
722
  async react(messageId, emoji) {
664
723
  await this.chat.send("react", { roomId: await this.resolveId("reacting"), messageId, emoji, op: "add" });
724
+ this.timeline.reaction(messageId, emoji, void 0, true);
665
725
  }
726
+ /** Removes a reaction. Idempotent; the ack clears it from `myReactions`. */
666
727
  async unreact(messageId, emoji) {
667
728
  await this.chat.send("react", { roomId: await this.resolveId("reacting"), messageId, emoji, op: "remove" });
729
+ this.timeline.reaction(messageId, emoji, void 0, false);
668
730
  }
669
731
  /**
670
732
  * Moves this user's read cursor.
@@ -859,7 +921,9 @@ var Room = class extends Emitter {
859
921
  case "reaction.added":
860
922
  case "reaction.removed": {
861
923
  const r = data;
862
- this.timeline.reaction(r.messageId, r.emoji, r.count);
924
+ const me = this.chat.user?.id;
925
+ const mine = me !== void 0 && r.userId === me ? type === "reaction.added" : void 0;
926
+ this.timeline.reaction(r.messageId, r.emoji, r.count, mine);
863
927
  break;
864
928
  }
865
929
  case "thread.updated": {
@@ -1108,6 +1172,48 @@ var ChatClient = class extends Emitter {
1108
1172
  });
1109
1173
  return this.opening;
1110
1174
  }
1175
+ /**
1176
+ * Re-authenticates: calls `token()` again and replaces the socket, keeping
1177
+ * every room handle and every subscription.
1178
+ *
1179
+ * For when the identity the server holds has to change mid-session --
1180
+ * a nickname change, a new avatar, fresh claims. The server reads the
1181
+ * token once, at `auth`, so `sender.name` on the next message is the old
1182
+ * one until the connection is re-authenticated. `close()` + `connect()`
1183
+ * does that too, but passes through `closed` (a consumer's "you are
1184
+ * offline" screen) and tears down the page listeners.
1185
+ *
1186
+ * States: `open` → `reconnecting` → `open`, never `closed`. Rooms
1187
+ * resubscribe from what they hold and catch up the gap, as after any
1188
+ * drop; `messages` is kept. Frames awaiting an ack on the old socket are
1189
+ * rejected with `closed`. `chat.user` is the new identity once this
1190
+ * resolves. If `token()` fails the client keeps retrying in the
1191
+ * background like any reconnect (and this rejects); a `ChatError` from
1192
+ * `token()` stops it at `closed`, as on connect.
1193
+ *
1194
+ * On a client that is not connected (never connected, or `close()`d) it
1195
+ * is `connect()`.
1196
+ */
1197
+ async reconnect() {
1198
+ if (this.opening !== void 0) await this.opening.catch(() => {
1199
+ });
1200
+ const socket = this.socket;
1201
+ if (socket === void 0 || this.state !== "open") return this.connect();
1202
+ this.clearTimers();
1203
+ this.retired.add(socket);
1204
+ this.socket = void 0;
1205
+ this.failPending(new ChatError("closed", "the connection was replaced by reconnect()"));
1206
+ try {
1207
+ socket.close(1e3, "client reconnecting");
1208
+ } catch {
1209
+ }
1210
+ this.opening = this.openOnce("reconnecting").finally(() => {
1211
+ this.opening = void 0;
1212
+ });
1213
+ return this.opening;
1214
+ }
1215
+ /** Sockets `reconnect()` replaced: their late events are not this client's any more. */
1216
+ retired = /* @__PURE__ */ new WeakSet();
1111
1217
  /**
1112
1218
  * Closes for good.
1113
1219
  *
@@ -1221,8 +1327,8 @@ var ChatClient = class extends Emitter {
1221
1327
  this.state = next;
1222
1328
  this.emit("state", next);
1223
1329
  }
1224
- async openOnce() {
1225
- this.setState(this.attempt === 0 ? "connecting" : "reconnecting");
1330
+ async openOnce(state = this.attempt === 0 ? "connecting" : "reconnecting") {
1331
+ this.setState(state);
1226
1332
  let authData;
1227
1333
  try {
1228
1334
  authData = await this.authData();
@@ -1248,6 +1354,7 @@ var ChatClient = class extends Emitter {
1248
1354
  else reject(err);
1249
1355
  };
1250
1356
  socket.addEventListener("message", (ev) => {
1357
+ if (this.retired.has(socket)) return;
1251
1358
  const frame = JSON.parse(String(ev.data));
1252
1359
  if (frame.type === "hello") {
1253
1360
  this.onHello(frame.data);
@@ -1268,6 +1375,7 @@ var ChatClient = class extends Emitter {
1268
1375
  this.onFrame(frame);
1269
1376
  });
1270
1377
  socket.addEventListener("close", () => {
1378
+ if (this.retired.has(socket)) return;
1271
1379
  if (this.socket !== void 0 && this.socket !== socket) return;
1272
1380
  this.clearTimers();
1273
1381
  this.socket = void 0;
package/dist/index.d.cts CHANGED
@@ -142,6 +142,16 @@ type Message = {
142
142
  emoji: string;
143
143
  count: number;
144
144
  }[];
145
+ /**
146
+ * The emoji this client's user has on the message.
147
+ *
148
+ * Filled by history reads and kept in step by the SDK afterwards: a
149
+ * `reaction.*` event whose `userId` is `chat.user.id`, and the ack of
150
+ * this client's own `react()`/`unreact()`, update it. Absent on a row
151
+ * the server rendered without a viewer (a live `message.created` has
152
+ * none yet, which is the same as empty).
153
+ */
154
+ myReactions?: string[];
145
155
  thread?: {
146
156
  count: number;
147
157
  lastSeq?: number;
@@ -281,6 +291,15 @@ declare class Room extends Emitter<RoomEvents> {
281
291
  id?: string;
282
292
  key?: string;
283
293
  });
294
+ /**
295
+ * Whether older history exists above the first message held -- what
296
+ * `loadOlder()` would report as `hasMore`, known before calling it.
297
+ *
298
+ * Derived from the first row's seq against the room's retention floor
299
+ * (the subscribe ack's `minSeq`), and false once a `loadOlder()` came
300
+ * back short. False while nothing is loaded. Re-read it on `messages`.
301
+ */
302
+ get hasOlder(): boolean;
284
303
  /** Everything this client knows about the room, in seq order. A new array whenever it changes. */
285
304
  get messages(): Message[];
286
305
  /**
@@ -348,8 +367,14 @@ declare class Room extends Emitter<RoomEvents> {
348
367
  thread: string;
349
368
  };
350
369
  }): Promise<Message[]>;
351
- /** Adds a reaction. Idempotent, like the frame. */
370
+ /**
371
+ * Adds a reaction. Idempotent, like the frame.
372
+ *
373
+ * The ack updates the row's `myReactions` (not its count -- see below),
374
+ * so the "I reacted" state is right even if the broadcast is missed.
375
+ */
352
376
  react(messageId: string, emoji: string): Promise<void>;
377
+ /** Removes a reaction. Idempotent; the ack clears it from `myReactions`. */
353
378
  unreact(messageId: string, emoji: string): Promise<void>;
354
379
  /**
355
380
  * Moves this user's read cursor.
@@ -686,6 +711,31 @@ declare class ChatClient extends Emitter<ChatClientEvents> {
686
711
  registerRoomId(roomId: string, room: Room): void;
687
712
  /** Opens the connection and resolves when `hello` arrives. */
688
713
  connect(): Promise<void>;
714
+ /**
715
+ * Re-authenticates: calls `token()` again and replaces the socket, keeping
716
+ * every room handle and every subscription.
717
+ *
718
+ * For when the identity the server holds has to change mid-session --
719
+ * a nickname change, a new avatar, fresh claims. The server reads the
720
+ * token once, at `auth`, so `sender.name` on the next message is the old
721
+ * one until the connection is re-authenticated. `close()` + `connect()`
722
+ * does that too, but passes through `closed` (a consumer's "you are
723
+ * offline" screen) and tears down the page listeners.
724
+ *
725
+ * States: `open` → `reconnecting` → `open`, never `closed`. Rooms
726
+ * resubscribe from what they hold and catch up the gap, as after any
727
+ * drop; `messages` is kept. Frames awaiting an ack on the old socket are
728
+ * rejected with `closed`. `chat.user` is the new identity once this
729
+ * resolves. If `token()` fails the client keeps retrying in the
730
+ * background like any reconnect (and this rejects); a `ChatError` from
731
+ * `token()` stops it at `closed`, as on connect.
732
+ *
733
+ * On a client that is not connected (never connected, or `close()`d) it
734
+ * is `connect()`.
735
+ */
736
+ reconnect(): Promise<void>;
737
+ /** Sockets `reconnect()` replaced: their late events are not this client's any more. */
738
+ private readonly retired;
689
739
  /**
690
740
  * Closes for good.
691
741
  *
@@ -840,9 +890,27 @@ declare class Timeline {
840
890
  private shared;
841
891
  /** The `loadOlder` in flight, so a scroll handler firing twice asks once. */
842
892
  private older;
893
+ /**
894
+ * The lowest seq the room still keeps (`minSeq` from the subscribe ack;
895
+ * 1 until told). Below it there is nothing to read, whatever seq says.
896
+ */
897
+ private floor;
898
+ /** A `loadOlder` came back short: the top of what the server keeps was reached. */
899
+ private exhausted;
843
900
  constructor(options: TimelineOptions);
844
901
  /** A new array whenever the list changes; never mutated once returned. */
845
902
  get messages(): Message[];
903
+ /**
904
+ * Whether `loadOlder()` would find anything, answered without asking.
905
+ *
906
+ * `loadOlder()`의 `hasMore`와 같은 판정이다: 맨 위 행이 방의 보존 하한
907
+ * (`minSeq`, seq는 1부터 구멍 없이 붙는다)보다 위에 있고, 앞선 `loadOlder`가
908
+ * 덜 찬 페이지로 끝을 알린 적이 없으면 더 있다. 첫 `loadOlder()` 전에 "이전
909
+ * 메시지 더 보기"를 그릴지 정할 수 있게 한다. 목록이 비었으면 false다.
910
+ */
911
+ get hasOlder(): boolean;
912
+ /** Records the room's retention floor (`minSeq`). */
913
+ setFloor(minSeq: number): void;
846
914
  /** The highest seq this timeline holds, hole or no hole. */
847
915
  get highestSeq(): number;
848
916
  /**
@@ -917,9 +985,11 @@ declare class Timeline {
917
985
  *
918
986
  * The server sends the new `count` with the event, so this is a
919
987
  * replacement rather than an increment: two clients reacting at once
920
- * cannot drift the way `+1`/`-1` would.
988
+ * cannot drift the way `+1`/`-1` would. `count` undefined leaves the
989
+ * aggregate alone (the react ack path); `mine` undefined leaves
990
+ * `myReactions` alone (somebody else's event).
921
991
  */
922
- reaction(messageId: string, emoji: string, count: number): void;
992
+ reaction(messageId: string, emoji: string, count: number | undefined, mine?: boolean): void;
923
993
  /** Applies a `thread.updated` to the root message's aggregate. */
924
994
  thread(rootId: string, count: number, lastSeq?: number): void;
925
995
  /** Applies a `message.deleted`, remembering it if the row is not here. */
package/dist/index.d.ts CHANGED
@@ -142,6 +142,16 @@ type Message = {
142
142
  emoji: string;
143
143
  count: number;
144
144
  }[];
145
+ /**
146
+ * The emoji this client's user has on the message.
147
+ *
148
+ * Filled by history reads and kept in step by the SDK afterwards: a
149
+ * `reaction.*` event whose `userId` is `chat.user.id`, and the ack of
150
+ * this client's own `react()`/`unreact()`, update it. Absent on a row
151
+ * the server rendered without a viewer (a live `message.created` has
152
+ * none yet, which is the same as empty).
153
+ */
154
+ myReactions?: string[];
145
155
  thread?: {
146
156
  count: number;
147
157
  lastSeq?: number;
@@ -281,6 +291,15 @@ declare class Room extends Emitter<RoomEvents> {
281
291
  id?: string;
282
292
  key?: string;
283
293
  });
294
+ /**
295
+ * Whether older history exists above the first message held -- what
296
+ * `loadOlder()` would report as `hasMore`, known before calling it.
297
+ *
298
+ * Derived from the first row's seq against the room's retention floor
299
+ * (the subscribe ack's `minSeq`), and false once a `loadOlder()` came
300
+ * back short. False while nothing is loaded. Re-read it on `messages`.
301
+ */
302
+ get hasOlder(): boolean;
284
303
  /** Everything this client knows about the room, in seq order. A new array whenever it changes. */
285
304
  get messages(): Message[];
286
305
  /**
@@ -348,8 +367,14 @@ declare class Room extends Emitter<RoomEvents> {
348
367
  thread: string;
349
368
  };
350
369
  }): Promise<Message[]>;
351
- /** Adds a reaction. Idempotent, like the frame. */
370
+ /**
371
+ * Adds a reaction. Idempotent, like the frame.
372
+ *
373
+ * The ack updates the row's `myReactions` (not its count -- see below),
374
+ * so the "I reacted" state is right even if the broadcast is missed.
375
+ */
352
376
  react(messageId: string, emoji: string): Promise<void>;
377
+ /** Removes a reaction. Idempotent; the ack clears it from `myReactions`. */
353
378
  unreact(messageId: string, emoji: string): Promise<void>;
354
379
  /**
355
380
  * Moves this user's read cursor.
@@ -686,6 +711,31 @@ declare class ChatClient extends Emitter<ChatClientEvents> {
686
711
  registerRoomId(roomId: string, room: Room): void;
687
712
  /** Opens the connection and resolves when `hello` arrives. */
688
713
  connect(): Promise<void>;
714
+ /**
715
+ * Re-authenticates: calls `token()` again and replaces the socket, keeping
716
+ * every room handle and every subscription.
717
+ *
718
+ * For when the identity the server holds has to change mid-session --
719
+ * a nickname change, a new avatar, fresh claims. The server reads the
720
+ * token once, at `auth`, so `sender.name` on the next message is the old
721
+ * one until the connection is re-authenticated. `close()` + `connect()`
722
+ * does that too, but passes through `closed` (a consumer's "you are
723
+ * offline" screen) and tears down the page listeners.
724
+ *
725
+ * States: `open` → `reconnecting` → `open`, never `closed`. Rooms
726
+ * resubscribe from what they hold and catch up the gap, as after any
727
+ * drop; `messages` is kept. Frames awaiting an ack on the old socket are
728
+ * rejected with `closed`. `chat.user` is the new identity once this
729
+ * resolves. If `token()` fails the client keeps retrying in the
730
+ * background like any reconnect (and this rejects); a `ChatError` from
731
+ * `token()` stops it at `closed`, as on connect.
732
+ *
733
+ * On a client that is not connected (never connected, or `close()`d) it
734
+ * is `connect()`.
735
+ */
736
+ reconnect(): Promise<void>;
737
+ /** Sockets `reconnect()` replaced: their late events are not this client's any more. */
738
+ private readonly retired;
689
739
  /**
690
740
  * Closes for good.
691
741
  *
@@ -840,9 +890,27 @@ declare class Timeline {
840
890
  private shared;
841
891
  /** The `loadOlder` in flight, so a scroll handler firing twice asks once. */
842
892
  private older;
893
+ /**
894
+ * The lowest seq the room still keeps (`minSeq` from the subscribe ack;
895
+ * 1 until told). Below it there is nothing to read, whatever seq says.
896
+ */
897
+ private floor;
898
+ /** A `loadOlder` came back short: the top of what the server keeps was reached. */
899
+ private exhausted;
843
900
  constructor(options: TimelineOptions);
844
901
  /** A new array whenever the list changes; never mutated once returned. */
845
902
  get messages(): Message[];
903
+ /**
904
+ * Whether `loadOlder()` would find anything, answered without asking.
905
+ *
906
+ * `loadOlder()`의 `hasMore`와 같은 판정이다: 맨 위 행이 방의 보존 하한
907
+ * (`minSeq`, seq는 1부터 구멍 없이 붙는다)보다 위에 있고, 앞선 `loadOlder`가
908
+ * 덜 찬 페이지로 끝을 알린 적이 없으면 더 있다. 첫 `loadOlder()` 전에 "이전
909
+ * 메시지 더 보기"를 그릴지 정할 수 있게 한다. 목록이 비었으면 false다.
910
+ */
911
+ get hasOlder(): boolean;
912
+ /** Records the room's retention floor (`minSeq`). */
913
+ setFloor(minSeq: number): void;
846
914
  /** The highest seq this timeline holds, hole or no hole. */
847
915
  get highestSeq(): number;
848
916
  /**
@@ -917,9 +985,11 @@ declare class Timeline {
917
985
  *
918
986
  * The server sends the new `count` with the event, so this is a
919
987
  * replacement rather than an increment: two clients reacting at once
920
- * cannot drift the way `+1`/`-1` would.
988
+ * cannot drift the way `+1`/`-1` would. `count` undefined leaves the
989
+ * aggregate alone (the react ack path); `mine` undefined leaves
990
+ * `myReactions` alone (somebody else's event).
921
991
  */
922
- reaction(messageId: string, emoji: string, count: number): void;
992
+ reaction(messageId: string, emoji: string, count: number | undefined, mine?: boolean): void;
923
993
  /** Applies a `thread.updated` to the root message's aggregate. */
924
994
  thread(rootId: string, count: number, lastSeq?: number): void;
925
995
  /** Applies a `message.deleted`, remembering it if the row is not here. */
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`.
@@ -481,6 +523,17 @@ var Room = class extends Emitter {
481
523
  onChange: (messages) => this.emit("messages", messages)
482
524
  });
483
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
+ }
484
537
  /** Everything this client knows about the room, in seq order. A new array whenever it changes. */
485
538
  get messages() {
486
539
  return this.timeline.messages;
@@ -559,6 +612,7 @@ var Room = class extends Emitter {
559
612
  this.id = ack.roomId;
560
613
  this.chat.registerRoomId(ack.roomId, this);
561
614
  this.lastSeq = ack.lastSeq;
615
+ if (typeof ack.minSeq === "number") this.timeline.setFloor(ack.minSeq);
562
616
  if (ack.presence !== void 0) this.presence = ack.presence;
563
617
  if (attempt.wasReset) {
564
618
  await this.loadRecent(current);
@@ -602,12 +656,20 @@ var Room = class extends Emitter {
602
656
  });
603
657
  return page.messages;
604
658
  }
605
- /** 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
+ */
606
665
  async react(messageId, emoji) {
607
666
  await this.chat.send("react", { roomId: await this.resolveId("reacting"), messageId, emoji, op: "add" });
667
+ this.timeline.reaction(messageId, emoji, void 0, true);
608
668
  }
669
+ /** Removes a reaction. Idempotent; the ack clears it from `myReactions`. */
609
670
  async unreact(messageId, emoji) {
610
671
  await this.chat.send("react", { roomId: await this.resolveId("reacting"), messageId, emoji, op: "remove" });
672
+ this.timeline.reaction(messageId, emoji, void 0, false);
611
673
  }
612
674
  /**
613
675
  * Moves this user's read cursor.
@@ -802,7 +864,9 @@ var Room = class extends Emitter {
802
864
  case "reaction.added":
803
865
  case "reaction.removed": {
804
866
  const r = data;
805
- 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);
806
870
  break;
807
871
  }
808
872
  case "thread.updated": {
@@ -1051,6 +1115,48 @@ var ChatClient = class extends Emitter {
1051
1115
  });
1052
1116
  return this.opening;
1053
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();
1054
1160
  /**
1055
1161
  * Closes for good.
1056
1162
  *
@@ -1164,8 +1270,8 @@ var ChatClient = class extends Emitter {
1164
1270
  this.state = next;
1165
1271
  this.emit("state", next);
1166
1272
  }
1167
- async openOnce() {
1168
- this.setState(this.attempt === 0 ? "connecting" : "reconnecting");
1273
+ async openOnce(state = this.attempt === 0 ? "connecting" : "reconnecting") {
1274
+ this.setState(state);
1169
1275
  let authData;
1170
1276
  try {
1171
1277
  authData = await this.authData();
@@ -1191,6 +1297,7 @@ var ChatClient = class extends Emitter {
1191
1297
  else reject(err);
1192
1298
  };
1193
1299
  socket.addEventListener("message", (ev) => {
1300
+ if (this.retired.has(socket)) return;
1194
1301
  const frame = JSON.parse(String(ev.data));
1195
1302
  if (frame.type === "hello") {
1196
1303
  this.onHello(frame.data);
@@ -1211,6 +1318,7 @@ var ChatClient = class extends Emitter {
1211
1318
  this.onFrame(frame);
1212
1319
  });
1213
1320
  socket.addEventListener("close", () => {
1321
+ if (this.retired.has(socket)) return;
1214
1322
  if (this.socket !== void 0 && this.socket !== socket) return;
1215
1323
  this.clearTimers();
1216
1324
  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.2",
4
4
  "description": "Client SDK for the chat server: one WebSocket, many rooms, ordered history.",
5
5
  "license": "MIT",
6
6
  "type": "module",