@kispi/chat 0.2.2 → 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
@@ -87,7 +87,7 @@ off()
87
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 |
88
88
  | `history({before?, after?, limit?, view?})` | Reads the past directly. **Independent of `messages`.** `view` is `'main'`, `'all'`, or `{thread: id}` |
89
89
  | `reload()` | Re-reads the latest page |
90
- | `react(messageId, emoji)` / `unreact(...)` | Reactions. Idempotent. The ack updates that row's `myReactions` |
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 |
91
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 |
92
92
  | `markRead(seq, {threadId?})` | Read cursor |
93
93
  | `typing()` | Typing indicator. Does not throw |
@@ -139,10 +139,30 @@ the screen froze.
139
139
  **Reactions: `reactions` is everyone's counts, `myReactions` is the emoji you pressed.**
140
140
  The SDK keeps both live — counts from the `count` on `reaction.*` events, `myReactions` from
141
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
142
+ devices) and from your own `react()`/`unreact()`. Both are overwrites, not
143
143
  increments, so getting the event and the ack never counts twice. Only the changed row is a new
144
144
  object.
145
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
+
146
166
  **Attach the past to the same list with `loadOlder()`.** `history()` is a direct read that
147
167
  doesn't touch the list, so merging its results with the live list, filtering duplicates, and
148
168
  handling `reset` and reconnects all fall to the consumer. `loadOlder()` puts those rows into
@@ -151,6 +171,12 @@ like any other row. Calling it several times concurrently still makes one reques
151
171
  `hasMore` is `false` you're at the start of the room (or of the retention period).
152
172
  `reset` or `reload()` discards the attached past as well.
153
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
+
154
180
  ```ts
155
181
  showLoadMore(room.hasOlder)
156
182
  const { hasMore } = await room.loadOlder()
package/README.md CHANGED
@@ -87,7 +87,7 @@ off()
87
87
  | `loadOlder({limit?})` | 가장 오래된 메시지 앞 페이지(기본 100)를 읽어 **`messages` 앞에 붙인다.** `{messages, hasMore}`. 스크롤을 올릴 때 쓴다. 목록이 비어 있으면(구독 전) 아무것도 읽지 않는다 |
88
88
  | `history({before?, after?, limit?, view?})` | 과거를 직접 읽는다. **`messages`와 따로 논다.** `view`는 `'main'`, `'all'`, `{thread: id}` |
89
89
  | `reload()` | 최근 페이지를 다시 읽는다 |
90
- | `react(messageId, emoji)` / `unreact(...)` | 리액션. 멱등. ack가 그 행의 `myReactions`를 고친다 |
90
+ | `react(messageId, emoji)` / `unreact(...)` | 리액션. 멱등. **프레임보다 먼저** 그 행의 `myReactions`를 고치고, 서버가 거절하면 되돌린다. 개수는 건드리지 않는다 |
91
91
  | `reactionsOf(messageId, {emoji?, cursor?, limit?})` | 누가 눌렀는지. **한 행은 (유저, 이모지) 쌍**이라 한 사람이 여러 줄일 수 있다. `limit` 기본 50 최대 100, 마지막 페이지의 `cursor`는 빈 문자열 |
92
92
  | `markRead(seq, {threadId?})` | 읽음 커서 |
93
93
  | `typing()` | 입력 중. 던지지 않는다 |
@@ -137,9 +137,27 @@ room.on('messages', m => (messages.value = m))
137
137
  **리액션: `reactions`는 모두의 개수, `myReactions`는 내가 누른 이모지다.** 둘 다
138
138
  SDK가 라이브로 맞춘다 — 개수는 `reaction.*` 이벤트의 `count`로, `myReactions`는 그
139
139
  이벤트의 `userId`가 `chat.user.id`일 때(다른 탭·기기에서 누른 것 포함)와 내
140
- `react()`/`unreact()`의 ack로. 둘 다 더하기가 아니라 덮어쓰기라 이벤트와 ack가 모두
140
+ `react()`/`unreact()`로. 둘 다 더하기가 아니라 덮어쓰기라 이벤트와 ack가 모두
141
141
  와도 두 번 세지 않는다. 바뀐 행만 새 객체다.
142
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
+
143
161
  **과거는 `loadOlder()`로 같은 목록에 붙인다.** `history()`는 목록을 건드리지 않는
144
162
  직접 읽기라, 그 결과를 라이브 목록과 합치고 중복을 거르고 `reset`과 재접속을
145
163
  처리하는 일이 전부 소비자 몫이 된다. `loadOlder()`는 그 행들을 `messages`에 넣으므로
@@ -147,6 +165,13 @@ SDK가 라이브로 맞춘다 — 개수는 `reaction.*` 이벤트의 `count`로
147
165
  불러도 요청은 하나이고, `hasMore`가 `false`면 방(또는 보존 기간)의 맨 앞이다.
148
166
  `reset`이나 `reload()`는 붙인 과거도 함께 버린다.
149
167
 
168
+ **`hasOlder`와 `loadOlder()`는 SDK의 목록을 말한다, 당신의 창이 아니라.** SDK는
169
+ `room.messages`를 줄이지 않는다 — 500건을 들고 있으면 500건이 다 거기 있다. 화면
170
+ 성능 때문에 받은 배열을 100건으로 잘라 그리고 있다면, 더 보여 줄 400건은 서버가
171
+ 아니라 `room.messages`에 있다. 그때 `hasOlder`가 `false`인 것은 맞는 답이다(방의
172
+ 맨 앞이라는 뜻이다). 창을 넓히는 일은 `room.messages`를 다시 읽는 것이고, 서버에
173
+ 묻는 것은 그 다음이다.
174
+
150
175
  ```ts
151
176
  showLoadMore(room.hasOlder)
152
177
  const { hasMore } = await room.loadOlder()
package/dist/index.cjs CHANGED
@@ -211,6 +211,18 @@ var Timeline = class {
211
211
  floor = 1;
212
212
  /** A `loadOlder` came back short: the top of what the server keeps was reached. */
213
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;
214
226
  constructor(options) {
215
227
  this.options = options;
216
228
  }
@@ -278,6 +290,7 @@ var Timeline = class {
278
290
  this.epoch++;
279
291
  this.fills.clear();
280
292
  this.pendingDeletes.clear();
293
+ this.presses.clear();
281
294
  this.items = [];
282
295
  this.exhausted = false;
283
296
  this.shared = false;
@@ -305,6 +318,7 @@ var Timeline = class {
305
318
  this.epoch++;
306
319
  this.fills.clear();
307
320
  const page = [...messages].sort((a, b) => a.seq - b.seq);
321
+ for (const m of page) this.serverRendered(m.id);
308
322
  const top = page.at(-1)?.seq ?? 0;
309
323
  this.items = [...page, ...this.items.filter((m) => m.seq > top)];
310
324
  this.exhausted = false;
@@ -361,8 +375,57 @@ var Timeline = class {
361
375
  * cannot drift the way `+1`/`-1` would. `count` undefined leaves the
362
376
  * aggregate alone (the react ack path); `mine` undefined leaves
363
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.
364
383
  */
365
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.
420
+ */
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) {
366
429
  const at = this.items.findIndex((m) => m.id === messageId);
367
430
  if (at === -1) return;
368
431
  const current = this.items[at];
@@ -479,6 +542,7 @@ var Timeline = class {
479
542
  }
480
543
  /** Inserts at the seq position, replacing an existing row with that seq. */
481
544
  insert(message, notify = true) {
545
+ this.serverRendered(message.id);
482
546
  const pending = this.pendingDeletes.has(message.id);
483
547
  if (pending) this.pendingDeletes.delete(message.id);
484
548
  const withDelete = pending ? emptied(message) : message;
@@ -503,6 +567,21 @@ var Timeline = class {
503
567
  this.changed();
504
568
  return true;
505
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
+ }
506
585
  /** Makes `items` safe to mutate: a copy, if the current one was handed out. */
507
586
  own() {
508
587
  if (!this.shared) return;
@@ -517,6 +596,9 @@ var Timeline = class {
517
596
  function emptied(message) {
518
597
  return { ...message, body: {}, deletedAt: message.deletedAt ?? Date.now() };
519
598
  }
599
+ function pairKey(messageId, emoji) {
600
+ return `${messageId}\0${emoji}`;
601
+ }
520
602
  function createTimeline(options) {
521
603
  return new Timeline(options);
522
604
  }
@@ -716,17 +798,49 @@ var Room = class extends Emitter {
716
798
  /**
717
799
  * Adds a reaction. Idempotent, like the frame.
718
800
  *
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.
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`.
721
804
  */
722
805
  async react(messageId, emoji) {
723
- await this.chat.send("react", { roomId: await this.resolveId("reacting"), messageId, emoji, op: "add" });
724
- this.timeline.reaction(messageId, emoji, void 0, true);
806
+ await this.toggleReaction(messageId, emoji, true);
725
807
  }
726
- /** Removes a reaction. Idempotent; the ack clears it from `myReactions`. */
808
+ /** Removes a reaction. Idempotent, and optimistic the same way `react` is. */
727
809
  async unreact(messageId, emoji) {
728
- await this.chat.send("react", { roomId: await this.resolveId("reacting"), messageId, emoji, op: "remove" });
729
- this.timeline.reaction(messageId, emoji, void 0, false);
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);
730
844
  }
731
845
  /**
732
846
  * Moves this user's read cursor.
package/dist/index.d.cts CHANGED
@@ -146,10 +146,15 @@ type Message = {
146
146
  * The emoji this client's user has on the message.
147
147
  *
148
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).
149
+ * `reaction.*` event whose `userId` is `chat.user.id` updates it, and
150
+ * this client's own `react()`/`unreact()` update it **before the frame
151
+ * goes out**, rolling back if the server refuses. Absent on a row the
152
+ * server rendered without a viewer (a live `message.created` has none
153
+ * yet, which is the same as empty).
154
+ *
155
+ * The SDK owns this field, so a consumer does not need -- and should
156
+ * not add -- an optimistic layer of its own on top of it. The count in
157
+ * `reactions` is the opposite: always the server's, never moved locally.
153
158
  */
154
159
  myReactions?: string[];
155
160
  thread?: {
@@ -370,12 +375,31 @@ declare class Room extends Emitter<RoomEvents> {
370
375
  /**
371
376
  * Adds a reaction. Idempotent, like the frame.
372
377
  *
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.
378
+ * The row's `myReactions` moves **before the frame goes out** and rolls
379
+ * back if the server refuses; its count stays the server's. See
380
+ * `toggleReaction`.
375
381
  */
376
382
  react(messageId: string, emoji: string): Promise<void>;
377
- /** Removes a reaction. Idempotent; the ack clears it from `myReactions`. */
383
+ /** Removes a reaction. Idempotent, and optimistic the same way `react` is. */
378
384
  unreact(messageId: string, emoji: string): Promise<void>;
385
+ /**
386
+ * Moves `myReactions` now, sends the frame after.
387
+ *
388
+ * 누른 값은 (사용자, 이모지)로 로컬에서 정해지고 엔진은 멱등이므로 ack를
389
+ * 기다릴 이유가 없다 — 기다리면 누른 느낌이 왕복 시간만큼 늦는다. 소비자가
390
+ * 자기 낙관 계층을 덧대면 이 필드의 주인이 둘이 되므로, SDK가 한다.
391
+ * **발행은 다르다**: `before_publish` 훅이 본문을 바꿀 수 있어 미리 그릴 값이
392
+ * 로컬에 없다. 그래서 `send()`는 낙관적이지 않다.
393
+ *
394
+ * 개수는 옮기지 않는다. ack가 오기 전에 남이 같은 이모지를 누르면 로컬
395
+ * +1/-1은 서버가 보낸 수와 다른 값이 되고, 그것을 고칠 이벤트는 이미
396
+ * 지나갔다. 또 ack와 방 프레임은 다른 길로 와 순서가 없으므로, 늦게 온 내
397
+ * ack(count 1)가 앞서 온 남의 이벤트(count 2)를 되돌린다.
398
+ *
399
+ * 거절·타임아웃·끊김이면 되돌리지만, 그 사이 더 새로운 값이 자리를 차지했다면
400
+ * 두고 나온다(`Timeline.settleReaction`).
401
+ */
402
+ private toggleReaction;
379
403
  /**
380
404
  * Moves this user's read cursor.
381
405
  *
@@ -847,6 +871,21 @@ type FetchRange = (range: {
847
871
  before: number;
848
872
  limit: number;
849
873
  }) => Promise<Message[]>;
874
+ /**
875
+ * A press of this client's own that the server has not answered yet.
876
+ *
877
+ * Carries its own sequence number, which is the whole point: by the time
878
+ * an answer comes back the (message, emoji) pair may belong to a newer
879
+ * press, and settling this one then would put a value nobody is showing
880
+ * any more back on screen.
881
+ */
882
+ type PendingReaction = {
883
+ messageId: string;
884
+ emoji: string;
885
+ /** What the press asked for: in `myReactions`, or out of it. */
886
+ mine: boolean;
887
+ seq: number;
888
+ };
850
889
  type TimelineOptions = {
851
890
  fetchRange: FetchRange;
852
891
  /** Called whenever the list changes. */
@@ -897,6 +936,18 @@ declare class Timeline {
897
936
  private floor;
898
937
  /** A `loadOlder` came back short: the top of what the server keeps was reached. */
899
938
  private exhausted;
939
+ /**
940
+ * Presses this client has made and the server has not answered yet, one
941
+ * entry per (message, emoji) pair, each remembering the last value the
942
+ * **server** gave for that pair.
943
+ *
944
+ * 되돌릴 자리로 "누르기 직전 화면값"을 쓰면 안 된다. 눌렀다 곧바로 취소하고
945
+ * 두 거절이 차례로 오면, 두 번째 거절이 첫 번째의 낙관값 — 서버가 한 번도
946
+ * 가진 적 없는 값 — 을 화면에 되살린다. 마지막으로 서버가 말한 값만이
947
+ * 되돌릴 자리다.
948
+ */
949
+ private readonly presses;
950
+ private pressSeq;
900
951
  constructor(options: TimelineOptions);
901
952
  /** A new array whenever the list changes; never mutated once returned. */
902
953
  get messages(): Message[];
@@ -988,8 +1039,39 @@ declare class Timeline {
988
1039
  * cannot drift the way `+1`/`-1` would. `count` undefined leaves the
989
1040
  * aggregate alone (the react ack path); `mine` undefined leaves
990
1041
  * `myReactions` alone (somebody else's event).
1042
+ *
1043
+ * This is the **authoritative** entry point: a value the server sent.
1044
+ * So a press still waiting on an answer for the same pair is finished
1045
+ * here -- a refusal landing afterwards must not push this value back
1046
+ * to the older one it was going to restore.
991
1047
  */
992
1048
  reaction(messageId: string, emoji: string, count: number | undefined, mine?: boolean): void;
1049
+ /**
1050
+ * Applies this client's own react/unreact to `myReactions` **before the
1051
+ * frame goes out**, and returns the press so its answer can settle it.
1052
+ *
1053
+ * 누른 값은 (사용자, 이모지)로 로컬에서 정해지고 엔진은 멱등이므로, 미리
1054
+ * 적용해도 ack가 새로 알려 줄 것이 없다. `count`는 일부러 건드리지 않는다 —
1055
+ * 그 사이 남이 같은 이모지를 누르면 로컬 +1/-1은 서버가 보낼 수와 다르고,
1056
+ * 그 차이를 고칠 이벤트는 이미 지나갔다.
1057
+ *
1058
+ * Undefined when the row is not held: there is nothing to show now and
1059
+ * nothing to put back later.
1060
+ */
1061
+ pressReaction(messageId: string, emoji: string, mine: boolean): PendingReaction | undefined;
1062
+ /**
1063
+ * Settles a press with what the server said.
1064
+ *
1065
+ * `accepted` re-asserts the value the press asked for; a refusal puts
1066
+ * the last server-given value back. Either way it does nothing once
1067
+ * this press no longer owns the pair -- a newer press took it over
1068
+ * (and that one's answer decides), or something authoritative already
1069
+ * landed on it (a `reaction.*` event carrying this user's id, a
1070
+ * `reload()`, a reconnect's catch-up page). Reverting there would
1071
+ * resurrect a value older than what is on screen.
1072
+ */
1073
+ settleReaction(press: PendingReaction, accepted: boolean): void;
1074
+ private applyReaction;
993
1075
  /** Applies a `thread.updated` to the root message's aggregate. */
994
1076
  thread(rootId: string, count: number, lastSeq?: number): void;
995
1077
  /** Applies a `message.deleted`, remembering it if the row is not here. */
@@ -1032,10 +1114,19 @@ declare class Timeline {
1032
1114
  /** Inserts at the seq position, replacing an existing row with that seq. */
1033
1115
  private insert;
1034
1116
  private applyDelete;
1117
+ /**
1118
+ * Forgets the presses on a row the server has just re-rendered.
1119
+ *
1120
+ * 히스토리 응답의 행은 뷰어별 `myReactions`를 싣고 오므로 그 값이 서버의
1121
+ * 값이다. 누름을 남겨 두면 그 뒤에 온 거절이 더 새로운 진실 위에 옛 값을
1122
+ * 덮어쓴다. 행 전체 단위인 것은 페이지가 그 행의 이모지 전부를 다시 그려
1123
+ * 주기 때문이다.
1124
+ */
1125
+ private serverRendered;
1035
1126
  /** Makes `items` safe to mutate: a copy, if the current one was handed out. */
1036
1127
  private own;
1037
1128
  private changed;
1038
1129
  }
1039
1130
  declare function createTimeline(options: TimelineOptions): Timeline;
1040
1131
 
1041
- export { type AuthUser, ChatClient, type ChatClientEvents, type ChatClientOptions, ChatError, type ConnectionState, type FetchRange, type Frame, type Hello, type Message, type PublishAck, type ReactionUser, type ReactionsPage, Room, type RoomEvents, type SendInput, type SendOptions, Timeline, type TimelineOptions, type WebSocketFactory, type WebSocketLike, createChatClient, createTimeline };
1132
+ export { type AuthUser, ChatClient, type ChatClientEvents, type ChatClientOptions, ChatError, type ConnectionState, type FetchRange, type Frame, type Hello, type Message, type PendingReaction, type PublishAck, type ReactionUser, type ReactionsPage, Room, type RoomEvents, type SendInput, type SendOptions, Timeline, type TimelineOptions, type WebSocketFactory, type WebSocketLike, createChatClient, createTimeline };
package/dist/index.d.ts CHANGED
@@ -146,10 +146,15 @@ type Message = {
146
146
  * The emoji this client's user has on the message.
147
147
  *
148
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).
149
+ * `reaction.*` event whose `userId` is `chat.user.id` updates it, and
150
+ * this client's own `react()`/`unreact()` update it **before the frame
151
+ * goes out**, rolling back if the server refuses. Absent on a row the
152
+ * server rendered without a viewer (a live `message.created` has none
153
+ * yet, which is the same as empty).
154
+ *
155
+ * The SDK owns this field, so a consumer does not need -- and should
156
+ * not add -- an optimistic layer of its own on top of it. The count in
157
+ * `reactions` is the opposite: always the server's, never moved locally.
153
158
  */
154
159
  myReactions?: string[];
155
160
  thread?: {
@@ -370,12 +375,31 @@ declare class Room extends Emitter<RoomEvents> {
370
375
  /**
371
376
  * Adds a reaction. Idempotent, like the frame.
372
377
  *
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.
378
+ * The row's `myReactions` moves **before the frame goes out** and rolls
379
+ * back if the server refuses; its count stays the server's. See
380
+ * `toggleReaction`.
375
381
  */
376
382
  react(messageId: string, emoji: string): Promise<void>;
377
- /** Removes a reaction. Idempotent; the ack clears it from `myReactions`. */
383
+ /** Removes a reaction. Idempotent, and optimistic the same way `react` is. */
378
384
  unreact(messageId: string, emoji: string): Promise<void>;
385
+ /**
386
+ * Moves `myReactions` now, sends the frame after.
387
+ *
388
+ * 누른 값은 (사용자, 이모지)로 로컬에서 정해지고 엔진은 멱등이므로 ack를
389
+ * 기다릴 이유가 없다 — 기다리면 누른 느낌이 왕복 시간만큼 늦는다. 소비자가
390
+ * 자기 낙관 계층을 덧대면 이 필드의 주인이 둘이 되므로, SDK가 한다.
391
+ * **발행은 다르다**: `before_publish` 훅이 본문을 바꿀 수 있어 미리 그릴 값이
392
+ * 로컬에 없다. 그래서 `send()`는 낙관적이지 않다.
393
+ *
394
+ * 개수는 옮기지 않는다. ack가 오기 전에 남이 같은 이모지를 누르면 로컬
395
+ * +1/-1은 서버가 보낸 수와 다른 값이 되고, 그것을 고칠 이벤트는 이미
396
+ * 지나갔다. 또 ack와 방 프레임은 다른 길로 와 순서가 없으므로, 늦게 온 내
397
+ * ack(count 1)가 앞서 온 남의 이벤트(count 2)를 되돌린다.
398
+ *
399
+ * 거절·타임아웃·끊김이면 되돌리지만, 그 사이 더 새로운 값이 자리를 차지했다면
400
+ * 두고 나온다(`Timeline.settleReaction`).
401
+ */
402
+ private toggleReaction;
379
403
  /**
380
404
  * Moves this user's read cursor.
381
405
  *
@@ -847,6 +871,21 @@ type FetchRange = (range: {
847
871
  before: number;
848
872
  limit: number;
849
873
  }) => Promise<Message[]>;
874
+ /**
875
+ * A press of this client's own that the server has not answered yet.
876
+ *
877
+ * Carries its own sequence number, which is the whole point: by the time
878
+ * an answer comes back the (message, emoji) pair may belong to a newer
879
+ * press, and settling this one then would put a value nobody is showing
880
+ * any more back on screen.
881
+ */
882
+ type PendingReaction = {
883
+ messageId: string;
884
+ emoji: string;
885
+ /** What the press asked for: in `myReactions`, or out of it. */
886
+ mine: boolean;
887
+ seq: number;
888
+ };
850
889
  type TimelineOptions = {
851
890
  fetchRange: FetchRange;
852
891
  /** Called whenever the list changes. */
@@ -897,6 +936,18 @@ declare class Timeline {
897
936
  private floor;
898
937
  /** A `loadOlder` came back short: the top of what the server keeps was reached. */
899
938
  private exhausted;
939
+ /**
940
+ * Presses this client has made and the server has not answered yet, one
941
+ * entry per (message, emoji) pair, each remembering the last value the
942
+ * **server** gave for that pair.
943
+ *
944
+ * 되돌릴 자리로 "누르기 직전 화면값"을 쓰면 안 된다. 눌렀다 곧바로 취소하고
945
+ * 두 거절이 차례로 오면, 두 번째 거절이 첫 번째의 낙관값 — 서버가 한 번도
946
+ * 가진 적 없는 값 — 을 화면에 되살린다. 마지막으로 서버가 말한 값만이
947
+ * 되돌릴 자리다.
948
+ */
949
+ private readonly presses;
950
+ private pressSeq;
900
951
  constructor(options: TimelineOptions);
901
952
  /** A new array whenever the list changes; never mutated once returned. */
902
953
  get messages(): Message[];
@@ -988,8 +1039,39 @@ declare class Timeline {
988
1039
  * cannot drift the way `+1`/`-1` would. `count` undefined leaves the
989
1040
  * aggregate alone (the react ack path); `mine` undefined leaves
990
1041
  * `myReactions` alone (somebody else's event).
1042
+ *
1043
+ * This is the **authoritative** entry point: a value the server sent.
1044
+ * So a press still waiting on an answer for the same pair is finished
1045
+ * here -- a refusal landing afterwards must not push this value back
1046
+ * to the older one it was going to restore.
991
1047
  */
992
1048
  reaction(messageId: string, emoji: string, count: number | undefined, mine?: boolean): void;
1049
+ /**
1050
+ * Applies this client's own react/unreact to `myReactions` **before the
1051
+ * frame goes out**, and returns the press so its answer can settle it.
1052
+ *
1053
+ * 누른 값은 (사용자, 이모지)로 로컬에서 정해지고 엔진은 멱등이므로, 미리
1054
+ * 적용해도 ack가 새로 알려 줄 것이 없다. `count`는 일부러 건드리지 않는다 —
1055
+ * 그 사이 남이 같은 이모지를 누르면 로컬 +1/-1은 서버가 보낼 수와 다르고,
1056
+ * 그 차이를 고칠 이벤트는 이미 지나갔다.
1057
+ *
1058
+ * Undefined when the row is not held: there is nothing to show now and
1059
+ * nothing to put back later.
1060
+ */
1061
+ pressReaction(messageId: string, emoji: string, mine: boolean): PendingReaction | undefined;
1062
+ /**
1063
+ * Settles a press with what the server said.
1064
+ *
1065
+ * `accepted` re-asserts the value the press asked for; a refusal puts
1066
+ * the last server-given value back. Either way it does nothing once
1067
+ * this press no longer owns the pair -- a newer press took it over
1068
+ * (and that one's answer decides), or something authoritative already
1069
+ * landed on it (a `reaction.*` event carrying this user's id, a
1070
+ * `reload()`, a reconnect's catch-up page). Reverting there would
1071
+ * resurrect a value older than what is on screen.
1072
+ */
1073
+ settleReaction(press: PendingReaction, accepted: boolean): void;
1074
+ private applyReaction;
993
1075
  /** Applies a `thread.updated` to the root message's aggregate. */
994
1076
  thread(rootId: string, count: number, lastSeq?: number): void;
995
1077
  /** Applies a `message.deleted`, remembering it if the row is not here. */
@@ -1032,10 +1114,19 @@ declare class Timeline {
1032
1114
  /** Inserts at the seq position, replacing an existing row with that seq. */
1033
1115
  private insert;
1034
1116
  private applyDelete;
1117
+ /**
1118
+ * Forgets the presses on a row the server has just re-rendered.
1119
+ *
1120
+ * 히스토리 응답의 행은 뷰어별 `myReactions`를 싣고 오므로 그 값이 서버의
1121
+ * 값이다. 누름을 남겨 두면 그 뒤에 온 거절이 더 새로운 진실 위에 옛 값을
1122
+ * 덮어쓴다. 행 전체 단위인 것은 페이지가 그 행의 이모지 전부를 다시 그려
1123
+ * 주기 때문이다.
1124
+ */
1125
+ private serverRendered;
1035
1126
  /** Makes `items` safe to mutate: a copy, if the current one was handed out. */
1036
1127
  private own;
1037
1128
  private changed;
1038
1129
  }
1039
1130
  declare function createTimeline(options: TimelineOptions): Timeline;
1040
1131
 
1041
- export { type AuthUser, ChatClient, type ChatClientEvents, type ChatClientOptions, ChatError, type ConnectionState, type FetchRange, type Frame, type Hello, type Message, type PublishAck, type ReactionUser, type ReactionsPage, Room, type RoomEvents, type SendInput, type SendOptions, Timeline, type TimelineOptions, type WebSocketFactory, type WebSocketLike, createChatClient, createTimeline };
1132
+ export { type AuthUser, ChatClient, type ChatClientEvents, type ChatClientOptions, ChatError, type ConnectionState, type FetchRange, type Frame, type Hello, type Message, type PendingReaction, type PublishAck, type ReactionUser, type ReactionsPage, Room, type RoomEvents, type SendInput, type SendOptions, Timeline, type TimelineOptions, type WebSocketFactory, type WebSocketLike, createChatClient, createTimeline };
package/dist/index.js CHANGED
@@ -154,6 +154,18 @@ var Timeline = class {
154
154
  floor = 1;
155
155
  /** A `loadOlder` came back short: the top of what the server keeps was reached. */
156
156
  exhausted = false;
157
+ /**
158
+ * Presses this client has made and the server has not answered yet, one
159
+ * entry per (message, emoji) pair, each remembering the last value the
160
+ * **server** gave for that pair.
161
+ *
162
+ * 되돌릴 자리로 "누르기 직전 화면값"을 쓰면 안 된다. 눌렀다 곧바로 취소하고
163
+ * 두 거절이 차례로 오면, 두 번째 거절이 첫 번째의 낙관값 — 서버가 한 번도
164
+ * 가진 적 없는 값 — 을 화면에 되살린다. 마지막으로 서버가 말한 값만이
165
+ * 되돌릴 자리다.
166
+ */
167
+ presses = /* @__PURE__ */ new Map();
168
+ pressSeq = 0;
157
169
  constructor(options) {
158
170
  this.options = options;
159
171
  }
@@ -221,6 +233,7 @@ var Timeline = class {
221
233
  this.epoch++;
222
234
  this.fills.clear();
223
235
  this.pendingDeletes.clear();
236
+ this.presses.clear();
224
237
  this.items = [];
225
238
  this.exhausted = false;
226
239
  this.shared = false;
@@ -248,6 +261,7 @@ var Timeline = class {
248
261
  this.epoch++;
249
262
  this.fills.clear();
250
263
  const page = [...messages].sort((a, b) => a.seq - b.seq);
264
+ for (const m of page) this.serverRendered(m.id);
251
265
  const top = page.at(-1)?.seq ?? 0;
252
266
  this.items = [...page, ...this.items.filter((m) => m.seq > top)];
253
267
  this.exhausted = false;
@@ -304,8 +318,57 @@ var Timeline = class {
304
318
  * cannot drift the way `+1`/`-1` would. `count` undefined leaves the
305
319
  * aggregate alone (the react ack path); `mine` undefined leaves
306
320
  * `myReactions` alone (somebody else's event).
321
+ *
322
+ * This is the **authoritative** entry point: a value the server sent.
323
+ * So a press still waiting on an answer for the same pair is finished
324
+ * here -- a refusal landing afterwards must not push this value back
325
+ * to the older one it was going to restore.
307
326
  */
308
327
  reaction(messageId, emoji, count, mine) {
328
+ if (mine !== void 0) this.presses.delete(pairKey(messageId, emoji));
329
+ this.applyReaction(messageId, emoji, count, mine);
330
+ }
331
+ /**
332
+ * Applies this client's own react/unreact to `myReactions` **before the
333
+ * frame goes out**, and returns the press so its answer can settle it.
334
+ *
335
+ * 누른 값은 (사용자, 이모지)로 로컬에서 정해지고 엔진은 멱등이므로, 미리
336
+ * 적용해도 ack가 새로 알려 줄 것이 없다. `count`는 일부러 건드리지 않는다 —
337
+ * 그 사이 남이 같은 이모지를 누르면 로컬 +1/-1은 서버가 보낼 수와 다르고,
338
+ * 그 차이를 고칠 이벤트는 이미 지나갔다.
339
+ *
340
+ * Undefined when the row is not held: there is nothing to show now and
341
+ * nothing to put back later.
342
+ */
343
+ pressReaction(messageId, emoji, mine) {
344
+ const at = this.items.findIndex((m) => m.id === messageId);
345
+ if (at === -1) return void 0;
346
+ const key = pairKey(messageId, emoji);
347
+ const base = this.presses.get(key)?.base ?? (this.items[at].myReactions ?? []).includes(emoji);
348
+ const press = { messageId, emoji, mine, seq: ++this.pressSeq };
349
+ this.presses.set(key, { seq: press.seq, base });
350
+ this.applyReaction(messageId, emoji, void 0, mine);
351
+ return press;
352
+ }
353
+ /**
354
+ * Settles a press with what the server said.
355
+ *
356
+ * `accepted` re-asserts the value the press asked for; a refusal puts
357
+ * the last server-given value back. Either way it does nothing once
358
+ * this press no longer owns the pair -- a newer press took it over
359
+ * (and that one's answer decides), or something authoritative already
360
+ * landed on it (a `reaction.*` event carrying this user's id, a
361
+ * `reload()`, a reconnect's catch-up page). Reverting there would
362
+ * resurrect a value older than what is on screen.
363
+ */
364
+ settleReaction(press, accepted) {
365
+ const key = pairKey(press.messageId, press.emoji);
366
+ const held = this.presses.get(key);
367
+ if (held === void 0 || held.seq !== press.seq) return;
368
+ this.presses.delete(key);
369
+ this.applyReaction(press.messageId, press.emoji, void 0, accepted ? press.mine : held.base);
370
+ }
371
+ applyReaction(messageId, emoji, count, mine) {
309
372
  const at = this.items.findIndex((m) => m.id === messageId);
310
373
  if (at === -1) return;
311
374
  const current = this.items[at];
@@ -422,6 +485,7 @@ var Timeline = class {
422
485
  }
423
486
  /** Inserts at the seq position, replacing an existing row with that seq. */
424
487
  insert(message, notify = true) {
488
+ this.serverRendered(message.id);
425
489
  const pending = this.pendingDeletes.has(message.id);
426
490
  if (pending) this.pendingDeletes.delete(message.id);
427
491
  const withDelete = pending ? emptied(message) : message;
@@ -446,6 +510,21 @@ var Timeline = class {
446
510
  this.changed();
447
511
  return true;
448
512
  }
513
+ /**
514
+ * Forgets the presses on a row the server has just re-rendered.
515
+ *
516
+ * 히스토리 응답의 행은 뷰어별 `myReactions`를 싣고 오므로 그 값이 서버의
517
+ * 값이다. 누름을 남겨 두면 그 뒤에 온 거절이 더 새로운 진실 위에 옛 값을
518
+ * 덮어쓴다. 행 전체 단위인 것은 페이지가 그 행의 이모지 전부를 다시 그려
519
+ * 주기 때문이다.
520
+ */
521
+ serverRendered(messageId) {
522
+ if (this.presses.size === 0) return;
523
+ const prefix = `${messageId}\0`;
524
+ for (const key of this.presses.keys()) {
525
+ if (key.startsWith(prefix)) this.presses.delete(key);
526
+ }
527
+ }
449
528
  /** Makes `items` safe to mutate: a copy, if the current one was handed out. */
450
529
  own() {
451
530
  if (!this.shared) return;
@@ -460,6 +539,9 @@ var Timeline = class {
460
539
  function emptied(message) {
461
540
  return { ...message, body: {}, deletedAt: message.deletedAt ?? Date.now() };
462
541
  }
542
+ function pairKey(messageId, emoji) {
543
+ return `${messageId}\0${emoji}`;
544
+ }
463
545
  function createTimeline(options) {
464
546
  return new Timeline(options);
465
547
  }
@@ -659,17 +741,49 @@ var Room = class extends Emitter {
659
741
  /**
660
742
  * Adds a reaction. Idempotent, like the frame.
661
743
  *
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.
744
+ * The row's `myReactions` moves **before the frame goes out** and rolls
745
+ * back if the server refuses; its count stays the server's. See
746
+ * `toggleReaction`.
664
747
  */
665
748
  async react(messageId, emoji) {
666
- await this.chat.send("react", { roomId: await this.resolveId("reacting"), messageId, emoji, op: "add" });
667
- this.timeline.reaction(messageId, emoji, void 0, true);
749
+ await this.toggleReaction(messageId, emoji, true);
668
750
  }
669
- /** Removes a reaction. Idempotent; the ack clears it from `myReactions`. */
751
+ /** Removes a reaction. Idempotent, and optimistic the same way `react` is. */
670
752
  async unreact(messageId, emoji) {
671
- await this.chat.send("react", { roomId: await this.resolveId("reacting"), messageId, emoji, op: "remove" });
672
- this.timeline.reaction(messageId, emoji, void 0, false);
753
+ await this.toggleReaction(messageId, emoji, false);
754
+ }
755
+ /**
756
+ * Moves `myReactions` now, sends the frame after.
757
+ *
758
+ * 누른 값은 (사용자, 이모지)로 로컬에서 정해지고 엔진은 멱등이므로 ack를
759
+ * 기다릴 이유가 없다 — 기다리면 누른 느낌이 왕복 시간만큼 늦는다. 소비자가
760
+ * 자기 낙관 계층을 덧대면 이 필드의 주인이 둘이 되므로, SDK가 한다.
761
+ * **발행은 다르다**: `before_publish` 훅이 본문을 바꿀 수 있어 미리 그릴 값이
762
+ * 로컬에 없다. 그래서 `send()`는 낙관적이지 않다.
763
+ *
764
+ * 개수는 옮기지 않는다. ack가 오기 전에 남이 같은 이모지를 누르면 로컬
765
+ * +1/-1은 서버가 보낸 수와 다른 값이 되고, 그것을 고칠 이벤트는 이미
766
+ * 지나갔다. 또 ack와 방 프레임은 다른 길로 와 순서가 없으므로, 늦게 온 내
767
+ * ack(count 1)가 앞서 온 남의 이벤트(count 2)를 되돌린다.
768
+ *
769
+ * 거절·타임아웃·끊김이면 되돌리지만, 그 사이 더 새로운 값이 자리를 차지했다면
770
+ * 두고 나온다(`Timeline.settleReaction`).
771
+ */
772
+ async toggleReaction(messageId, emoji, mine) {
773
+ const press = this.timeline.pressReaction(messageId, emoji, mine);
774
+ try {
775
+ await this.chat.send("react", {
776
+ roomId: await this.resolveId("reacting"),
777
+ messageId,
778
+ emoji,
779
+ op: mine ? "add" : "remove"
780
+ });
781
+ } catch (err) {
782
+ if (press !== void 0) this.timeline.settleReaction(press, false);
783
+ throw err;
784
+ }
785
+ if (press !== void 0) this.timeline.settleReaction(press, true);
786
+ else this.timeline.reaction(messageId, emoji, void 0, mine);
673
787
  }
674
788
  /**
675
789
  * Moves this user's read cursor.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kispi/chat",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
4
4
  "description": "Client SDK for the chat server: one WebSocket, many rooms, ordered history.",
5
5
  "license": "MIT",
6
6
  "type": "module",