@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/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. Updates that row's `myReactions` **before the frame goes out**, and rolls it back if the server refuses. Never touches the count |
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,33 @@ 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 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
+
146
+ **`myReactions` moves the moment you press.** `react()`/`unreact()` update that row's
147
+ `myReactions` and emit `messages` **before** the frame goes out, so the press does not feel a
148
+ round trip late — the value is already determined by (you, emoji) and the server is idempotent,
149
+ so the ack has nothing new to say. If the server refuses (a limit, permissions) or the ack never
150
+ arrives (timeout, a dropped connection), the SDK rolls it back — unless something newer has
151
+ taken that spot in the meantime. A later press, a `reaction.*` event carrying your own `userId`,
152
+ and a page from `reload()` or a reconnect all beat a pending optimistic value, and a refusal
153
+ never resurrects a stale one. **The SDK owns this field**: an optimistic layer of your own on
154
+ top of it would give it two owners.
155
+
156
+ **The count (`reactions[].count`) is always the server's.** It is never moved locally. If
157
+ somebody else presses the same emoji while your ack is in flight, a local ±1 disagrees with the
158
+ number the server sends, and the event that would have corrected it has already gone by. Counts
159
+ move on `reaction.*` events only.
160
+
161
+ **Publishing (`send()`) is deliberately not optimistic.** A `before_publish` hook may rewrite
162
+ the body (an emoji filter, a link rewrite), so there is no value on the client to draw ahead of
163
+ time. A message joins the list when its `message.created` arrives. A "sending…" indicator is
164
+ your own view state, and unlike a reaction the SDK cannot decide it for you.
165
+
137
166
  **Attach the past to the same list with `loadOlder()`.** `history()` is a direct read that
138
167
  doesn't touch the list, so merging its results with the live list, filtering duplicates, and
139
168
  handling `reset` and reconnects all fall to the consumer. `loadOlder()` puts those rows into
@@ -142,7 +171,14 @@ like any other row. Calling it several times concurrently still makes one reques
142
171
  `hasMore` is `false` you're at the start of the room (or of the retention period).
143
172
  `reset` or `reload()` discards the attached past as well.
144
173
 
174
+ **`hasOlder` and `loadOlder()` describe the SDK's list, not your window.** The SDK never trims
175
+ `room.messages` — if it holds 500 rows, all 500 are there. So if you render a 100-row slice of
176
+ the array you were handed, the other 400 are in `room.messages`, not on the server, and
177
+ `hasOlder === false` is the right answer (it means you are at the start of the room). Widening
178
+ the window is a re-read of `room.messages`; asking the server comes after that.
179
+
145
180
  ```ts
181
+ showLoadMore(room.hasOlder)
146
182
  const { hasMore } = await room.loadOlder()
147
183
  ```
148
184
 
@@ -198,6 +234,20 @@ The last two leave `state` at `closed` and report why on `chat.on('error')` —
198
234
  or during a REST token refresh, where there is no caller to reject. Call `connect()` to come
199
235
  back.
200
236
 
237
+ **After a nickname change, `reconnect()`.** The server reads the name and avatar from the
238
+ token's claims once, when the socket authenticates. So after your backend changes the name,
239
+ the next message on this connection still carries the old one. `reconnect()` calls `token()`
240
+ again, re-authenticates with the new claims, and the rooms carry on from what they hold.
241
+ `close()` + `connect()` works too, but passes through `closed` (your offline screen flashes)
242
+ and removes the page listeners. Frames still awaiting an ack on the old socket reject with
243
+ `closed`. When it resolves, `chat.user` is the new identity.
244
+
245
+ ```ts
246
+ await api.updateNickname(name) // so your backend puts the new name in the next token()
247
+ await chat.reconnect()
248
+ chat.user?.name // the new name
249
+ ```
250
+
201
251
  **The SDK handles token expiry.** The socket authenticates only once on connect, so it stays
202
252
  fine even when the token expires, but REST checks the token on every request. When REST gets a
203
253
  401, the SDK calls `token()` again and retries **once**. A second 401 (origin, revoked token,
@@ -340,12 +390,26 @@ spaces don't overlap.
340
390
 
341
391
  | Group | Methods |
342
392
  |---|---|
343
- | `rooms` | `ensure({key?, type, name?, meta?, members?})`, `ensureDM([a, b])`, `get`, `update`, `delete`, `custom(roomId, payload)`, `members.list/add/remove` |
393
+ | `rooms` | `ensure({key?, type, name?, meta?, members?})`, `ensureDM([a, b])`, `get`, `update`, `delete`, `custom(roomId, payload)`, `presence(roomId, {full?})`, `members.list/add/remove` |
344
394
  | `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` |
395
+ | `users` | `list({cursor?, limit?})`, `update`, `delete({purgeMessages?})`, `purgeMessages(userId)`, `ban({until, reason?})`, `unban`, `revokeTokens`, `disconnect` |
346
396
  | `events` | `list({after?, limit?})` — the outbox. Catches up on missed webhooks |
347
397
  | `webhooks` | `verify(headers, rawBody, {now?, tolerance?})` |
348
398
 
399
+ `rooms.presence(roomId)` is `{count}`; with `{full: true}` it adds `users` (at most 1000,
400
+ `capped: true` when cut). An empty room is `{count: 0}`, not a 404.
401
+
402
+ **`users.purgeMessages(userId)` is moderation** — it deletes every message the user sent in the
403
+ app (tombstones: seq is untouched, and watching clients get `message.deleted`) **without
404
+ withdrawing them.** The name stays. For spam, **ban first**, then call it — a message posted
405
+ while it runs is not in its listing. It does a bounded amount per call, so call again while
406
+ `purgeCapped` is `true`. A withdrawn user is a 404 (use `delete(userId, {purgeMessages: true})`).
407
+
408
+ ```ts
409
+ await chat.users.ban(userId, { until: Date.now() + 7 * 86_400_000, reason: 'spam' })
410
+ while ((await chat.users.purgeMessages(userId)).purgeCapped) {}
411
+ ```
412
+
349
413
  `users.ban`'s `until` is **unix milliseconds and required.** For an indefinite ban, pick a
350
414
  far-future time yourself — it goes into the audit log as a decision, not an accident.
351
415
 
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(...)` | 리액션. 멱등. **프레임보다 먼저** 그 행의 `myReactions`를 고치고, 서버가 거절하면 되돌린다. 개수는 건드리지 않는다 |
89
91
  | `reactionsOf(messageId, {emoji?, cursor?, limit?})` | 누가 눌렀는지. **한 행은 (유저, 이모지) 쌍**이라 한 사람이 여러 줄일 수 있다. `limit` 기본 50 최대 100, 마지막 페이지의 `cursor`는 빈 문자열 |
90
92
  | `markRead(seq, {threadId?})` | 읽음 커서 |
91
93
  | `typing()` | 입력 중. 던지지 않는다 |
@@ -132,6 +134,30 @@ 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가 모두
141
+ 와도 두 번 세지 않는다. 바뀐 행만 새 객체다.
142
+
143
+ **`myReactions`는 누른 즉시 바뀐다.** `react()`/`unreact()`는 프레임을 보내기 **전에**
144
+ 그 행의 `myReactions`를 고치고 `messages`를 내보낸다. 그래서 누른 느낌이 왕복만큼
145
+ 늦지 않는다 — 누른 값은 (나, 이모지)로 이미 정해져 있고 서버는 멱등이라, ack가
146
+ 새로 알려 줄 것이 없다. 서버가 거절하거나(한도, 권한) ack가 못 오면(타임아웃,
147
+ 끊김) 되돌린다. 단, 그 사이 더 새로운 값이 자리를 차지했으면 그대로 둔다: 뒤에 누른
148
+ 값, 내 `userId`가 실린 `reaction.*` 이벤트, `reload()`나 재접속이 가져온 페이지가
149
+ 모두 대기 중인 낙관값을 이긴다. 거절이 낡은 값을 되살리는 일은 없다.
150
+ **이 필드의 주인은 SDK다** — 위에 자기 낙관 계층을 덧대면 주인이 둘이 된다.
151
+
152
+ **개수(`reactions[].count`)는 늘 서버 값이다.** 로컬에서 ±1하지 않는다. ack를
153
+ 기다리는 사이 남이 같은 이모지를 누르면 로컬 계산은 서버가 보낼 수와 어긋나고,
154
+ 그 차이를 고쳐 줄 이벤트는 이미 지나갔다. 개수는 `reaction.*` 이벤트가 옮긴다.
155
+
156
+ **발행(`send()`)은 일부러 낙관적이지 않다.** `before_publish` 훅이 본문을 바꿀 수
157
+ 있어서(이모지 필터, 링크 치환) 미리 그릴 값이 클라이언트에 없다. 메시지는
158
+ `message.created`가 올 때 목록에 들어간다. 보내는 중 표시가 필요하면 그것은 소비자의
159
+ 화면 상태이고, 리액션과 달리 SDK가 대신 정해 줄 수 없다.
160
+
135
161
  **과거는 `loadOlder()`로 같은 목록에 붙인다.** `history()`는 목록을 건드리지 않는
136
162
  직접 읽기라, 그 결과를 라이브 목록과 합치고 중복을 거르고 `reset`과 재접속을
137
163
  처리하는 일이 전부 소비자 몫이 된다. `loadOlder()`는 그 행들을 `messages`에 넣으므로
@@ -139,7 +165,15 @@ room.on('messages', m => (messages.value = m))
139
165
  불러도 요청은 하나이고, `hasMore`가 `false`면 방(또는 보존 기간)의 맨 앞이다.
140
166
  `reset`이나 `reload()`는 붙인 과거도 함께 버린다.
141
167
 
168
+ **`hasOlder`와 `loadOlder()`는 SDK의 목록을 말한다, 당신의 창이 아니라.** SDK는
169
+ `room.messages`를 줄이지 않는다 — 500건을 들고 있으면 500건이 다 거기 있다. 화면
170
+ 성능 때문에 받은 배열을 100건으로 잘라 그리고 있다면, 더 보여 줄 400건은 서버가
171
+ 아니라 `room.messages`에 있다. 그때 `hasOlder`가 `false`인 것은 맞는 답이다(방의
172
+ 맨 앞이라는 뜻이다). 창을 넓히는 일은 `room.messages`를 다시 읽는 것이고, 서버에
173
+ 묻는 것은 그 다음이다.
174
+
142
175
  ```ts
176
+ showLoadMore(room.hasOlder)
143
177
  const { hasMore } = await room.loadOlder()
144
178
  ```
145
179
 
@@ -191,6 +225,19 @@ token: async () => {
191
225
  뒤의 둘은 `state`를 `closed`로 두고 `chat.on('error')`로 이유를 낸다. 재접속 중이나
192
226
  REST의 토큰 갱신 중이라 거절을 받을 호출자가 없어도 그렇다. 다시 붙으려면 `connect()`.
193
227
 
228
+ **닉네임을 바꾸면 `reconnect()`.** 서버는 이름·아바타를 접속할 때 토큰 claim에서
229
+ 한 번 읽는다. 그래서 백엔드에서 이름을 바꿔도 이 접속의 다음 메시지는 옛 이름이다.
230
+ `reconnect()`는 `token()`을 다시 불러 새 claim으로 다시 인증하고, 방은 가진 것에서
231
+ 이어서 따라잡는다. `close()` + `connect()`도 되지만 `closed`를 거쳐(오프라인 화면이
232
+ 깜빡인다) 페이지 리스너까지 떼어 낸다. 옛 소켓에서 ack를 기다리던 프레임은 `closed`로
233
+ 거절된다. 끝나면 `chat.user`가 새 신원이다.
234
+
235
+ ```ts
236
+ await api.updateNickname(name) // 백엔드가 다음 token()에 새 이름을 싣게 한다
237
+ await chat.reconnect()
238
+ chat.user?.name // 새 이름
239
+ ```
240
+
194
241
  **토큰 만료는 SDK가 처리한다.** 소켓은 접속할 때 한 번만 인증하므로 토큰이 만료돼도
195
242
  멀쩡하지만, REST는 요청마다 토큰을 본다. REST가 401을 받으면 SDK가 `token()`을 다시
196
243
  불러 **한 번** 재시도한다. 두 번째 401(오리진, 폐기된 토큰, 밴)은 그대로 던진다.
@@ -331,12 +378,26 @@ app.post('/api/chat-token', (req, res) => {
331
378
 
332
379
  | 그룹 | 메서드 |
333
380
  |---|---|
334
- | `rooms` | `ensure({key?, type, name?, meta?, members?})`, `ensureDM([a, b])`, `get`, `update`, `delete`, `custom(roomId, payload)`, `members.list/add/remove` |
381
+ | `rooms` | `ensure({key?, type, name?, meta?, members?})`, `ensureDM([a, b])`, `get`, `update`, `delete`, `custom(roomId, payload)`, `presence(roomId, {full?})`, `members.list/add/remove` |
335
382
  | `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` |
383
+ | `users` | `list({cursor?, limit?})`, `update`, `delete({purgeMessages?})`, `purgeMessages(userId)`, `ban({until, reason?})`, `unban`, `revokeTokens`, `disconnect` |
337
384
  | `events` | `list({after?, limit?})` — outbox. 놓친 웹훅을 따라잡는다 |
338
385
  | `webhooks` | `verify(headers, rawBody, {now?, tolerance?})` |
339
386
 
387
+ `rooms.presence(roomId)`는 `{count}`이고, `{full: true}`면 `users`(최대 1000명, 잘리면
388
+ `capped: true`)가 붙는다. 빈 방은 404가 아니라 `{count: 0}`이다.
389
+
390
+ **`users.purgeMessages(userId)`는 모더레이션이다** — 그 유저가 앱에서 보낸 메시지를
391
+ 전부 지우되(tombstone: seq는 그대로, 보고 있는 클라이언트에는 `message.deleted`가
392
+ 간다) **탈퇴시키지 않는다.** 이름도 남는다. 스팸이면 **밴을 먼저** 하고 부른다 — 도는
393
+ 사이에 올라온 메시지는 목록에 없다. 한 번에 상한만큼만 하므로 `purgeCapped`가
394
+ `true`인 동안 다시 부른다. 탈퇴한 유저는 404다(그때는 `delete(userId, {purgeMessages: true})`).
395
+
396
+ ```ts
397
+ await chat.users.ban(userId, { until: Date.now() + 7 * 86_400_000, reason: 'spam' })
398
+ while ((await chat.users.purgeMessages(userId)).purgeCapped) {}
399
+ ```
400
+
340
401
  `users.ban`의 `until`은 **unix 밀리초이고 필수다.** 무기한 밴은 먼 미래 시각을
341
402
  직접 고른다 — 감사 로그에 사고가 아니라 결정으로 남는다.
342
403
 
package/dist/index.cjs CHANGED
@@ -204,6 +204,25 @@ 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;
214
+ /**
215
+ * Presses this client has made and the server has not answered yet, one
216
+ * entry per (message, emoji) pair, each remembering the last value the
217
+ * **server** gave for that pair.
218
+ *
219
+ * 되돌릴 자리로 "누르기 직전 화면값"을 쓰면 안 된다. 눌렀다 곧바로 취소하고
220
+ * 두 거절이 차례로 오면, 두 번째 거절이 첫 번째의 낙관값 — 서버가 한 번도
221
+ * 가진 적 없는 값 — 을 화면에 되살린다. 마지막으로 서버가 말한 값만이
222
+ * 되돌릴 자리다.
223
+ */
224
+ presses = /* @__PURE__ */ new Map();
225
+ pressSeq = 0;
207
226
  constructor(options) {
208
227
  this.options = options;
209
228
  }
@@ -212,6 +231,22 @@ var Timeline = class {
212
231
  this.shared = true;
213
232
  return this.items;
214
233
  }
234
+ /**
235
+ * Whether `loadOlder()` would find anything, answered without asking.
236
+ *
237
+ * `loadOlder()`의 `hasMore`와 같은 판정이다: 맨 위 행이 방의 보존 하한
238
+ * (`minSeq`, seq는 1부터 구멍 없이 붙는다)보다 위에 있고, 앞선 `loadOlder`가
239
+ * 덜 찬 페이지로 끝을 알린 적이 없으면 더 있다. 첫 `loadOlder()` 전에 "이전
240
+ * 메시지 더 보기"를 그릴지 정할 수 있게 한다. 목록이 비었으면 false다.
241
+ */
242
+ get hasOlder() {
243
+ const first = this.items[0];
244
+ return !this.exhausted && first !== void 0 && first.seq > this.floor;
245
+ }
246
+ /** Records the room's retention floor (`minSeq`). */
247
+ setFloor(minSeq) {
248
+ this.floor = Math.max(1, minSeq);
249
+ }
215
250
  /** The highest seq this timeline holds, hole or no hole. */
216
251
  get highestSeq() {
217
252
  return this.items.at(-1)?.seq ?? 0;
@@ -255,7 +290,9 @@ var Timeline = class {
255
290
  this.epoch++;
256
291
  this.fills.clear();
257
292
  this.pendingDeletes.clear();
293
+ this.presses.clear();
258
294
  this.items = [];
295
+ this.exhausted = false;
259
296
  this.shared = false;
260
297
  this.changed();
261
298
  }
@@ -281,8 +318,10 @@ var Timeline = class {
281
318
  this.epoch++;
282
319
  this.fills.clear();
283
320
  const page = [...messages].sort((a, b) => a.seq - b.seq);
321
+ for (const m of page) this.serverRendered(m.id);
284
322
  const top = page.at(-1)?.seq ?? 0;
285
323
  this.items = [...page, ...this.items.filter((m) => m.seq > top)];
324
+ this.exhausted = false;
286
325
  this.shared = false;
287
326
  for (const m of this.items) {
288
327
  if (this.pendingDeletes.has(m.id)) this.applyDelete(m.id);
@@ -333,20 +372,84 @@ var Timeline = class {
333
372
  *
334
373
  * The server sends the new `count` with the event, so this is a
335
374
  * replacement rather than an increment: two clients reacting at once
336
- * cannot drift the way `+1`/`-1` would.
375
+ * cannot drift the way `+1`/`-1` would. `count` undefined leaves the
376
+ * aggregate alone (the react ack path); `mine` undefined leaves
377
+ * `myReactions` alone (somebody else's event).
378
+ *
379
+ * This is the **authoritative** entry point: a value the server sent.
380
+ * So a press still waiting on an answer for the same pair is finished
381
+ * here -- a refusal landing afterwards must not push this value back
382
+ * to the older one it was going to restore.
383
+ */
384
+ reaction(messageId, emoji, count, mine) {
385
+ if (mine !== void 0) this.presses.delete(pairKey(messageId, emoji));
386
+ this.applyReaction(messageId, emoji, count, mine);
387
+ }
388
+ /**
389
+ * Applies this client's own react/unreact to `myReactions` **before the
390
+ * frame goes out**, and returns the press so its answer can settle it.
391
+ *
392
+ * 누른 값은 (사용자, 이모지)로 로컬에서 정해지고 엔진은 멱등이므로, 미리
393
+ * 적용해도 ack가 새로 알려 줄 것이 없다. `count`는 일부러 건드리지 않는다 —
394
+ * 그 사이 남이 같은 이모지를 누르면 로컬 +1/-1은 서버가 보낼 수와 다르고,
395
+ * 그 차이를 고칠 이벤트는 이미 지나갔다.
396
+ *
397
+ * Undefined when the row is not held: there is nothing to show now and
398
+ * nothing to put back later.
399
+ */
400
+ pressReaction(messageId, emoji, mine) {
401
+ const at = this.items.findIndex((m) => m.id === messageId);
402
+ if (at === -1) return void 0;
403
+ const key = pairKey(messageId, emoji);
404
+ const base = this.presses.get(key)?.base ?? (this.items[at].myReactions ?? []).includes(emoji);
405
+ const press = { messageId, emoji, mine, seq: ++this.pressSeq };
406
+ this.presses.set(key, { seq: press.seq, base });
407
+ this.applyReaction(messageId, emoji, void 0, mine);
408
+ return press;
409
+ }
410
+ /**
411
+ * Settles a press with what the server said.
412
+ *
413
+ * `accepted` re-asserts the value the press asked for; a refusal puts
414
+ * the last server-given value back. Either way it does nothing once
415
+ * this press no longer owns the pair -- a newer press took it over
416
+ * (and that one's answer decides), or something authoritative already
417
+ * landed on it (a `reaction.*` event carrying this user's id, a
418
+ * `reload()`, a reconnect's catch-up page). Reverting there would
419
+ * resurrect a value older than what is on screen.
337
420
  */
338
- reaction(messageId, emoji, count) {
421
+ settleReaction(press, accepted) {
422
+ const key = pairKey(press.messageId, press.emoji);
423
+ const held = this.presses.get(key);
424
+ if (held === void 0 || held.seq !== press.seq) return;
425
+ this.presses.delete(key);
426
+ this.applyReaction(press.messageId, press.emoji, void 0, accepted ? press.mine : held.base);
427
+ }
428
+ applyReaction(messageId, emoji, count, mine) {
339
429
  const at = this.items.findIndex((m) => m.id === messageId);
340
430
  if (at === -1) return;
341
431
  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);
432
+ let reactions = current.reactions;
433
+ if (count !== void 0) {
434
+ const existing = current.reactions ?? [];
435
+ const slot = existing.findIndex((r) => r.emoji === emoji);
436
+ if (count <= 0) reactions = existing.filter((r) => r.emoji !== emoji);
437
+ else if (slot === -1) reactions = [...existing, { emoji, count }];
438
+ else reactions = existing.map((r, i) => i === slot ? { emoji, count } : r);
439
+ }
440
+ let myReactions = current.myReactions;
441
+ if (mine !== void 0) {
442
+ const held = current.myReactions ?? [];
443
+ const has = held.includes(emoji);
444
+ if (mine && !has) myReactions = [...held, emoji];
445
+ else if (!mine && has) myReactions = held.filter((e) => e !== emoji);
446
+ }
447
+ if (reactions === current.reactions && myReactions === current.myReactions) return;
448
+ const next = { ...current };
449
+ if (reactions !== void 0) next.reactions = reactions;
450
+ if (myReactions !== void 0) next.myReactions = myReactions;
348
451
  this.own();
349
- this.items[at] = { ...current, reactions: next };
452
+ this.items[at] = next;
350
453
  this.changed();
351
454
  }
352
455
  /** Applies a `thread.updated` to the root message's aggregate. */
@@ -383,14 +486,16 @@ var Timeline = class {
383
486
  }
384
487
  async readOlder(limit) {
385
488
  const first = this.items[0];
386
- if (first === void 0 || first.seq <= 1) return { messages: [], hasMore: false };
489
+ if (first === void 0 || first.seq <= this.floor) return { messages: [], hasMore: false };
387
490
  const epoch = this.epoch;
388
491
  const page = await this.options.fetchRange({ after: 0, before: first.seq, limit });
389
492
  if (this.epoch !== epoch) return { messages: [], hasMore: true };
390
493
  for (const m of page) this.insert(m, false);
391
494
  if (page.length > 0) this.changed();
392
495
  const top = page[0];
393
- return { messages: page, hasMore: page.length >= limit && top !== void 0 && top.seq > 1 };
496
+ const hasMore = page.length >= limit && top !== void 0 && top.seq > this.floor;
497
+ this.exhausted = !hasMore;
498
+ return { messages: page, hasMore };
394
499
  }
395
500
  /**
396
501
  * Fetches everything between what we have and `upTo`.
@@ -437,6 +542,7 @@ var Timeline = class {
437
542
  }
438
543
  /** Inserts at the seq position, replacing an existing row with that seq. */
439
544
  insert(message, notify = true) {
545
+ this.serverRendered(message.id);
440
546
  const pending = this.pendingDeletes.has(message.id);
441
547
  if (pending) this.pendingDeletes.delete(message.id);
442
548
  const withDelete = pending ? emptied(message) : message;
@@ -461,6 +567,21 @@ var Timeline = class {
461
567
  this.changed();
462
568
  return true;
463
569
  }
570
+ /**
571
+ * Forgets the presses on a row the server has just re-rendered.
572
+ *
573
+ * 히스토리 응답의 행은 뷰어별 `myReactions`를 싣고 오므로 그 값이 서버의
574
+ * 값이다. 누름을 남겨 두면 그 뒤에 온 거절이 더 새로운 진실 위에 옛 값을
575
+ * 덮어쓴다. 행 전체 단위인 것은 페이지가 그 행의 이모지 전부를 다시 그려
576
+ * 주기 때문이다.
577
+ */
578
+ serverRendered(messageId) {
579
+ if (this.presses.size === 0) return;
580
+ const prefix = `${messageId}\0`;
581
+ for (const key of this.presses.keys()) {
582
+ if (key.startsWith(prefix)) this.presses.delete(key);
583
+ }
584
+ }
464
585
  /** Makes `items` safe to mutate: a copy, if the current one was handed out. */
465
586
  own() {
466
587
  if (!this.shared) return;
@@ -475,6 +596,9 @@ var Timeline = class {
475
596
  function emptied(message) {
476
597
  return { ...message, body: {}, deletedAt: message.deletedAt ?? Date.now() };
477
598
  }
599
+ function pairKey(messageId, emoji) {
600
+ return `${messageId}\0${emoji}`;
601
+ }
478
602
  function createTimeline(options) {
479
603
  return new Timeline(options);
480
604
  }
@@ -538,6 +662,17 @@ var Room = class extends Emitter {
538
662
  onChange: (messages) => this.emit("messages", messages)
539
663
  });
540
664
  }
665
+ /**
666
+ * Whether older history exists above the first message held -- what
667
+ * `loadOlder()` would report as `hasMore`, known before calling it.
668
+ *
669
+ * Derived from the first row's seq against the room's retention floor
670
+ * (the subscribe ack's `minSeq`), and false once a `loadOlder()` came
671
+ * back short. False while nothing is loaded. Re-read it on `messages`.
672
+ */
673
+ get hasOlder() {
674
+ return this.timeline.hasOlder;
675
+ }
541
676
  /** Everything this client knows about the room, in seq order. A new array whenever it changes. */
542
677
  get messages() {
543
678
  return this.timeline.messages;
@@ -616,6 +751,7 @@ var Room = class extends Emitter {
616
751
  this.id = ack.roomId;
617
752
  this.chat.registerRoomId(ack.roomId, this);
618
753
  this.lastSeq = ack.lastSeq;
754
+ if (typeof ack.minSeq === "number") this.timeline.setFloor(ack.minSeq);
619
755
  if (ack.presence !== void 0) this.presence = ack.presence;
620
756
  if (attempt.wasReset) {
621
757
  await this.loadRecent(current);
@@ -659,12 +795,52 @@ var Room = class extends Emitter {
659
795
  });
660
796
  return page.messages;
661
797
  }
662
- /** Adds a reaction. Idempotent, like the frame. */
798
+ /**
799
+ * Adds a reaction. Idempotent, like the frame.
800
+ *
801
+ * The row's `myReactions` moves **before the frame goes out** and rolls
802
+ * back if the server refuses; its count stays the server's. See
803
+ * `toggleReaction`.
804
+ */
663
805
  async react(messageId, emoji) {
664
- await this.chat.send("react", { roomId: await this.resolveId("reacting"), messageId, emoji, op: "add" });
806
+ await this.toggleReaction(messageId, emoji, true);
665
807
  }
808
+ /** Removes a reaction. Idempotent, and optimistic the same way `react` is. */
666
809
  async unreact(messageId, emoji) {
667
- await this.chat.send("react", { roomId: await this.resolveId("reacting"), messageId, emoji, op: "remove" });
810
+ await this.toggleReaction(messageId, emoji, false);
811
+ }
812
+ /**
813
+ * Moves `myReactions` now, sends the frame after.
814
+ *
815
+ * 누른 값은 (사용자, 이모지)로 로컬에서 정해지고 엔진은 멱등이므로 ack를
816
+ * 기다릴 이유가 없다 — 기다리면 누른 느낌이 왕복 시간만큼 늦는다. 소비자가
817
+ * 자기 낙관 계층을 덧대면 이 필드의 주인이 둘이 되므로, SDK가 한다.
818
+ * **발행은 다르다**: `before_publish` 훅이 본문을 바꿀 수 있어 미리 그릴 값이
819
+ * 로컬에 없다. 그래서 `send()`는 낙관적이지 않다.
820
+ *
821
+ * 개수는 옮기지 않는다. ack가 오기 전에 남이 같은 이모지를 누르면 로컬
822
+ * +1/-1은 서버가 보낸 수와 다른 값이 되고, 그것을 고칠 이벤트는 이미
823
+ * 지나갔다. 또 ack와 방 프레임은 다른 길로 와 순서가 없으므로, 늦게 온 내
824
+ * ack(count 1)가 앞서 온 남의 이벤트(count 2)를 되돌린다.
825
+ *
826
+ * 거절·타임아웃·끊김이면 되돌리지만, 그 사이 더 새로운 값이 자리를 차지했다면
827
+ * 두고 나온다(`Timeline.settleReaction`).
828
+ */
829
+ async toggleReaction(messageId, emoji, mine) {
830
+ const press = this.timeline.pressReaction(messageId, emoji, mine);
831
+ try {
832
+ await this.chat.send("react", {
833
+ roomId: await this.resolveId("reacting"),
834
+ messageId,
835
+ emoji,
836
+ op: mine ? "add" : "remove"
837
+ });
838
+ } catch (err) {
839
+ if (press !== void 0) this.timeline.settleReaction(press, false);
840
+ throw err;
841
+ }
842
+ if (press !== void 0) this.timeline.settleReaction(press, true);
843
+ else this.timeline.reaction(messageId, emoji, void 0, mine);
668
844
  }
669
845
  /**
670
846
  * Moves this user's read cursor.
@@ -859,7 +1035,9 @@ var Room = class extends Emitter {
859
1035
  case "reaction.added":
860
1036
  case "reaction.removed": {
861
1037
  const r = data;
862
- this.timeline.reaction(r.messageId, r.emoji, r.count);
1038
+ const me = this.chat.user?.id;
1039
+ const mine = me !== void 0 && r.userId === me ? type === "reaction.added" : void 0;
1040
+ this.timeline.reaction(r.messageId, r.emoji, r.count, mine);
863
1041
  break;
864
1042
  }
865
1043
  case "thread.updated": {
@@ -1108,6 +1286,48 @@ var ChatClient = class extends Emitter {
1108
1286
  });
1109
1287
  return this.opening;
1110
1288
  }
1289
+ /**
1290
+ * Re-authenticates: calls `token()` again and replaces the socket, keeping
1291
+ * every room handle and every subscription.
1292
+ *
1293
+ * For when the identity the server holds has to change mid-session --
1294
+ * a nickname change, a new avatar, fresh claims. The server reads the
1295
+ * token once, at `auth`, so `sender.name` on the next message is the old
1296
+ * one until the connection is re-authenticated. `close()` + `connect()`
1297
+ * does that too, but passes through `closed` (a consumer's "you are
1298
+ * offline" screen) and tears down the page listeners.
1299
+ *
1300
+ * States: `open` → `reconnecting` → `open`, never `closed`. Rooms
1301
+ * resubscribe from what they hold and catch up the gap, as after any
1302
+ * drop; `messages` is kept. Frames awaiting an ack on the old socket are
1303
+ * rejected with `closed`. `chat.user` is the new identity once this
1304
+ * resolves. If `token()` fails the client keeps retrying in the
1305
+ * background like any reconnect (and this rejects); a `ChatError` from
1306
+ * `token()` stops it at `closed`, as on connect.
1307
+ *
1308
+ * On a client that is not connected (never connected, or `close()`d) it
1309
+ * is `connect()`.
1310
+ */
1311
+ async reconnect() {
1312
+ if (this.opening !== void 0) await this.opening.catch(() => {
1313
+ });
1314
+ const socket = this.socket;
1315
+ if (socket === void 0 || this.state !== "open") return this.connect();
1316
+ this.clearTimers();
1317
+ this.retired.add(socket);
1318
+ this.socket = void 0;
1319
+ this.failPending(new ChatError("closed", "the connection was replaced by reconnect()"));
1320
+ try {
1321
+ socket.close(1e3, "client reconnecting");
1322
+ } catch {
1323
+ }
1324
+ this.opening = this.openOnce("reconnecting").finally(() => {
1325
+ this.opening = void 0;
1326
+ });
1327
+ return this.opening;
1328
+ }
1329
+ /** Sockets `reconnect()` replaced: their late events are not this client's any more. */
1330
+ retired = /* @__PURE__ */ new WeakSet();
1111
1331
  /**
1112
1332
  * Closes for good.
1113
1333
  *
@@ -1221,8 +1441,8 @@ var ChatClient = class extends Emitter {
1221
1441
  this.state = next;
1222
1442
  this.emit("state", next);
1223
1443
  }
1224
- async openOnce() {
1225
- this.setState(this.attempt === 0 ? "connecting" : "reconnecting");
1444
+ async openOnce(state = this.attempt === 0 ? "connecting" : "reconnecting") {
1445
+ this.setState(state);
1226
1446
  let authData;
1227
1447
  try {
1228
1448
  authData = await this.authData();
@@ -1248,6 +1468,7 @@ var ChatClient = class extends Emitter {
1248
1468
  else reject(err);
1249
1469
  };
1250
1470
  socket.addEventListener("message", (ev) => {
1471
+ if (this.retired.has(socket)) return;
1251
1472
  const frame = JSON.parse(String(ev.data));
1252
1473
  if (frame.type === "hello") {
1253
1474
  this.onHello(frame.data);
@@ -1268,6 +1489,7 @@ var ChatClient = class extends Emitter {
1268
1489
  this.onFrame(frame);
1269
1490
  });
1270
1491
  socket.addEventListener("close", () => {
1492
+ if (this.retired.has(socket)) return;
1271
1493
  if (this.socket !== void 0 && this.socket !== socket) return;
1272
1494
  this.clearTimers();
1273
1495
  this.socket = void 0;