@convokitapp/vue-ui 0.4.1 → 0.5.0

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/CHANGELOG.md CHANGED
@@ -1,5 +1,38 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.0
4
+
5
+ - Precise read positions. Receipts resolve per user from a monotonic read-through
6
+ position `(createdAt, id)` when the server provides one, and fall back to the
7
+ acknowledgement time only for legacy participants without a position. A read
8
+ request that arrives late no longer marks messages that were rendered after it
9
+ as read. The state exposes `readPositionByUserId` beside `readAtByUserId`;
10
+ `readerIdsFor`, `defaultReadersResolver`, `MessageListView` and
11
+ `ConversationView` accept both (`readAtByUserId` alone still works).
12
+ - Targeted acknowledgements. Automatic and explicit reads name the newest
13
+ rendered, non-pending message (`throughMessageId`); nothing is sent while
14
+ nothing is rendered. A withheld media-only row is acknowledged once its
15
+ hydration renders it. One request is in flight at a time, a follow-up is
16
+ resolved at send time, and targets at or before the accepted one are skipped.
17
+ A target the server does not know (`MESSAGE_NOT_FOUND`) is skipped and the
18
+ next newest row is acknowledged once; membership failures still surface as
19
+ errors.
20
+ - Visibility gating. Automatic acknowledgements wait while the document is
21
+ hidden and resume when it becomes visible; the SDK-backed `Conversation`
22
+ wires `document.visibilityState` and `visibilitychange`, and controllers gain
23
+ `setVisible(visible)`. Unknown, prerender and server environments count as
24
+ visible. `markReadOnLoad` / `markReadOnReceive` keep their meaning; with both
25
+ off no request is ever sent, including on visibility changes.
26
+ - 0.5.0 adapter change: `ConvoKitUiClient.markConversationRead(conversationId,
27
+ options?)` receives `{ throughMessageId }`. Custom adapters must forward it
28
+ and report an unknown target with `code: 'MESSAGE_NOT_FOUND'` (or a 404
29
+ without a code). One-argument implementations still type-check but would
30
+ acknowledge the server's newest message instead of the rendered one.
31
+ - Requires core SDK 0.5.x (`Participant.readPosition`, `ReadEvent.readPosition`,
32
+ `markConversationRead` target) and the coordinated backend. Mixed fleet:
33
+ precise receipts need both the sender's and the reader's clients on 0.5;
34
+ 0.4 readers keep timestamp semantics and keep parsing the additive payload.
35
+
3
36
  ## 0.4.1
4
37
 
5
38
  - Describe a confirmed outgoing message without readers as `Sent` instead of
package/PARITY.md CHANGED
@@ -18,6 +18,9 @@ while using Vue-native composition patterns.
18
18
  | Realtime messages, typing, reads | Yes | Yes | Yes |
19
19
  | Pending state without provisional timestamps or receipts | Yes | Yes | Yes |
20
20
  | Read markers and reader resolution | Yes | Yes | Yes |
21
+ | Read positions (`readPositionByUserId`, `lastReadAt` fallback) | Yes | Yes | Yes |
22
+ | Targeted acknowledgements (`throughMessageId`, newest rendered row) | Yes | Yes | Yes |
23
+ | Visibility-gated auto-read | `WidgetsBindingObserver` | `visibilitychange` | `visibilitychange` |
21
24
  | Mark read on load/receive | Yes | Yes | Yes |
22
25
  | Typing idle timeout | Yes | Yes | Yes |
23
26
  | Image, file, location, contact rendering | Yes | Yes | Yes |
@@ -32,10 +35,14 @@ Vue also exposes controlled/uncontrolled composer text through `v-model`,
32
35
  per-part `classNames` and `styles`, `unstyled` mode, native root attributes,
33
36
  image loading policy, time formatting, scroll behavior controls, callback props,
34
37
  and exposed composable controllers.
35
- ## Unreleased recovery parity
38
+ ## Read-position parity (0.5.0)
36
39
 
37
- Room and inbox session eviction, value-cursor history, full viewed-range reconnect
38
- reconciliation, ID-only deletions and server-sourced read positions are tested in
39
- this feature branch. They require the unpublished core SDK recovery contract.
40
- The parity script checks API presence only; the controller, rendered-view and
41
- actual-SDK protocol tests verify behavior. See README 0.3.0 behavior and integration notes.
40
+ Every UI applies the same receipt rule per user: a read position wins when
41
+ present (`covers` by `(createdAt, id)`), otherwise `lastReadAt` is compared
42
+ with `createdAt`. Acknowledgements target the newest rendered non-pending
43
+ message, coalesce to one request in flight, skip targets at or before the last
44
+ accepted one, re-issue once on a `MESSAGE_NOT_FOUND` miss, and wait while the
45
+ platform reports the view as hidden. The adapter interface gained the optional
46
+ target in all libraries (`markConversationRead(conversationId, options?)` here).
47
+ The parity script checks API presence only; the store, rendered-view and
48
+ actual-SDK protocol tests verify behavior.
package/README.md CHANGED
@@ -11,6 +11,48 @@ React package: system typography, separated rows, subtle borders, compact
11
11
  actions, and restrained radii. It remains framework-CSS independent and does
12
12
  not require Tailwind in the host application.
13
13
 
14
+ ## 0.5.0 precise read positions
15
+
16
+ This release requires JavaScript SDK 0.5.x and the coordinated backend.
17
+
18
+ - Read receipts resolve per user from a monotonic read-through position, the
19
+ `(createdAt, id)` of a concrete message the server resolved, and fall back to
20
+ the acknowledgement time only for legacy participants without a position. A
21
+ read request that arrives late no longer marks messages rendered after it as
22
+ read. `useConversation` and `Conversation` expose `readPositionByUserId`
23
+ beside `readAtByUserId`; both are seeded from participants and advanced by
24
+ server read events only, never from the device clock.
25
+ - Acknowledgements name the newest rendered, non-pending message
26
+ (`throughMessageId`). Nothing is sent while nothing is rendered, and a
27
+ media-only row is acknowledged only once its hydration renders it. One
28
+ request is in flight at a time; a follow-up is resolved at send time and
29
+ skipped when the newest row is already acknowledged. A target the server
30
+ does not know (`MESSAGE_NOT_FOUND`, for example deleted meanwhile) is
31
+ skipped and the next newest row is acknowledged once. Membership failures
32
+ still surface as errors.
33
+ - Automatic acknowledgements wait while the document is hidden and resume when
34
+ it becomes visible. The SDK-backed `Conversation` follows
35
+ `document.visibilityState` and `visibilitychange`; controllers gain
36
+ `setVisible(visible)` for host-driven surfaces (tabs, drawers, background
37
+ routes). Unknown, prerender and server environments count as visible.
38
+ `markReadOnLoad` and `markReadOnReceive` keep their meaning; with both off,
39
+ no request is ever sent, including on visibility changes. `markRead()` is
40
+ the host's decision and is not gated.
41
+ - Controlled views accept `readPositionByUserId` next to `readAtByUserId`;
42
+ `readerIdsFor(message, readAtByUserId, readPositionByUserId?)` and
43
+ `defaultReadersResolver` apply the unified rule. Custom `#message` and
44
+ `#read-receipt` slots and `readersResolver` receive the same reader IDs.
45
+ - Adapter change: `ConvoKitUiClient.markConversationRead(conversationId,
46
+ options?)` receives `{ throughMessageId }`. Custom adapters must forward it
47
+ to the core SDK (or their backend) and report an unknown target with
48
+ `code: 'MESSAGE_NOT_FOUND'` (or a 404 without a code). One-argument
49
+ implementations still type-check but acknowledge the server's newest message
50
+ instead of the rendered one.
51
+ - Mixed fleet: precise receipts need both the sender's and the reader's
52
+ clients on 0.5. 0.4 readers keep timestamp semantics and keep parsing the
53
+ additive payload; the dashboard shows both the read-through and the last
54
+ acknowledgement time.
55
+
14
56
  ## 0.4.0 live inbox, sending and recovery
15
57
 
16
58
  This release requires JavaScript SDK 0.4.x and the coordinated backend. The core
@@ -45,6 +87,7 @@ membership changes; no additional customer configuration is needed.
45
87
  - Read receipts use persisted participant positions and server read events,
46
88
  monotonically. A successful mark-read request does not fabricate a timestamp
47
89
  from the device clock. Remote typing expires and clears on disconnect.
90
+ (0.5.0 replaces the timestamp comparison with read positions; see above.)
48
91
  - Pending sends are bound to their original room/session. Reconciliation uses
49
92
  the exact clientMessageId, sender and room, never identical text or file count.
50
93
  A matching live/history row replaces its pending bubble immediately, even
@@ -148,6 +191,9 @@ Available slots include `conversation-item`, `separator`, `header`, `message`,
148
191
  Use `useConversationList` and `useConversation` when you want ConvoKit's
149
192
  pagination, de-duplication, realtime, typing, and read state without the
150
193
  default UI. Both return readonly Vue refs plus actions and a `dispose()` method.
194
+ `useConversation` also exposes `readPositionByUserId`, `readerIdsFor(message)`,
195
+ `markRead()` and `setVisible(visible)`; call `setVisible(false)` while your own
196
+ surface hides the room so automatic acknowledgements wait until it is shown.
151
197
 
152
198
  Optimistic rows display `Sending…` until acknowledgement. The final server
153
199
  timestamp is formatted in the viewer's local timezone. Custom message slots can
package/dist/index.cjs CHANGED
@@ -66,7 +66,7 @@ function createConvoKitUiClient(client) {
66
66
  getMessages: (options) => client.getMessages(options),
67
67
  getMessage: (id) => client.getMessage(id),
68
68
  sendMessage: (input) => client.sendMessage(input),
69
- markConversationRead: (conversationId) => client.markConversationRead(conversationId),
69
+ markConversationRead: (conversationId, options) => client.markConversationRead(conversationId, options),
70
70
  sendTyping: (input) => client.sendTyping(input),
71
71
  onMessage: (conversationId, handler, onError) => client.realtime.onMessage(conversationId, {
72
72
  onEvent: handler,
@@ -88,9 +88,13 @@ var import_reka_ui = require("reka-ui");
88
88
  var import_vue2 = require("vue");
89
89
 
90
90
  // src/utils.ts
91
+ var import_sdk = require("@convokitapp/sdk");
91
92
  var import_clsx = require("clsx");
92
93
  var import_vue = require("vue");
93
94
  var pendingMessageIdPrefix = "convokit-pending-";
95
+ function compareMessageOrder(left, right) {
96
+ return left.createdAt.getTime() - right.createdAt.getTime() || (left.id < right.id ? -1 : left.id > right.id ? 1 : 0);
97
+ }
94
98
  function isConvoKitPendingMessage(message) {
95
99
  return message.id.startsWith(pendingMessageIdPrefix);
96
100
  }
@@ -139,13 +143,16 @@ function mergeMessages(current, incoming) {
139
143
  const leftPending = isConvoKitPendingMessage(left);
140
144
  const rightPending = isConvoKitPendingMessage(right);
141
145
  if (leftPending !== rightPending) return leftPending ? 1 : -1;
142
- const byTime = left.createdAt.getTime() - right.createdAt.getTime();
143
- return byTime === 0 ? left.id.localeCompare(right.id) : byTime;
146
+ return compareMessageOrder(left, right);
144
147
  });
145
148
  }
146
- function readerIdsFor(message, readAtByUserId) {
149
+ function readerIdsFor(message, readAtByUserId, readPositionByUserId = /* @__PURE__ */ new Map()) {
147
150
  if (isConvoKitPendingMessage(message)) return /* @__PURE__ */ new Set();
148
- return new Set([...readAtByUserId.entries()].filter(([userId, readAt]) => userId !== message.senderId && readAt.getTime() >= message.createdAt.getTime()).map(([userId]) => userId));
151
+ const userIds = /* @__PURE__ */ new Set([...readAtByUserId.keys(), ...readPositionByUserId.keys()]);
152
+ return new Set([...userIds].filter((userId) => userId !== message.senderId && (0, import_sdk.readThrough)({
153
+ readPosition: readPositionByUserId.get(userId) ?? null,
154
+ lastReadAt: readAtByUserId.get(userId) ?? null
155
+ }, message)));
149
156
  }
150
157
  function partClass(part, appearance, defaultClass) {
151
158
  return cx(!appearance.unstyled && defaultClass, appearance.classNames?.[part]);
@@ -199,7 +206,7 @@ var import_vue7 = require("vue");
199
206
  var import_vue3 = require("vue");
200
207
 
201
208
  // src/conversation-store.ts
202
- var import_sdk = require("@convokitapp/sdk");
209
+ var import_sdk2 = require("@convokitapp/sdk");
203
210
  function version(message) {
204
211
  return message.updatedAt?.getTime() ?? message.createdAt.getTime();
205
212
  }
@@ -209,8 +216,20 @@ function hasContent(message) {
209
216
  function newest(current, incoming, incomingComplete = true) {
210
217
  return version(current) > version(incoming) || !incomingComplete && version(current) === version(incoming) ? current : incoming;
211
218
  }
212
- function compare(left, right) {
213
- return left.createdAt.getTime() - right.createdAt.getTime() || (left.id < right.id ? -1 : left.id > right.id ? 1 : 0);
219
+ var compare = compareMessageOrder;
220
+ function positionCursor(position) {
221
+ return { createdAt: position.createdAt, id: position.messageId };
222
+ }
223
+ function readEntry(participant) {
224
+ return { userId: participant.appUserId, readAt: participant.lastReadAt, readPosition: participant.readPosition };
225
+ }
226
+ function acknowledgement() {
227
+ return { inFlight: void 0, followUp: false, suppressed: false, target: void 0, acknowledged: void 0, unacknowledgeable: /* @__PURE__ */ new Set() };
228
+ }
229
+ function isTargetMiss(cause) {
230
+ if (typeof cause !== "object" || cause === null) return false;
231
+ const { code, status } = cause;
232
+ return code === "MESSAGE_NOT_FOUND" || code === void 0 && status === 404;
214
233
  }
215
234
  function blank(currentUserId = "") {
216
235
  return {
@@ -218,6 +237,7 @@ function blank(currentUserId = "") {
218
237
  messages: [],
219
238
  typingUserIds: /* @__PURE__ */ new Set(),
220
239
  readAtByUserId: /* @__PURE__ */ new Map(),
240
+ readPositionByUserId: /* @__PURE__ */ new Map(),
221
241
  isInitialLoading: false,
222
242
  isLoadingOlder: false,
223
243
  isReconciling: false,
@@ -265,6 +285,9 @@ var ConversationStore = class {
265
285
  hydrationPool = { running: /* @__PURE__ */ new Set(), queued: /* @__PURE__ */ new Map() };
266
286
  // Keep tombstones until an explicit reload/session change, including across refreshes.
267
287
  deleted = /* @__PURE__ */ new Set();
288
+ ack = acknowledgement();
289
+ // Visible until the platform reports otherwise; unknown/prerender/no document count as visible.
290
+ visible = true;
268
291
  sendRevision;
269
292
  activeSend;
270
293
  refreshQueued = false;
@@ -327,6 +350,7 @@ var ConversationStore = class {
327
350
  this.hydrations.clear();
328
351
  this.hydrationPool.queued.clear();
329
352
  this.deleted.clear();
353
+ this.ack = acknowledgement();
330
354
  this.sendRevision = void 0;
331
355
  this.activeSend = void 0;
332
356
  this.refreshQueued = false;
@@ -382,8 +406,8 @@ var ConversationStore = class {
382
406
  if (!this.alive(generation) || conversationId !== this.room || !id.trim()) return;
383
407
  this.removeMessage(id);
384
408
  }, report));
385
- add(() => this.client.onReadReceipt(this.room, ({ userId, readAt }) => {
386
- if (this.alive(generation)) this.mergeReads([[userId, readAt]]);
409
+ add(() => this.client.onReadReceipt(this.room, ({ userId, readAt, readPosition }) => {
410
+ if (this.alive(generation)) this.mergeReads([{ userId, readAt, readPosition }]);
387
411
  }, report));
388
412
  add(() => this.client.onTyping(this.room, ({ userId, isTyping }) => {
389
413
  if (!this.alive(generation) || !userId.trim() || userId === this.user) return;
@@ -426,9 +450,6 @@ var ConversationStore = class {
426
450
  this.hydrations.set(message.id, job);
427
451
  this.hydrationPool.queued.set(message.id, job);
428
452
  this.drainHydration();
429
- if (type === "insert" && !existing && message.senderId !== this.user && (this.options.markReadOnReceive ?? true)) {
430
- void this.markRead();
431
- }
432
453
  }
433
454
  record(message, insert, revision, complete) {
434
455
  const existing = this.state.messages.find((item) => item.id === message.id);
@@ -438,7 +459,9 @@ var ConversationStore = class {
438
459
  this.confirmSend(message);
439
460
  }
440
461
  this.changes.set(message.id, { revision, message, insert, complete });
441
- if (existing || insert && hasContent(message)) this.patch({ messages: mergeMessages(this.state.messages, [message]) });
462
+ if (!existing && !(insert && hasContent(message))) return;
463
+ this.patch({ messages: mergeMessages(this.state.messages, [message]) });
464
+ if (!existing && message.senderId !== this.user && (this.options.markReadOnReceive ?? true)) void this.acknowledge(true);
442
465
  }
443
466
  confirmSend(message) {
444
467
  const send = this.activeSend;
@@ -450,11 +473,19 @@ var ConversationStore = class {
450
473
  return this.alive(job.generation) && !this.deleted.has(job.message.id) && this.hydrations.get(job.message.id) === job;
451
474
  }
452
475
  removeMessage(id) {
476
+ this.forget(id);
477
+ this.patch({ messages: this.state.messages.filter((message) => message.id !== id) });
478
+ }
479
+ /** Tombstone a row learned to be gone; a removed acknowledgement target is re-resolved from what remains. */
480
+ forget(id) {
453
481
  this.deleted.add(id);
454
482
  this.changes.delete(id);
455
483
  this.hydrations.delete(id);
456
484
  this.hydrationPool.queued.delete(id);
457
- this.patch({ messages: this.state.messages.filter((message) => message.id !== id) });
485
+ const ack = this.ack;
486
+ if (ack.target !== id && ack.acknowledged?.id !== id) return;
487
+ ack.unacknowledgeable.add(id);
488
+ if (ack.target === id) ack.followUp = true;
458
489
  }
459
490
  drainHydration() {
460
491
  const pool = this.hydrationPool;
@@ -491,13 +522,23 @@ var ConversationStore = class {
491
522
  });
492
523
  }
493
524
  }
525
+ /** Both maps only ever advance: acknowledgement times by time, positions by (createdAt, id). Participants and
526
+ * read events are the only sources; the local user's own read is never written from the device clock.
527
+ */
494
528
  mergeReads(entries) {
495
- const next = new Map(this.state.readAtByUserId);
496
- for (const [userId, readAt] of entries) {
497
- if (!userId.trim() || !Number.isFinite(readAt.getTime())) continue;
498
- if (readAt.getTime() > (next.get(userId)?.getTime() ?? -Infinity)) next.set(userId, readAt);
529
+ const readAt = new Map(this.state.readAtByUserId);
530
+ const positions = new Map(this.state.readPositionByUserId);
531
+ for (const entry of entries) {
532
+ const { userId, readPosition } = entry;
533
+ if (!userId.trim()) continue;
534
+ if (entry.readAt && Number.isFinite(entry.readAt.getTime()) && entry.readAt.getTime() > (readAt.get(userId)?.getTime() ?? -Infinity)) {
535
+ readAt.set(userId, entry.readAt);
536
+ }
537
+ if (!readPosition || typeof readPosition.messageId !== "string" || !readPosition.messageId.trim() || !(readPosition.createdAt instanceof Date) || !Number.isFinite(readPosition.createdAt.getTime())) continue;
538
+ const current = positions.get(userId);
539
+ if (!current || compare(positionCursor(readPosition), positionCursor(current)) > 0) positions.set(userId, readPosition);
499
540
  }
500
- this.patch({ readAtByUserId: next });
541
+ this.patch({ readAtByUserId: readAt, readPositionByUserId: positions });
501
542
  }
502
543
  validatePage(page, before) {
503
544
  if (page.length > this.pageSize) throw new Error("Message page exceeds the requested limit");
@@ -554,9 +595,9 @@ var ConversationStore = class {
554
595
  this.validatePage(page);
555
596
  this.cursor = page.at(-1);
556
597
  this.patch({ conversation, messages: this.overlay(page, revision), hasOlderMessages: page.length === this.pageSize });
557
- this.mergeReads(conversation.participants.flatMap((participant) => participant.lastReadAt ? [[participant.appUserId, participant.lastReadAt]] : []));
598
+ this.mergeReads(conversation.participants.map(readEntry));
558
599
  this.prune(revision);
559
- if (this.options.markReadOnLoad ?? true) await this.markRead();
600
+ if (this.options.markReadOnLoad ?? true) await this.acknowledge(true);
560
601
  } catch (cause) {
561
602
  this.fail(cause, generation, true);
562
603
  } finally {
@@ -612,14 +653,9 @@ var ConversationStore = class {
612
653
  const reconciled = this.overlay(rows, revision);
613
654
  const survivingIds = new Set(reconciled.map((message) => message.id));
614
655
  const known = this.state.messages.filter((message) => !isConvoKitPendingMessage(message)).map((message) => message.id).concat([...this.changes].filter(([, change]) => change.insert && change.revision <= revision).map(([id]) => id));
615
- for (const id of known) if (!survivingIds.has(id)) {
616
- this.deleted.add(id);
617
- this.changes.delete(id);
618
- this.hydrations.delete(id);
619
- this.hydrationPool.queued.delete(id);
620
- }
656
+ for (const id of known) if (!survivingIds.has(id)) this.forget(id);
621
657
  this.patch({ conversation, messages: reconciled, hasOlderMessages: hasOlder });
622
- this.mergeReads(conversation.participants.flatMap((participant) => participant.lastReadAt ? [[participant.appUserId, participant.lastReadAt]] : []));
658
+ this.mergeReads(conversation.participants.map(readEntry));
623
659
  this.prune(revision);
624
660
  } catch (cause) {
625
661
  this.fail(cause, generation, true);
@@ -654,15 +690,68 @@ var ConversationStore = class {
654
690
  }
655
691
  }
656
692
  };
657
- markRead = async () => {
658
- if (!this.alive()) return;
659
- const generation = this.generation;
693
+ /** Acknowledge through the newest rendered row now, regardless of visibility; no request without a target. */
694
+ markRead = () => this.alive() ? this.acknowledge(false) : Promise.resolve();
695
+ /** Automatic acknowledgements wait while hidden and are re-issued (once) on becoming visible. */
696
+ setVisible = (visible) => {
697
+ this.visible = visible;
698
+ if (!visible || !this.ack.suppressed || !this.alive()) return;
699
+ this.ack.suppressed = false;
700
+ void this.acknowledge(true);
701
+ };
702
+ /** The newest non-pending rendered row by (createdAt, id), never by list index and never a raw realtime
703
+ * row; rows the server does not know are skipped, and nothing at or before the accepted target is re-sent.
704
+ */
705
+ ackTarget(ack) {
706
+ let target;
707
+ for (const message of this.state.messages) {
708
+ if (isConvoKitPendingMessage(message) || ack.unacknowledgeable.has(message.id)) continue;
709
+ if (!target || compare(message, target) > 0) target = { createdAt: message.createdAt, id: message.id };
710
+ }
711
+ return target && (!ack.acknowledged || compare(target, ack.acknowledged) > 0) ? target : void 0;
712
+ }
713
+ /** Resolves when the request this call issued or joined settles; a follow-up is issued, not awaited. */
714
+ acknowledge(automatic) {
715
+ const ack = this.ack;
716
+ if (automatic && !this.visible) {
717
+ ack.suppressed = true;
718
+ return Promise.resolve();
719
+ }
720
+ if (ack.inFlight) {
721
+ ack.followUp = true;
722
+ return ack.inFlight;
723
+ }
724
+ return this.issue(ack, this.generation) ?? Promise.resolve();
725
+ }
726
+ issue(ack, generation) {
727
+ ack.followUp = false;
728
+ const target = this.ackTarget(ack);
729
+ if (!target) return void 0;
730
+ ack.target = target.id;
731
+ ack.inFlight = this.send(ack, generation, target).finally(() => {
732
+ ack.inFlight = void 0;
733
+ ack.target = void 0;
734
+ if (!this.alive(generation) || !ack.followUp) return;
735
+ if (!this.visible) {
736
+ ack.followUp = false;
737
+ ack.suppressed = true;
738
+ } else this.issue(ack, generation);
739
+ });
740
+ return ack.inFlight;
741
+ }
742
+ async send(ack, generation, target) {
660
743
  try {
661
- await this.client.markConversationRead(this.room);
744
+ await this.client.markConversationRead(this.room, { throughMessageId: target.id });
745
+ if (!this.alive(generation)) return;
746
+ if (!ack.acknowledged || compare(target, ack.acknowledged) > 0) ack.acknowledged = target;
662
747
  } catch (cause) {
663
- this.fail(cause, generation);
748
+ if (!this.alive(generation)) return;
749
+ if (isTargetMiss(cause)) {
750
+ ack.unacknowledgeable.add(target.id);
751
+ ack.followUp = true;
752
+ } else this.fail(cause, generation);
664
753
  }
665
- };
754
+ }
666
755
  updateTyping = async (isTyping) => {
667
756
  if (!this.alive()) return;
668
757
  const generation = this.generation;
@@ -691,7 +780,7 @@ var ConversationStore = class {
691
780
  const generation = this.generation;
692
781
  const revision = this.revision;
693
782
  this.sendRevision = revision;
694
- const clientMessageId = (0, import_sdk.createClientMessageId)();
783
+ const clientMessageId = (0, import_sdk2.createClientMessageId)();
695
784
  const pendingId = `convokit-pending-${clientMessageId}`;
696
785
  const pending = {
697
786
  id: pendingId,
@@ -745,7 +834,7 @@ var ConversationStore = class {
745
834
  }
746
835
  }
747
836
  };
748
- readerIdsFor = (message) => readerIdsFor(message, this.state.readAtByUserId);
837
+ readerIdsFor = (message) => readerIdsFor(message, this.state.readAtByUserId, this.state.readPositionByUserId);
749
838
  };
750
839
 
751
840
  // src/composables/use-conversation.ts
@@ -758,6 +847,7 @@ function useConversation(options) {
758
847
  let store = createStore();
759
848
  const snapshot = (0, import_vue3.shallowRef)(store.getSnapshot());
760
849
  let unsubscribe;
850
+ let visible = true;
761
851
  const stop = (0, import_vue3.watch)(
762
852
  () => [(0, import_vue3.toValue)(options.client), (0, import_vue3.toValue)(options.client).sessionIdentity, (0, import_vue3.toValue)(options.conversationId)],
763
853
  () => {
@@ -768,6 +858,7 @@ function useConversation(options) {
768
858
  unsubscribe = store.subscribe(() => {
769
859
  snapshot.value = store.getSnapshot();
770
860
  });
861
+ store.setVisible(visible);
771
862
  store.start(options.autoLoad ?? true);
772
863
  },
773
864
  { immediate: true, flush: "sync" }
@@ -787,6 +878,7 @@ function useConversation(options) {
787
878
  messages: field("messages"),
788
879
  typingUserIds: field("typingUserIds"),
789
880
  readAtByUserId: field("readAtByUserId"),
881
+ readPositionByUserId: field("readPositionByUserId"),
790
882
  isInitialLoading: field("isInitialLoading"),
791
883
  isLoadingOlder: field("isLoadingOlder"),
792
884
  isReconciling: field("isReconciling"),
@@ -802,6 +894,10 @@ function useConversation(options) {
802
894
  sendMessage: (input) => store.sendMessage(input),
803
895
  markRead: () => store.markRead(),
804
896
  updateTyping: (isTyping) => store.updateTyping(isTyping),
897
+ setVisible: (value) => {
898
+ visible = value;
899
+ store.setVisible(value);
900
+ },
805
901
  dispose
806
902
  };
807
903
  }
@@ -857,6 +953,7 @@ var MessageListView = (0, import_vue5.defineComponent)({
857
953
  messages: { type: Array, required: true },
858
954
  currentUserId: { type: String, required: true },
859
955
  readAtByUserId: { type: Object, default: () => /* @__PURE__ */ new Map() },
956
+ readPositionByUserId: { type: Object, default: () => /* @__PURE__ */ new Map() },
860
957
  readersResolver: { type: Function, default: void 0 },
861
958
  onLoadOlder: { type: Function, default: void 0 },
862
959
  hasOlderMessages: { type: Boolean, default: false },
@@ -916,7 +1013,7 @@ var MessageListView = (0, import_vue5.defineComponent)({
916
1013
  const isCurrentUser = message.senderId === props.currentUserId;
917
1014
  const sender = participants.value.get(message.senderId);
918
1015
  const isPending = isConvoKitPendingMessage(message);
919
- const readerIds = isPending ? /* @__PURE__ */ new Set() : props.readersResolver ? props.readersResolver(message) : readerIdsFor(message, props.readAtByUserId);
1016
+ const readerIds = isPending ? /* @__PURE__ */ new Set() : props.readersResolver ? props.readersResolver(message) : readerIdsFor(message, props.readAtByUserId, props.readPositionByUserId);
920
1017
  const slotProps = { message, chronologicalIndex: index, isCurrentUser, sender, readerIds };
921
1018
  const custom = slots.message?.(slotProps);
922
1019
  if (custom) return (0, import_vue5.h)("div", { key: message.id, role: "listitem" }, custom);
@@ -1015,8 +1112,8 @@ var MessageListView = (0, import_vue5.defineComponent)({
1015
1112
  };
1016
1113
  }
1017
1114
  });
1018
- function defaultReadersResolver(readAtByUserId) {
1019
- return (message) => readerIdsFor(message, readAtByUserId);
1115
+ function defaultReadersResolver(readAtByUserId, readPositionByUserId = /* @__PURE__ */ new Map()) {
1116
+ return (message) => readerIdsFor(message, readAtByUserId, readPositionByUserId);
1020
1117
  }
1021
1118
 
1022
1119
  // src/components/conversation.ts
@@ -1034,6 +1131,7 @@ var viewProps = {
1034
1131
  onSendMessage: { type: Function, required: true },
1035
1132
  typingUserIds: { type: Object, default: () => /* @__PURE__ */ new Set() },
1036
1133
  readAtByUserId: { type: Object, default: () => /* @__PURE__ */ new Map() },
1134
+ readPositionByUserId: { type: Object, default: () => /* @__PURE__ */ new Map() },
1037
1135
  readersResolver: { type: Function, default: void 0 },
1038
1136
  onBack: { type: Function, default: void 0 },
1039
1137
  onRefresh: { type: Function, default: void 0 },
@@ -1262,6 +1360,7 @@ var ConversationView = (0, import_vue7.defineComponent)({
1262
1360
  messages: props.messages,
1263
1361
  currentUserId: props.currentUserId,
1264
1362
  readAtByUserId: props.readAtByUserId,
1363
+ readPositionByUserId: props.readPositionByUserId,
1265
1364
  ...props.readersResolver ? { readersResolver: props.readersResolver } : {},
1266
1365
  ...props.onLoadOlder ? { onLoadOlder: loadOlder } : {},
1267
1366
  hasOlderMessages: props.hasOlderMessages,
@@ -1324,6 +1423,12 @@ var Conversation = (0, import_vue7.defineComponent)({
1324
1423
  (0, import_vue7.watchEffect)(() => {
1325
1424
  emit("controller-change", controller);
1326
1425
  });
1426
+ if (typeof document !== "undefined") {
1427
+ const syncVisibility = () => controller.setVisible(document.visibilityState !== "hidden");
1428
+ syncVisibility();
1429
+ document.addEventListener("visibilitychange", syncVisibility);
1430
+ (0, import_vue7.onBeforeUnmount)(() => document.removeEventListener("visibilitychange", syncVisibility));
1431
+ }
1327
1432
  return () => {
1328
1433
  const loadedConversation = controller.conversation.value;
1329
1434
  if (!loadedConversation) {
@@ -1358,6 +1463,7 @@ var Conversation = (0, import_vue7.defineComponent)({
1358
1463
  onSendMessage: _onSendMessage,
1359
1464
  typingUserIds: _typingUserIds,
1360
1465
  readAtByUserId: _readAtByUserId,
1466
+ readPositionByUserId: _readPositionByUserId,
1361
1467
  onRefresh: _onRefresh,
1362
1468
  onLoadOlder: _onLoadOlder,
1363
1469
  onTypingChange: _onTypingChange,
@@ -1380,6 +1486,7 @@ var Conversation = (0, import_vue7.defineComponent)({
1380
1486
  },
1381
1487
  typingUserIds: controller.typingUserIds.value,
1382
1488
  readAtByUserId: controller.readAtByUserId.value,
1489
+ readPositionByUserId: controller.readPositionByUserId.value,
1383
1490
  onRefresh: controller.refresh,
1384
1491
  onLoadOlder: controller.loadOlderMessages,
1385
1492
  onTypingChange: controller.updateTyping,