@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 +42 -4
- package/README.md +40 -4
- package/dist/index.cjs +123 -15
- package/dist/index.d.cts +73 -3
- package/dist/index.d.ts +73 -3
- package/dist/index.js +123 -15
- package/dist/server/index.cjs +2 -0
- package/dist/server/index.d.cts +39 -1
- package/dist/server/index.d.ts +39 -1
- package/dist/server/index.js +2 -0
- package/package.json +1 -1
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
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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] =
|
|
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 <=
|
|
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
|
-
|
|
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
|
-
/**
|
|
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.
|
|
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(
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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] =
|
|
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 <=
|
|
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
|
-
|
|
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
|
-
/**
|
|
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.
|
|
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(
|
|
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;
|
package/dist/server/index.cjs
CHANGED
|
@@ -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
|
},
|
package/dist/server/index.d.cts
CHANGED
|
@@ -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 };
|
package/dist/server/index.d.ts
CHANGED
|
@@ -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 };
|
package/dist/server/index.js
CHANGED
|
@@ -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
|
},
|