@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/dist/index.js CHANGED
@@ -21,7 +21,7 @@ function createConvoKitUiClient(client) {
21
21
  getMessages: (options) => client.getMessages(options),
22
22
  getMessage: (id) => client.getMessage(id),
23
23
  sendMessage: (input) => client.sendMessage(input),
24
- markConversationRead: (conversationId) => client.markConversationRead(conversationId),
24
+ markConversationRead: (conversationId, options) => client.markConversationRead(conversationId, options),
25
25
  sendTyping: (input) => client.sendTyping(input),
26
26
  onMessage: (conversationId, handler, onError) => client.realtime.onMessage(conversationId, {
27
27
  onEvent: handler,
@@ -43,9 +43,13 @@ import { AvatarFallback, AvatarImage, AvatarRoot } from "reka-ui";
43
43
  import { defineComponent, h } from "vue";
44
44
 
45
45
  // src/utils.ts
46
+ import { readThrough } from "@convokitapp/sdk";
46
47
  import { clsx } from "clsx";
47
48
  import { normalizeClass } from "vue";
48
49
  var pendingMessageIdPrefix = "convokit-pending-";
50
+ function compareMessageOrder(left, right) {
51
+ return left.createdAt.getTime() - right.createdAt.getTime() || (left.id < right.id ? -1 : left.id > right.id ? 1 : 0);
52
+ }
49
53
  function isConvoKitPendingMessage(message) {
50
54
  return message.id.startsWith(pendingMessageIdPrefix);
51
55
  }
@@ -94,13 +98,16 @@ function mergeMessages(current, incoming) {
94
98
  const leftPending = isConvoKitPendingMessage(left);
95
99
  const rightPending = isConvoKitPendingMessage(right);
96
100
  if (leftPending !== rightPending) return leftPending ? 1 : -1;
97
- const byTime = left.createdAt.getTime() - right.createdAt.getTime();
98
- return byTime === 0 ? left.id.localeCompare(right.id) : byTime;
101
+ return compareMessageOrder(left, right);
99
102
  });
100
103
  }
101
- function readerIdsFor(message, readAtByUserId) {
104
+ function readerIdsFor(message, readAtByUserId, readPositionByUserId = /* @__PURE__ */ new Map()) {
102
105
  if (isConvoKitPendingMessage(message)) return /* @__PURE__ */ new Set();
103
- return new Set([...readAtByUserId.entries()].filter(([userId, readAt]) => userId !== message.senderId && readAt.getTime() >= message.createdAt.getTime()).map(([userId]) => userId));
106
+ const userIds = /* @__PURE__ */ new Set([...readAtByUserId.keys(), ...readPositionByUserId.keys()]);
107
+ return new Set([...userIds].filter((userId) => userId !== message.senderId && readThrough({
108
+ readPosition: readPositionByUserId.get(userId) ?? null,
109
+ lastReadAt: readAtByUserId.get(userId) ?? null
110
+ }, message)));
104
111
  }
105
112
  function partClass(part, appearance, defaultClass) {
106
113
  return cx(!appearance.unstyled && defaultClass, appearance.classNames?.[part]);
@@ -151,6 +158,7 @@ import { ArrowLeft, LoaderCircle as LoaderCircle2, Paperclip, RefreshCw, Send }
151
158
  import {
152
159
  defineComponent as defineComponent3,
153
160
  h as h3,
161
+ onBeforeUnmount,
154
162
  ref as ref2,
155
163
  watchEffect
156
164
  } from "vue";
@@ -169,8 +177,20 @@ function hasContent(message) {
169
177
  function newest(current, incoming, incomingComplete = true) {
170
178
  return version(current) > version(incoming) || !incomingComplete && version(current) === version(incoming) ? current : incoming;
171
179
  }
172
- function compare(left, right) {
173
- return left.createdAt.getTime() - right.createdAt.getTime() || (left.id < right.id ? -1 : left.id > right.id ? 1 : 0);
180
+ var compare = compareMessageOrder;
181
+ function positionCursor(position) {
182
+ return { createdAt: position.createdAt, id: position.messageId };
183
+ }
184
+ function readEntry(participant) {
185
+ return { userId: participant.appUserId, readAt: participant.lastReadAt, readPosition: participant.readPosition };
186
+ }
187
+ function acknowledgement() {
188
+ return { inFlight: void 0, followUp: false, suppressed: false, target: void 0, acknowledged: void 0, unacknowledgeable: /* @__PURE__ */ new Set() };
189
+ }
190
+ function isTargetMiss(cause) {
191
+ if (typeof cause !== "object" || cause === null) return false;
192
+ const { code, status } = cause;
193
+ return code === "MESSAGE_NOT_FOUND" || code === void 0 && status === 404;
174
194
  }
175
195
  function blank(currentUserId = "") {
176
196
  return {
@@ -178,6 +198,7 @@ function blank(currentUserId = "") {
178
198
  messages: [],
179
199
  typingUserIds: /* @__PURE__ */ new Set(),
180
200
  readAtByUserId: /* @__PURE__ */ new Map(),
201
+ readPositionByUserId: /* @__PURE__ */ new Map(),
181
202
  isInitialLoading: false,
182
203
  isLoadingOlder: false,
183
204
  isReconciling: false,
@@ -225,6 +246,9 @@ var ConversationStore = class {
225
246
  hydrationPool = { running: /* @__PURE__ */ new Set(), queued: /* @__PURE__ */ new Map() };
226
247
  // Keep tombstones until an explicit reload/session change, including across refreshes.
227
248
  deleted = /* @__PURE__ */ new Set();
249
+ ack = acknowledgement();
250
+ // Visible until the platform reports otherwise; unknown/prerender/no document count as visible.
251
+ visible = true;
228
252
  sendRevision;
229
253
  activeSend;
230
254
  refreshQueued = false;
@@ -287,6 +311,7 @@ var ConversationStore = class {
287
311
  this.hydrations.clear();
288
312
  this.hydrationPool.queued.clear();
289
313
  this.deleted.clear();
314
+ this.ack = acknowledgement();
290
315
  this.sendRevision = void 0;
291
316
  this.activeSend = void 0;
292
317
  this.refreshQueued = false;
@@ -342,8 +367,8 @@ var ConversationStore = class {
342
367
  if (!this.alive(generation) || conversationId !== this.room || !id.trim()) return;
343
368
  this.removeMessage(id);
344
369
  }, report));
345
- add(() => this.client.onReadReceipt(this.room, ({ userId, readAt }) => {
346
- if (this.alive(generation)) this.mergeReads([[userId, readAt]]);
370
+ add(() => this.client.onReadReceipt(this.room, ({ userId, readAt, readPosition }) => {
371
+ if (this.alive(generation)) this.mergeReads([{ userId, readAt, readPosition }]);
347
372
  }, report));
348
373
  add(() => this.client.onTyping(this.room, ({ userId, isTyping }) => {
349
374
  if (!this.alive(generation) || !userId.trim() || userId === this.user) return;
@@ -386,9 +411,6 @@ var ConversationStore = class {
386
411
  this.hydrations.set(message.id, job);
387
412
  this.hydrationPool.queued.set(message.id, job);
388
413
  this.drainHydration();
389
- if (type === "insert" && !existing && message.senderId !== this.user && (this.options.markReadOnReceive ?? true)) {
390
- void this.markRead();
391
- }
392
414
  }
393
415
  record(message, insert, revision, complete) {
394
416
  const existing = this.state.messages.find((item) => item.id === message.id);
@@ -398,7 +420,9 @@ var ConversationStore = class {
398
420
  this.confirmSend(message);
399
421
  }
400
422
  this.changes.set(message.id, { revision, message, insert, complete });
401
- if (existing || insert && hasContent(message)) this.patch({ messages: mergeMessages(this.state.messages, [message]) });
423
+ if (!existing && !(insert && hasContent(message))) return;
424
+ this.patch({ messages: mergeMessages(this.state.messages, [message]) });
425
+ if (!existing && message.senderId !== this.user && (this.options.markReadOnReceive ?? true)) void this.acknowledge(true);
402
426
  }
403
427
  confirmSend(message) {
404
428
  const send = this.activeSend;
@@ -410,11 +434,19 @@ var ConversationStore = class {
410
434
  return this.alive(job.generation) && !this.deleted.has(job.message.id) && this.hydrations.get(job.message.id) === job;
411
435
  }
412
436
  removeMessage(id) {
437
+ this.forget(id);
438
+ this.patch({ messages: this.state.messages.filter((message) => message.id !== id) });
439
+ }
440
+ /** Tombstone a row learned to be gone; a removed acknowledgement target is re-resolved from what remains. */
441
+ forget(id) {
413
442
  this.deleted.add(id);
414
443
  this.changes.delete(id);
415
444
  this.hydrations.delete(id);
416
445
  this.hydrationPool.queued.delete(id);
417
- this.patch({ messages: this.state.messages.filter((message) => message.id !== id) });
446
+ const ack = this.ack;
447
+ if (ack.target !== id && ack.acknowledged?.id !== id) return;
448
+ ack.unacknowledgeable.add(id);
449
+ if (ack.target === id) ack.followUp = true;
418
450
  }
419
451
  drainHydration() {
420
452
  const pool = this.hydrationPool;
@@ -451,13 +483,23 @@ var ConversationStore = class {
451
483
  });
452
484
  }
453
485
  }
486
+ /** Both maps only ever advance: acknowledgement times by time, positions by (createdAt, id). Participants and
487
+ * read events are the only sources; the local user's own read is never written from the device clock.
488
+ */
454
489
  mergeReads(entries) {
455
- const next = new Map(this.state.readAtByUserId);
456
- for (const [userId, readAt] of entries) {
457
- if (!userId.trim() || !Number.isFinite(readAt.getTime())) continue;
458
- if (readAt.getTime() > (next.get(userId)?.getTime() ?? -Infinity)) next.set(userId, readAt);
490
+ const readAt = new Map(this.state.readAtByUserId);
491
+ const positions = new Map(this.state.readPositionByUserId);
492
+ for (const entry of entries) {
493
+ const { userId, readPosition } = entry;
494
+ if (!userId.trim()) continue;
495
+ if (entry.readAt && Number.isFinite(entry.readAt.getTime()) && entry.readAt.getTime() > (readAt.get(userId)?.getTime() ?? -Infinity)) {
496
+ readAt.set(userId, entry.readAt);
497
+ }
498
+ if (!readPosition || typeof readPosition.messageId !== "string" || !readPosition.messageId.trim() || !(readPosition.createdAt instanceof Date) || !Number.isFinite(readPosition.createdAt.getTime())) continue;
499
+ const current = positions.get(userId);
500
+ if (!current || compare(positionCursor(readPosition), positionCursor(current)) > 0) positions.set(userId, readPosition);
459
501
  }
460
- this.patch({ readAtByUserId: next });
502
+ this.patch({ readAtByUserId: readAt, readPositionByUserId: positions });
461
503
  }
462
504
  validatePage(page, before) {
463
505
  if (page.length > this.pageSize) throw new Error("Message page exceeds the requested limit");
@@ -514,9 +556,9 @@ var ConversationStore = class {
514
556
  this.validatePage(page);
515
557
  this.cursor = page.at(-1);
516
558
  this.patch({ conversation, messages: this.overlay(page, revision), hasOlderMessages: page.length === this.pageSize });
517
- this.mergeReads(conversation.participants.flatMap((participant) => participant.lastReadAt ? [[participant.appUserId, participant.lastReadAt]] : []));
559
+ this.mergeReads(conversation.participants.map(readEntry));
518
560
  this.prune(revision);
519
- if (this.options.markReadOnLoad ?? true) await this.markRead();
561
+ if (this.options.markReadOnLoad ?? true) await this.acknowledge(true);
520
562
  } catch (cause) {
521
563
  this.fail(cause, generation, true);
522
564
  } finally {
@@ -572,14 +614,9 @@ var ConversationStore = class {
572
614
  const reconciled = this.overlay(rows, revision);
573
615
  const survivingIds = new Set(reconciled.map((message) => message.id));
574
616
  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));
575
- for (const id of known) if (!survivingIds.has(id)) {
576
- this.deleted.add(id);
577
- this.changes.delete(id);
578
- this.hydrations.delete(id);
579
- this.hydrationPool.queued.delete(id);
580
- }
617
+ for (const id of known) if (!survivingIds.has(id)) this.forget(id);
581
618
  this.patch({ conversation, messages: reconciled, hasOlderMessages: hasOlder });
582
- this.mergeReads(conversation.participants.flatMap((participant) => participant.lastReadAt ? [[participant.appUserId, participant.lastReadAt]] : []));
619
+ this.mergeReads(conversation.participants.map(readEntry));
583
620
  this.prune(revision);
584
621
  } catch (cause) {
585
622
  this.fail(cause, generation, true);
@@ -614,15 +651,68 @@ var ConversationStore = class {
614
651
  }
615
652
  }
616
653
  };
617
- markRead = async () => {
618
- if (!this.alive()) return;
619
- const generation = this.generation;
654
+ /** Acknowledge through the newest rendered row now, regardless of visibility; no request without a target. */
655
+ markRead = () => this.alive() ? this.acknowledge(false) : Promise.resolve();
656
+ /** Automatic acknowledgements wait while hidden and are re-issued (once) on becoming visible. */
657
+ setVisible = (visible) => {
658
+ this.visible = visible;
659
+ if (!visible || !this.ack.suppressed || !this.alive()) return;
660
+ this.ack.suppressed = false;
661
+ void this.acknowledge(true);
662
+ };
663
+ /** The newest non-pending rendered row by (createdAt, id), never by list index and never a raw realtime
664
+ * row; rows the server does not know are skipped, and nothing at or before the accepted target is re-sent.
665
+ */
666
+ ackTarget(ack) {
667
+ let target;
668
+ for (const message of this.state.messages) {
669
+ if (isConvoKitPendingMessage(message) || ack.unacknowledgeable.has(message.id)) continue;
670
+ if (!target || compare(message, target) > 0) target = { createdAt: message.createdAt, id: message.id };
671
+ }
672
+ return target && (!ack.acknowledged || compare(target, ack.acknowledged) > 0) ? target : void 0;
673
+ }
674
+ /** Resolves when the request this call issued or joined settles; a follow-up is issued, not awaited. */
675
+ acknowledge(automatic) {
676
+ const ack = this.ack;
677
+ if (automatic && !this.visible) {
678
+ ack.suppressed = true;
679
+ return Promise.resolve();
680
+ }
681
+ if (ack.inFlight) {
682
+ ack.followUp = true;
683
+ return ack.inFlight;
684
+ }
685
+ return this.issue(ack, this.generation) ?? Promise.resolve();
686
+ }
687
+ issue(ack, generation) {
688
+ ack.followUp = false;
689
+ const target = this.ackTarget(ack);
690
+ if (!target) return void 0;
691
+ ack.target = target.id;
692
+ ack.inFlight = this.send(ack, generation, target).finally(() => {
693
+ ack.inFlight = void 0;
694
+ ack.target = void 0;
695
+ if (!this.alive(generation) || !ack.followUp) return;
696
+ if (!this.visible) {
697
+ ack.followUp = false;
698
+ ack.suppressed = true;
699
+ } else this.issue(ack, generation);
700
+ });
701
+ return ack.inFlight;
702
+ }
703
+ async send(ack, generation, target) {
620
704
  try {
621
- await this.client.markConversationRead(this.room);
705
+ await this.client.markConversationRead(this.room, { throughMessageId: target.id });
706
+ if (!this.alive(generation)) return;
707
+ if (!ack.acknowledged || compare(target, ack.acknowledged) > 0) ack.acknowledged = target;
622
708
  } catch (cause) {
623
- this.fail(cause, generation);
709
+ if (!this.alive(generation)) return;
710
+ if (isTargetMiss(cause)) {
711
+ ack.unacknowledgeable.add(target.id);
712
+ ack.followUp = true;
713
+ } else this.fail(cause, generation);
624
714
  }
625
- };
715
+ }
626
716
  updateTyping = async (isTyping) => {
627
717
  if (!this.alive()) return;
628
718
  const generation = this.generation;
@@ -705,7 +795,7 @@ var ConversationStore = class {
705
795
  }
706
796
  }
707
797
  };
708
- readerIdsFor = (message) => readerIdsFor(message, this.state.readAtByUserId);
798
+ readerIdsFor = (message) => readerIdsFor(message, this.state.readAtByUserId, this.state.readPositionByUserId);
709
799
  };
710
800
 
711
801
  // src/composables/use-conversation.ts
@@ -718,6 +808,7 @@ function useConversation(options) {
718
808
  let store = createStore();
719
809
  const snapshot = shallowRef(store.getSnapshot());
720
810
  let unsubscribe;
811
+ let visible = true;
721
812
  const stop = watch(
722
813
  () => [toValue(options.client), toValue(options.client).sessionIdentity, toValue(options.conversationId)],
723
814
  () => {
@@ -728,6 +819,7 @@ function useConversation(options) {
728
819
  unsubscribe = store.subscribe(() => {
729
820
  snapshot.value = store.getSnapshot();
730
821
  });
822
+ store.setVisible(visible);
731
823
  store.start(options.autoLoad ?? true);
732
824
  },
733
825
  { immediate: true, flush: "sync" }
@@ -747,6 +839,7 @@ function useConversation(options) {
747
839
  messages: field("messages"),
748
840
  typingUserIds: field("typingUserIds"),
749
841
  readAtByUserId: field("readAtByUserId"),
842
+ readPositionByUserId: field("readPositionByUserId"),
750
843
  isInitialLoading: field("isInitialLoading"),
751
844
  isLoadingOlder: field("isLoadingOlder"),
752
845
  isReconciling: field("isReconciling"),
@@ -762,6 +855,10 @@ function useConversation(options) {
762
855
  sendMessage: (input) => store.sendMessage(input),
763
856
  markRead: () => store.markRead(),
764
857
  updateTyping: (isTyping) => store.updateTyping(isTyping),
858
+ setVisible: (value) => {
859
+ visible = value;
860
+ store.setVisible(value);
861
+ },
765
862
  dispose
766
863
  };
767
864
  }
@@ -834,6 +931,7 @@ var MessageListView = defineComponent2({
834
931
  messages: { type: Array, required: true },
835
932
  currentUserId: { type: String, required: true },
836
933
  readAtByUserId: { type: Object, default: () => /* @__PURE__ */ new Map() },
934
+ readPositionByUserId: { type: Object, default: () => /* @__PURE__ */ new Map() },
837
935
  readersResolver: { type: Function, default: void 0 },
838
936
  onLoadOlder: { type: Function, default: void 0 },
839
937
  hasOlderMessages: { type: Boolean, default: false },
@@ -893,7 +991,7 @@ var MessageListView = defineComponent2({
893
991
  const isCurrentUser = message.senderId === props.currentUserId;
894
992
  const sender = participants.value.get(message.senderId);
895
993
  const isPending = isConvoKitPendingMessage(message);
896
- const readerIds = isPending ? /* @__PURE__ */ new Set() : props.readersResolver ? props.readersResolver(message) : readerIdsFor(message, props.readAtByUserId);
994
+ const readerIds = isPending ? /* @__PURE__ */ new Set() : props.readersResolver ? props.readersResolver(message) : readerIdsFor(message, props.readAtByUserId, props.readPositionByUserId);
897
995
  const slotProps = { message, chronologicalIndex: index, isCurrentUser, sender, readerIds };
898
996
  const custom = slots.message?.(slotProps);
899
997
  if (custom) return h2("div", { key: message.id, role: "listitem" }, custom);
@@ -992,8 +1090,8 @@ var MessageListView = defineComponent2({
992
1090
  };
993
1091
  }
994
1092
  });
995
- function defaultReadersResolver(readAtByUserId) {
996
- return (message) => readerIdsFor(message, readAtByUserId);
1093
+ function defaultReadersResolver(readAtByUserId, readPositionByUserId = /* @__PURE__ */ new Map()) {
1094
+ return (message) => readerIdsFor(message, readAtByUserId, readPositionByUserId);
997
1095
  }
998
1096
 
999
1097
  // src/components/conversation.ts
@@ -1011,6 +1109,7 @@ var viewProps = {
1011
1109
  onSendMessage: { type: Function, required: true },
1012
1110
  typingUserIds: { type: Object, default: () => /* @__PURE__ */ new Set() },
1013
1111
  readAtByUserId: { type: Object, default: () => /* @__PURE__ */ new Map() },
1112
+ readPositionByUserId: { type: Object, default: () => /* @__PURE__ */ new Map() },
1014
1113
  readersResolver: { type: Function, default: void 0 },
1015
1114
  onBack: { type: Function, default: void 0 },
1016
1115
  onRefresh: { type: Function, default: void 0 },
@@ -1239,6 +1338,7 @@ var ConversationView = defineComponent3({
1239
1338
  messages: props.messages,
1240
1339
  currentUserId: props.currentUserId,
1241
1340
  readAtByUserId: props.readAtByUserId,
1341
+ readPositionByUserId: props.readPositionByUserId,
1242
1342
  ...props.readersResolver ? { readersResolver: props.readersResolver } : {},
1243
1343
  ...props.onLoadOlder ? { onLoadOlder: loadOlder } : {},
1244
1344
  hasOlderMessages: props.hasOlderMessages,
@@ -1301,6 +1401,12 @@ var Conversation = defineComponent3({
1301
1401
  watchEffect(() => {
1302
1402
  emit("controller-change", controller);
1303
1403
  });
1404
+ if (typeof document !== "undefined") {
1405
+ const syncVisibility = () => controller.setVisible(document.visibilityState !== "hidden");
1406
+ syncVisibility();
1407
+ document.addEventListener("visibilitychange", syncVisibility);
1408
+ onBeforeUnmount(() => document.removeEventListener("visibilitychange", syncVisibility));
1409
+ }
1304
1410
  return () => {
1305
1411
  const loadedConversation = controller.conversation.value;
1306
1412
  if (!loadedConversation) {
@@ -1335,6 +1441,7 @@ var Conversation = defineComponent3({
1335
1441
  onSendMessage: _onSendMessage,
1336
1442
  typingUserIds: _typingUserIds,
1337
1443
  readAtByUserId: _readAtByUserId,
1444
+ readPositionByUserId: _readPositionByUserId,
1338
1445
  onRefresh: _onRefresh,
1339
1446
  onLoadOlder: _onLoadOlder,
1340
1447
  onTypingChange: _onTypingChange,
@@ -1357,6 +1464,7 @@ var Conversation = defineComponent3({
1357
1464
  },
1358
1465
  typingUserIds: controller.typingUserIds.value,
1359
1466
  readAtByUserId: controller.readAtByUserId.value,
1467
+ readPositionByUserId: controller.readPositionByUserId.value,
1360
1468
  onRefresh: controller.refresh,
1361
1469
  onLoadOlder: controller.loadOlderMessages,
1362
1470
  onTypingChange: controller.updateTyping,