@convokitapp/vue-ui 0.4.1 → 0.6.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.cjs CHANGED
@@ -35,6 +35,7 @@ __export(index_exports, {
35
35
  isConvoKitPendingMessage: () => isConvoKitPendingMessage,
36
36
  matchesConversation: () => matchesConversation,
37
37
  mergeConversations: () => mergeConversations,
38
+ mergeInboxEntries: () => mergeInboxEntries,
38
39
  mergeMessages: () => mergeMessages,
39
40
  readerIdsFor: () => readerIdsFor,
40
41
  useConversation: () => useConversation,
@@ -54,6 +55,10 @@ function createConvoKitUiClient(client) {
54
55
  onEvent: handler,
55
56
  ...onError ? { onError } : {}
56
57
  }),
58
+ onInboxActivity: (handler, onError) => client.realtime.onInboxActivity(client.clientId, {
59
+ onEvent: handler,
60
+ ...onError ? { onError } : {}
61
+ }),
57
62
  onMessageDeleted: (conversationId, handler, onError) => client.realtime.onMessageDeleted(conversationId, {
58
63
  onEvent: handler,
59
64
  ...onError ? { onError } : {}
@@ -62,11 +67,12 @@ function createConvoKitUiClient(client) {
62
67
  return client.connected ? client.currentUserId : "";
63
68
  },
64
69
  getConversations: (options) => client.getConversations(options),
70
+ listInbox: (options) => client.listInbox(options),
65
71
  getConversation: (conversationId) => client.getConversation(conversationId),
66
72
  getMessages: (options) => client.getMessages(options),
67
73
  getMessage: (id) => client.getMessage(id),
68
74
  sendMessage: (input) => client.sendMessage(input),
69
- markConversationRead: (conversationId) => client.markConversationRead(conversationId),
75
+ markConversationRead: (conversationId, options) => client.markConversationRead(conversationId, options),
70
76
  sendTyping: (input) => client.sendTyping(input),
71
77
  onMessage: (conversationId, handler, onError) => client.realtime.onMessage(conversationId, {
72
78
  onEvent: handler,
@@ -88,9 +94,13 @@ var import_reka_ui = require("reka-ui");
88
94
  var import_vue2 = require("vue");
89
95
 
90
96
  // src/utils.ts
97
+ var import_sdk = require("@convokitapp/sdk");
91
98
  var import_clsx = require("clsx");
92
99
  var import_vue = require("vue");
93
100
  var pendingMessageIdPrefix = "convokit-pending-";
101
+ function compareMessageOrder(left, right) {
102
+ return left.createdAt.getTime() - right.createdAt.getTime() || (left.id < right.id ? -1 : left.id > right.id ? 1 : 0);
103
+ }
94
104
  function isConvoKitPendingMessage(message) {
95
105
  return message.id.startsWith(pendingMessageIdPrefix);
96
106
  }
@@ -132,6 +142,31 @@ function mergeConversations(current, incoming) {
132
142
  for (const conversation of incoming) byId.set(conversation.id, conversation);
133
143
  return [...byId.values()];
134
144
  }
145
+ function compareInboxOrder(left, right) {
146
+ return right.activityAt.getTime() - left.activityAt.getTime() || (left.conversation.id < right.conversation.id ? 1 : left.conversation.id > right.conversation.id ? -1 : 0);
147
+ }
148
+ function mergeInboxEntries(current, incoming) {
149
+ const byId = new Map(current.map((entry) => [entry.conversation.id, entry]));
150
+ for (const entry of incoming) byId.set(entry.conversation.id, entry);
151
+ return [...byId.values()].sort(compareInboxOrder);
152
+ }
153
+ function inboxPreview(conversation, summary, currentUserId) {
154
+ const message = summary?.latestMessage;
155
+ if (!message) return "";
156
+ const first = message.media[0];
157
+ const body = message.text?.trim() || (!first ? "" : first.type === "image" ? "Photo" : first.type === "file" ? first.name?.trim() || "File" : first.type === "location" ? "Location" : first.type === "contact" ? "Contact" : "");
158
+ if (!body) return "";
159
+ if (currentUserId !== void 0 && message.senderId === currentUserId) return `You: ${body}`;
160
+ if (conversation.participants.length > 2) {
161
+ const sender = conversation.participants.find((participant) => participant.appUserId === message.senderId || participant.id === message.senderId);
162
+ const name = sender?.name.trim();
163
+ if (name) return `${name}: ${body}`;
164
+ }
165
+ return body;
166
+ }
167
+ function formatMessageTime(date) {
168
+ return new Intl.DateTimeFormat(void 0, { hour: "numeric", minute: "2-digit" }).format(date);
169
+ }
135
170
  function mergeMessages(current, incoming) {
136
171
  const byId = new Map(current.map((message) => [message.id, message]));
137
172
  for (const message of incoming) byId.set(message.id, message);
@@ -139,13 +174,16 @@ function mergeMessages(current, incoming) {
139
174
  const leftPending = isConvoKitPendingMessage(left);
140
175
  const rightPending = isConvoKitPendingMessage(right);
141
176
  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;
177
+ return compareMessageOrder(left, right);
144
178
  });
145
179
  }
146
- function readerIdsFor(message, readAtByUserId) {
180
+ function readerIdsFor(message, readAtByUserId, readPositionByUserId = /* @__PURE__ */ new Map()) {
147
181
  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));
182
+ const userIds = /* @__PURE__ */ new Set([...readAtByUserId.keys(), ...readPositionByUserId.keys()]);
183
+ return new Set([...userIds].filter((userId) => userId !== message.senderId && (0, import_sdk.readThrough)({
184
+ readPosition: readPositionByUserId.get(userId) ?? null,
185
+ lastReadAt: readAtByUserId.get(userId) ?? null
186
+ }, message)));
149
187
  }
150
188
  function partClass(part, appearance, defaultClass) {
151
189
  return cx(!appearance.unstyled && defaultClass, appearance.classNames?.[part]);
@@ -199,7 +237,7 @@ var import_vue7 = require("vue");
199
237
  var import_vue3 = require("vue");
200
238
 
201
239
  // src/conversation-store.ts
202
- var import_sdk = require("@convokitapp/sdk");
240
+ var import_sdk2 = require("@convokitapp/sdk");
203
241
  function version(message) {
204
242
  return message.updatedAt?.getTime() ?? message.createdAt.getTime();
205
243
  }
@@ -209,8 +247,20 @@ function hasContent(message) {
209
247
  function newest(current, incoming, incomingComplete = true) {
210
248
  return version(current) > version(incoming) || !incomingComplete && version(current) === version(incoming) ? current : incoming;
211
249
  }
212
- function compare(left, right) {
213
- return left.createdAt.getTime() - right.createdAt.getTime() || (left.id < right.id ? -1 : left.id > right.id ? 1 : 0);
250
+ var compare = compareMessageOrder;
251
+ function positionCursor(position) {
252
+ return { createdAt: position.createdAt, id: position.messageId };
253
+ }
254
+ function readEntry(participant) {
255
+ return { userId: participant.appUserId, readAt: participant.lastReadAt, readPosition: participant.readPosition };
256
+ }
257
+ function acknowledgement() {
258
+ return { inFlight: void 0, followUp: false, suppressed: false, target: void 0, acknowledged: void 0, unacknowledgeable: /* @__PURE__ */ new Set() };
259
+ }
260
+ function isTargetMiss(cause) {
261
+ if (typeof cause !== "object" || cause === null) return false;
262
+ const { code, status } = cause;
263
+ return code === "MESSAGE_NOT_FOUND" || code === void 0 && status === 404;
214
264
  }
215
265
  function blank(currentUserId = "") {
216
266
  return {
@@ -218,6 +268,7 @@ function blank(currentUserId = "") {
218
268
  messages: [],
219
269
  typingUserIds: /* @__PURE__ */ new Set(),
220
270
  readAtByUserId: /* @__PURE__ */ new Map(),
271
+ readPositionByUserId: /* @__PURE__ */ new Map(),
221
272
  isInitialLoading: false,
222
273
  isLoadingOlder: false,
223
274
  isReconciling: false,
@@ -265,6 +316,9 @@ var ConversationStore = class {
265
316
  hydrationPool = { running: /* @__PURE__ */ new Set(), queued: /* @__PURE__ */ new Map() };
266
317
  // Keep tombstones until an explicit reload/session change, including across refreshes.
267
318
  deleted = /* @__PURE__ */ new Set();
319
+ ack = acknowledgement();
320
+ // Visible until the platform reports otherwise; unknown/prerender/no document count as visible.
321
+ visible = true;
268
322
  sendRevision;
269
323
  activeSend;
270
324
  refreshQueued = false;
@@ -327,6 +381,7 @@ var ConversationStore = class {
327
381
  this.hydrations.clear();
328
382
  this.hydrationPool.queued.clear();
329
383
  this.deleted.clear();
384
+ this.ack = acknowledgement();
330
385
  this.sendRevision = void 0;
331
386
  this.activeSend = void 0;
332
387
  this.refreshQueued = false;
@@ -382,8 +437,8 @@ var ConversationStore = class {
382
437
  if (!this.alive(generation) || conversationId !== this.room || !id.trim()) return;
383
438
  this.removeMessage(id);
384
439
  }, report));
385
- add(() => this.client.onReadReceipt(this.room, ({ userId, readAt }) => {
386
- if (this.alive(generation)) this.mergeReads([[userId, readAt]]);
440
+ add(() => this.client.onReadReceipt(this.room, ({ userId, readAt, readPosition }) => {
441
+ if (this.alive(generation)) this.mergeReads([{ userId, readAt, readPosition }]);
387
442
  }, report));
388
443
  add(() => this.client.onTyping(this.room, ({ userId, isTyping }) => {
389
444
  if (!this.alive(generation) || !userId.trim() || userId === this.user) return;
@@ -426,9 +481,6 @@ var ConversationStore = class {
426
481
  this.hydrations.set(message.id, job);
427
482
  this.hydrationPool.queued.set(message.id, job);
428
483
  this.drainHydration();
429
- if (type === "insert" && !existing && message.senderId !== this.user && (this.options.markReadOnReceive ?? true)) {
430
- void this.markRead();
431
- }
432
484
  }
433
485
  record(message, insert, revision, complete) {
434
486
  const existing = this.state.messages.find((item) => item.id === message.id);
@@ -438,7 +490,9 @@ var ConversationStore = class {
438
490
  this.confirmSend(message);
439
491
  }
440
492
  this.changes.set(message.id, { revision, message, insert, complete });
441
- if (existing || insert && hasContent(message)) this.patch({ messages: mergeMessages(this.state.messages, [message]) });
493
+ if (!existing && !(insert && hasContent(message))) return;
494
+ this.patch({ messages: mergeMessages(this.state.messages, [message]) });
495
+ if (!existing && message.senderId !== this.user && (this.options.markReadOnReceive ?? true)) void this.acknowledge(true);
442
496
  }
443
497
  confirmSend(message) {
444
498
  const send = this.activeSend;
@@ -450,11 +504,19 @@ var ConversationStore = class {
450
504
  return this.alive(job.generation) && !this.deleted.has(job.message.id) && this.hydrations.get(job.message.id) === job;
451
505
  }
452
506
  removeMessage(id) {
507
+ this.forget(id);
508
+ this.patch({ messages: this.state.messages.filter((message) => message.id !== id) });
509
+ }
510
+ /** Tombstone a row learned to be gone; a removed acknowledgement target is re-resolved from what remains. */
511
+ forget(id) {
453
512
  this.deleted.add(id);
454
513
  this.changes.delete(id);
455
514
  this.hydrations.delete(id);
456
515
  this.hydrationPool.queued.delete(id);
457
- this.patch({ messages: this.state.messages.filter((message) => message.id !== id) });
516
+ const ack = this.ack;
517
+ if (ack.target !== id && ack.acknowledged?.id !== id) return;
518
+ ack.unacknowledgeable.add(id);
519
+ if (ack.target === id) ack.followUp = true;
458
520
  }
459
521
  drainHydration() {
460
522
  const pool = this.hydrationPool;
@@ -491,13 +553,23 @@ var ConversationStore = class {
491
553
  });
492
554
  }
493
555
  }
556
+ /** Both maps only ever advance: acknowledgement times by time, positions by (createdAt, id). Participants and
557
+ * read events are the only sources; the local user's own read is never written from the device clock.
558
+ */
494
559
  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);
560
+ const readAt = new Map(this.state.readAtByUserId);
561
+ const positions = new Map(this.state.readPositionByUserId);
562
+ for (const entry of entries) {
563
+ const { userId, readPosition } = entry;
564
+ if (!userId.trim()) continue;
565
+ if (entry.readAt && Number.isFinite(entry.readAt.getTime()) && entry.readAt.getTime() > (readAt.get(userId)?.getTime() ?? -Infinity)) {
566
+ readAt.set(userId, entry.readAt);
567
+ }
568
+ if (!readPosition || typeof readPosition.messageId !== "string" || !readPosition.messageId.trim() || !(readPosition.createdAt instanceof Date) || !Number.isFinite(readPosition.createdAt.getTime())) continue;
569
+ const current = positions.get(userId);
570
+ if (!current || compare(positionCursor(readPosition), positionCursor(current)) > 0) positions.set(userId, readPosition);
499
571
  }
500
- this.patch({ readAtByUserId: next });
572
+ this.patch({ readAtByUserId: readAt, readPositionByUserId: positions });
501
573
  }
502
574
  validatePage(page, before) {
503
575
  if (page.length > this.pageSize) throw new Error("Message page exceeds the requested limit");
@@ -554,9 +626,9 @@ var ConversationStore = class {
554
626
  this.validatePage(page);
555
627
  this.cursor = page.at(-1);
556
628
  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]] : []));
629
+ this.mergeReads(conversation.participants.map(readEntry));
558
630
  this.prune(revision);
559
- if (this.options.markReadOnLoad ?? true) await this.markRead();
631
+ if (this.options.markReadOnLoad ?? true) await this.acknowledge(true);
560
632
  } catch (cause) {
561
633
  this.fail(cause, generation, true);
562
634
  } finally {
@@ -612,14 +684,9 @@ var ConversationStore = class {
612
684
  const reconciled = this.overlay(rows, revision);
613
685
  const survivingIds = new Set(reconciled.map((message) => message.id));
614
686
  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
- }
687
+ for (const id of known) if (!survivingIds.has(id)) this.forget(id);
621
688
  this.patch({ conversation, messages: reconciled, hasOlderMessages: hasOlder });
622
- this.mergeReads(conversation.participants.flatMap((participant) => participant.lastReadAt ? [[participant.appUserId, participant.lastReadAt]] : []));
689
+ this.mergeReads(conversation.participants.map(readEntry));
623
690
  this.prune(revision);
624
691
  } catch (cause) {
625
692
  this.fail(cause, generation, true);
@@ -654,15 +721,68 @@ var ConversationStore = class {
654
721
  }
655
722
  }
656
723
  };
657
- markRead = async () => {
658
- if (!this.alive()) return;
659
- const generation = this.generation;
724
+ /** Acknowledge through the newest rendered row now, regardless of visibility; no request without a target. */
725
+ markRead = () => this.alive() ? this.acknowledge(false) : Promise.resolve();
726
+ /** Automatic acknowledgements wait while hidden and are re-issued (once) on becoming visible. */
727
+ setVisible = (visible) => {
728
+ this.visible = visible;
729
+ if (!visible || !this.ack.suppressed || !this.alive()) return;
730
+ this.ack.suppressed = false;
731
+ void this.acknowledge(true);
732
+ };
733
+ /** The newest non-pending rendered row by (createdAt, id), never by list index and never a raw realtime
734
+ * row; rows the server does not know are skipped, and nothing at or before the accepted target is re-sent.
735
+ */
736
+ ackTarget(ack) {
737
+ let target;
738
+ for (const message of this.state.messages) {
739
+ if (isConvoKitPendingMessage(message) || ack.unacknowledgeable.has(message.id)) continue;
740
+ if (!target || compare(message, target) > 0) target = { createdAt: message.createdAt, id: message.id };
741
+ }
742
+ return target && (!ack.acknowledged || compare(target, ack.acknowledged) > 0) ? target : void 0;
743
+ }
744
+ /** Resolves when the request this call issued or joined settles; a follow-up is issued, not awaited. */
745
+ acknowledge(automatic) {
746
+ const ack = this.ack;
747
+ if (automatic && !this.visible) {
748
+ ack.suppressed = true;
749
+ return Promise.resolve();
750
+ }
751
+ if (ack.inFlight) {
752
+ ack.followUp = true;
753
+ return ack.inFlight;
754
+ }
755
+ return this.issue(ack, this.generation) ?? Promise.resolve();
756
+ }
757
+ issue(ack, generation) {
758
+ ack.followUp = false;
759
+ const target = this.ackTarget(ack);
760
+ if (!target) return void 0;
761
+ ack.target = target.id;
762
+ ack.inFlight = this.send(ack, generation, target).finally(() => {
763
+ ack.inFlight = void 0;
764
+ ack.target = void 0;
765
+ if (!this.alive(generation) || !ack.followUp) return;
766
+ if (!this.visible) {
767
+ ack.followUp = false;
768
+ ack.suppressed = true;
769
+ } else this.issue(ack, generation);
770
+ });
771
+ return ack.inFlight;
772
+ }
773
+ async send(ack, generation, target) {
660
774
  try {
661
- await this.client.markConversationRead(this.room);
775
+ await this.client.markConversationRead(this.room, { throughMessageId: target.id });
776
+ if (!this.alive(generation)) return;
777
+ if (!ack.acknowledged || compare(target, ack.acknowledged) > 0) ack.acknowledged = target;
662
778
  } catch (cause) {
663
- this.fail(cause, generation);
779
+ if (!this.alive(generation)) return;
780
+ if (isTargetMiss(cause)) {
781
+ ack.unacknowledgeable.add(target.id);
782
+ ack.followUp = true;
783
+ } else this.fail(cause, generation);
664
784
  }
665
- };
785
+ }
666
786
  updateTyping = async (isTyping) => {
667
787
  if (!this.alive()) return;
668
788
  const generation = this.generation;
@@ -691,7 +811,7 @@ var ConversationStore = class {
691
811
  const generation = this.generation;
692
812
  const revision = this.revision;
693
813
  this.sendRevision = revision;
694
- const clientMessageId = (0, import_sdk.createClientMessageId)();
814
+ const clientMessageId = (0, import_sdk2.createClientMessageId)();
695
815
  const pendingId = `convokit-pending-${clientMessageId}`;
696
816
  const pending = {
697
817
  id: pendingId,
@@ -745,7 +865,7 @@ var ConversationStore = class {
745
865
  }
746
866
  }
747
867
  };
748
- readerIdsFor = (message) => readerIdsFor(message, this.state.readAtByUserId);
868
+ readerIdsFor = (message) => readerIdsFor(message, this.state.readAtByUserId, this.state.readPositionByUserId);
749
869
  };
750
870
 
751
871
  // src/composables/use-conversation.ts
@@ -758,6 +878,7 @@ function useConversation(options) {
758
878
  let store = createStore();
759
879
  const snapshot = (0, import_vue3.shallowRef)(store.getSnapshot());
760
880
  let unsubscribe;
881
+ let visible = true;
761
882
  const stop = (0, import_vue3.watch)(
762
883
  () => [(0, import_vue3.toValue)(options.client), (0, import_vue3.toValue)(options.client).sessionIdentity, (0, import_vue3.toValue)(options.conversationId)],
763
884
  () => {
@@ -768,6 +889,7 @@ function useConversation(options) {
768
889
  unsubscribe = store.subscribe(() => {
769
890
  snapshot.value = store.getSnapshot();
770
891
  });
892
+ store.setVisible(visible);
771
893
  store.start(options.autoLoad ?? true);
772
894
  },
773
895
  { immediate: true, flush: "sync" }
@@ -787,6 +909,7 @@ function useConversation(options) {
787
909
  messages: field("messages"),
788
910
  typingUserIds: field("typingUserIds"),
789
911
  readAtByUserId: field("readAtByUserId"),
912
+ readPositionByUserId: field("readPositionByUserId"),
790
913
  isInitialLoading: field("isInitialLoading"),
791
914
  isLoadingOlder: field("isLoadingOlder"),
792
915
  isReconciling: field("isReconciling"),
@@ -802,6 +925,10 @@ function useConversation(options) {
802
925
  sendMessage: (input) => store.sendMessage(input),
803
926
  markRead: () => store.markRead(),
804
927
  updateTyping: (isTyping) => store.updateTyping(isTyping),
928
+ setVisible: (value) => {
929
+ visible = value;
930
+ store.setVisible(value);
931
+ },
805
932
  dispose
806
933
  };
807
934
  }
@@ -815,9 +942,6 @@ var appearanceProps = {
815
942
  density: { type: String, default: "comfortable" },
816
943
  unstyled: { type: Boolean, default: false }
817
944
  };
818
- function defaultFormatTime(date) {
819
- return new Intl.DateTimeFormat(void 0, { hour: "numeric", minute: "2-digit" }).format(date);
820
- }
821
945
  function defaultMedia(media, open, imageLoading) {
822
946
  const tag = open ? "button" : "div";
823
947
  const interactive = open ? { type: "button", onClick: open } : {};
@@ -857,6 +981,7 @@ var MessageListView = (0, import_vue5.defineComponent)({
857
981
  messages: { type: Array, required: true },
858
982
  currentUserId: { type: String, required: true },
859
983
  readAtByUserId: { type: Object, default: () => /* @__PURE__ */ new Map() },
984
+ readPositionByUserId: { type: Object, default: () => /* @__PURE__ */ new Map() },
860
985
  readersResolver: { type: Function, default: void 0 },
861
986
  onLoadOlder: { type: Function, default: void 0 },
862
987
  hasOlderMessages: { type: Boolean, default: false },
@@ -867,7 +992,7 @@ var MessageListView = (0, import_vue5.defineComponent)({
867
992
  paginationThreshold: { type: Number, default: 240 },
868
993
  reverse: { type: Boolean, default: true },
869
994
  stickToBottom: { type: Boolean, default: true },
870
- formatTime: { type: Function, default: defaultFormatTime },
995
+ formatTime: { type: Function, default: formatMessageTime },
871
996
  imageLoading: { type: String, default: "lazy" }
872
997
  },
873
998
  emits: ["load-older", "attachment-click"],
@@ -916,7 +1041,7 @@ var MessageListView = (0, import_vue5.defineComponent)({
916
1041
  const isCurrentUser = message.senderId === props.currentUserId;
917
1042
  const sender = participants.value.get(message.senderId);
918
1043
  const isPending = isConvoKitPendingMessage(message);
919
- const readerIds = isPending ? /* @__PURE__ */ new Set() : props.readersResolver ? props.readersResolver(message) : readerIdsFor(message, props.readAtByUserId);
1044
+ const readerIds = isPending ? /* @__PURE__ */ new Set() : props.readersResolver ? props.readersResolver(message) : readerIdsFor(message, props.readAtByUserId, props.readPositionByUserId);
920
1045
  const slotProps = { message, chronologicalIndex: index, isCurrentUser, sender, readerIds };
921
1046
  const custom = slots.message?.(slotProps);
922
1047
  if (custom) return (0, import_vue5.h)("div", { key: message.id, role: "listitem" }, custom);
@@ -1015,8 +1140,8 @@ var MessageListView = (0, import_vue5.defineComponent)({
1015
1140
  };
1016
1141
  }
1017
1142
  });
1018
- function defaultReadersResolver(readAtByUserId) {
1019
- return (message) => readerIdsFor(message, readAtByUserId);
1143
+ function defaultReadersResolver(readAtByUserId, readPositionByUserId = /* @__PURE__ */ new Map()) {
1144
+ return (message) => readerIdsFor(message, readAtByUserId, readPositionByUserId);
1020
1145
  }
1021
1146
 
1022
1147
  // src/components/conversation.ts
@@ -1034,6 +1159,7 @@ var viewProps = {
1034
1159
  onSendMessage: { type: Function, required: true },
1035
1160
  typingUserIds: { type: Object, default: () => /* @__PURE__ */ new Set() },
1036
1161
  readAtByUserId: { type: Object, default: () => /* @__PURE__ */ new Map() },
1162
+ readPositionByUserId: { type: Object, default: () => /* @__PURE__ */ new Map() },
1037
1163
  readersResolver: { type: Function, default: void 0 },
1038
1164
  onBack: { type: Function, default: void 0 },
1039
1165
  onRefresh: { type: Function, default: void 0 },
@@ -1262,6 +1388,7 @@ var ConversationView = (0, import_vue7.defineComponent)({
1262
1388
  messages: props.messages,
1263
1389
  currentUserId: props.currentUserId,
1264
1390
  readAtByUserId: props.readAtByUserId,
1391
+ readPositionByUserId: props.readPositionByUserId,
1265
1392
  ...props.readersResolver ? { readersResolver: props.readersResolver } : {},
1266
1393
  ...props.onLoadOlder ? { onLoadOlder: loadOlder } : {},
1267
1394
  hasOlderMessages: props.hasOlderMessages,
@@ -1324,6 +1451,12 @@ var Conversation = (0, import_vue7.defineComponent)({
1324
1451
  (0, import_vue7.watchEffect)(() => {
1325
1452
  emit("controller-change", controller);
1326
1453
  });
1454
+ if (typeof document !== "undefined") {
1455
+ const syncVisibility = () => controller.setVisible(document.visibilityState !== "hidden");
1456
+ syncVisibility();
1457
+ document.addEventListener("visibilitychange", syncVisibility);
1458
+ (0, import_vue7.onBeforeUnmount)(() => document.removeEventListener("visibilitychange", syncVisibility));
1459
+ }
1327
1460
  return () => {
1328
1461
  const loadedConversation = controller.conversation.value;
1329
1462
  if (!loadedConversation) {
@@ -1358,6 +1491,7 @@ var Conversation = (0, import_vue7.defineComponent)({
1358
1491
  onSendMessage: _onSendMessage,
1359
1492
  typingUserIds: _typingUserIds,
1360
1493
  readAtByUserId: _readAtByUserId,
1494
+ readPositionByUserId: _readPositionByUserId,
1361
1495
  onRefresh: _onRefresh,
1362
1496
  onLoadOlder: _onLoadOlder,
1363
1497
  onTypingChange: _onTypingChange,
@@ -1380,6 +1514,7 @@ var Conversation = (0, import_vue7.defineComponent)({
1380
1514
  },
1381
1515
  typingUserIds: controller.typingUserIds.value,
1382
1516
  readAtByUserId: controller.readAtByUserId.value,
1517
+ readPositionByUserId: controller.readPositionByUserId.value,
1383
1518
  onRefresh: controller.refresh,
1384
1519
  onLoadOlder: controller.loadOlderMessages,
1385
1520
  onTypingChange: controller.updateTyping,
@@ -1414,30 +1549,64 @@ var import_vue10 = require("vue");
1414
1549
  var import_vue8 = require("vue");
1415
1550
 
1416
1551
  // src/conversation-list-store.ts
1552
+ var defaultActivityRefreshWindowMs = 500;
1417
1553
  function blank2(filter) {
1418
- return { conversations: [], filter, isInitialLoading: false, isLoadingMore: false, hasMore: true, hasLoaded: false, error: null };
1554
+ return {
1555
+ conversations: [],
1556
+ summaries: /* @__PURE__ */ new Map(),
1557
+ currentUserId: "",
1558
+ filter,
1559
+ isInitialLoading: false,
1560
+ isLoadingMore: false,
1561
+ hasMore: true,
1562
+ hasLoaded: false,
1563
+ error: null
1564
+ };
1565
+ }
1566
+ function statusOf(cause) {
1567
+ return typeof cause === "object" && cause !== null && "status" in cause ? cause.status : void 0;
1568
+ }
1569
+ function summaryOf(entry) {
1570
+ const { conversation: _conversation, ...summary } = entry;
1571
+ return summary;
1572
+ }
1573
+ function ids(entries) {
1574
+ return entries.map((entry) => entry.conversation);
1419
1575
  }
1420
1576
  var ConversationListStore = class {
1421
1577
  constructor(options) {
1422
1578
  this.options = options;
1423
1579
  this.owner = options.client.sessionIdentity;
1580
+ this.user = this.owner ? options.client.currentUserId : "";
1424
1581
  this.pageSize = options.pageSize ?? 30;
1425
1582
  if (!Number.isInteger(this.pageSize) || this.pageSize < 1 || this.pageSize > 100) {
1426
1583
  throw new RangeError("pageSize must be an integer between 1 and 100");
1427
1584
  }
1585
+ this.activityRefreshWindowMs = options.activityRefreshWindowMs ?? defaultActivityRefreshWindowMs;
1586
+ if (!Number.isFinite(this.activityRefreshWindowMs) || this.activityRefreshWindowMs < 0) {
1587
+ throw new RangeError("activityRefreshWindowMs must be a non-negative number");
1588
+ }
1428
1589
  this.state = blank2(options.initialFilter ?? {});
1429
1590
  }
1430
1591
  options;
1431
1592
  owner;
1593
+ user;
1432
1594
  pageSize;
1595
+ activityRefreshWindowMs;
1433
1596
  state;
1434
1597
  source = [];
1598
+ entries = [];
1435
1599
  offset = 0;
1600
+ cursor = null;
1601
+ inboxUnavailable = false;
1602
+ inboxWarned = false;
1436
1603
  generation = 0;
1437
1604
  lifecycleGeneration = 0;
1438
1605
  disposed = true;
1439
1606
  lifecycle;
1440
1607
  inbox;
1608
+ activity;
1609
+ activityTimer;
1441
1610
  refreshQueued = false;
1442
1611
  refreshing = false;
1443
1612
  listeners = /* @__PURE__ */ new Set();
@@ -1455,12 +1624,27 @@ var ConversationListStore = class {
1455
1624
  alive(generation = this.generation) {
1456
1625
  return !this.disposed && generation === this.generation && this.owner !== null && this.options.client.sessionIdentity === this.owner;
1457
1626
  }
1627
+ get inboxMode() {
1628
+ return !this.options.pageLoader && typeof this.options.client.listInbox === "function" && !this.inboxUnavailable;
1629
+ }
1630
+ currentUserId() {
1631
+ return this.inboxMode ? this.user : "";
1632
+ }
1458
1633
  start = (autoLoad = true) => {
1459
1634
  if (!this.owner || this.options.client.sessionIdentity !== this.owner) return;
1460
1635
  if (!this.disposed) return;
1461
1636
  this.disposed = false;
1637
+ this.inboxUnavailable = false;
1462
1638
  const lifecycleGeneration = ++this.lifecycleGeneration;
1639
+ const current = () => this.alive() && lifecycleGeneration === this.lifecycleGeneration;
1640
+ const reconcile = (cause) => {
1641
+ if (current()) {
1642
+ this.patch({ error: cause });
1643
+ this.queueRefresh();
1644
+ }
1645
+ };
1463
1646
  try {
1647
+ this.patch({ currentUserId: this.currentUserId() });
1464
1648
  const subscription = this.options.client.onConnectionEvent({
1465
1649
  onEvent: () => {
1466
1650
  },
@@ -1472,15 +1656,20 @@ var ConversationListStore = class {
1472
1656
  else void subscription.unsubscribe().catch(() => void 0);
1473
1657
  if (!this.alive()) return;
1474
1658
  const inbox = this.options.client.onInboxChanged(() => {
1475
- if (this.alive() && lifecycleGeneration === this.lifecycleGeneration) this.queueRefresh();
1476
- }, (cause) => {
1477
- if (this.alive() && lifecycleGeneration === this.lifecycleGeneration) {
1478
- this.patch({ error: cause });
1479
- this.queueRefresh();
1480
- }
1481
- });
1659
+ if (!current()) return;
1660
+ this.clearActivityTimer();
1661
+ this.queueRefresh();
1662
+ }, reconcile);
1482
1663
  if (this.alive()) this.inbox = inbox;
1483
1664
  else void inbox.unsubscribe().catch(() => void 0);
1665
+ if (!this.alive()) return;
1666
+ if (this.inboxMode && typeof this.options.client.onInboxActivity === "function") {
1667
+ const activity = this.options.client.onInboxActivity(() => {
1668
+ if (current()) this.scheduleActivityRefresh(lifecycleGeneration);
1669
+ }, reconcile);
1670
+ if (this.alive()) this.activity = activity;
1671
+ else void activity.unsubscribe().catch(() => void 0);
1672
+ }
1484
1673
  if (autoLoad) void this.loadInitial();
1485
1674
  } catch (cause) {
1486
1675
  if (this.alive()) this.patch({ error: cause });
@@ -1490,27 +1679,109 @@ var ConversationListStore = class {
1490
1679
  this.disposed = true;
1491
1680
  this.generation++;
1492
1681
  this.lifecycleGeneration++;
1682
+ this.clearActivityTimer();
1493
1683
  const subscription = this.lifecycle;
1494
1684
  this.lifecycle = void 0;
1495
1685
  if (subscription) void subscription.unsubscribe().catch(() => void 0);
1496
1686
  if (this.inbox) void this.inbox.unsubscribe().catch(() => void 0);
1497
1687
  this.inbox = void 0;
1688
+ this.stopActivity();
1498
1689
  this.refreshQueued = false;
1499
1690
  this.refreshing = false;
1500
1691
  this.source = [];
1692
+ this.entries = [];
1501
1693
  this.offset = 0;
1694
+ this.cursor = null;
1502
1695
  this.patch(blank2(this.state.filter));
1503
1696
  };
1697
+ stopActivity() {
1698
+ if (this.activity) void this.activity.unsubscribe().catch(() => void 0);
1699
+ this.activity = void 0;
1700
+ }
1701
+ clearActivityTimer() {
1702
+ if (this.activityTimer !== void 0) clearTimeout(this.activityTimer);
1703
+ this.activityTimer = void 0;
1704
+ }
1705
+ /** Max-wait throttle: the first signal opens a window; later signals wait for it; one refresh runs when it closes. */
1706
+ scheduleActivityRefresh(lifecycleGeneration) {
1707
+ if (this.activityRefreshWindowMs === 0) return this.queueRefresh();
1708
+ if (this.activityTimer !== void 0) return;
1709
+ const timer = setTimeout(() => {
1710
+ this.activityTimer = void 0;
1711
+ if (this.alive() && lifecycleGeneration === this.lifecycleGeneration) this.queueRefresh();
1712
+ }, this.activityRefreshWindowMs);
1713
+ timer.unref?.();
1714
+ this.activityTimer = timer;
1715
+ }
1504
1716
  fail(cause, generation) {
1505
1717
  if (!this.alive(generation)) return;
1506
- const status = typeof cause === "object" && cause !== null && "status" in cause ? cause.status : void 0;
1718
+ const status = statusOf(cause);
1507
1719
  if (status === 401 || status === 403 || status === 404) {
1508
1720
  this.source = [];
1721
+ this.entries = [];
1509
1722
  this.offset = 0;
1510
- this.patch({ conversations: [], hasMore: false });
1723
+ this.cursor = null;
1724
+ this.patch({ conversations: [], summaries: /* @__PURE__ */ new Map(), hasMore: false });
1511
1725
  }
1512
1726
  this.patch({ error: cause });
1513
1727
  }
1728
+ /** Run an operation in inbox mode, falling back to the legacy path for the rest of this store's life when the
1729
+ * inbox route is absent (404: rollback, staging). Loaded rows are kept and the same operation continues.
1730
+ */
1731
+ async withFallback(generation, inbox, legacy) {
1732
+ if (!this.inboxMode) return legacy();
1733
+ try {
1734
+ await inbox();
1735
+ } catch (cause) {
1736
+ if (!this.alive(generation) || statusOf(cause) !== 404) throw cause;
1737
+ this.inboxUnavailable = true;
1738
+ this.clearActivityTimer();
1739
+ this.stopActivity();
1740
+ this.entries = [];
1741
+ this.cursor = null;
1742
+ this.offset = this.source.length;
1743
+ if (!this.inboxWarned) {
1744
+ this.inboxWarned = true;
1745
+ console.warn("ConvoKit inbox endpoint unavailable (404); using getConversations without previews or unread counts.");
1746
+ }
1747
+ this.patch({ summaries: /* @__PURE__ */ new Map(), currentUserId: "" });
1748
+ await legacy();
1749
+ }
1750
+ }
1751
+ validateInboxPage(page, limit, requestedCursor) {
1752
+ const seen = /* @__PURE__ */ new Set();
1753
+ for (const entry of page.entries) {
1754
+ const id = entry.conversation.id;
1755
+ if (!id.trim() || seen.has(id)) throw new Error("Invalid conversation page");
1756
+ seen.add(id);
1757
+ }
1758
+ if (page.entries.length > limit) throw new Error("Invalid conversation page");
1759
+ if (page.nextCursor !== null && (page.nextCursor === requestedCursor || page.entries.length === 0)) {
1760
+ throw new Error("Inbox pagination did not advance");
1761
+ }
1762
+ }
1763
+ /** Swap rows, summaries, cursor and hasMore together, filtered by the filter current at commit time. */
1764
+ commitInbox(entries, cursor) {
1765
+ this.entries = entries;
1766
+ this.source = ids(entries);
1767
+ this.cursor = cursor;
1768
+ this.patch({
1769
+ conversations: applyConversationFilter(this.source, this.state.filter),
1770
+ summaries: new Map(entries.map((entry) => [entry.conversation.id, summaryOf(entry)])),
1771
+ hasMore: cursor !== null
1772
+ });
1773
+ }
1774
+ async loadInboxUntilVisible(generation, filter) {
1775
+ const visibleBefore = applyConversationFilter(this.source, filter).length;
1776
+ while (this.alive(generation)) {
1777
+ const requested = this.cursor;
1778
+ const page = await this.options.client.listInbox({ limit: this.pageSize, cursor: requested, archived: filter.archived ?? false });
1779
+ if (!this.alive(generation)) return;
1780
+ this.validateInboxPage(page, this.pageSize, requested);
1781
+ this.commitInbox(mergeInboxEntries(this.entries, page.entries), page.nextCursor);
1782
+ if (page.nextCursor === null || this.state.conversations.length > visibleBefore) return;
1783
+ }
1784
+ }
1514
1785
  async loadUntilVisible(generation, filter) {
1515
1786
  const visibleBefore = applyConversationFilter(this.source, filter).length;
1516
1787
  while (this.alive(generation)) {
@@ -1537,10 +1808,17 @@ var ConversationListStore = class {
1537
1808
  const generation = ++this.generation;
1538
1809
  this.refreshing = false;
1539
1810
  this.source = [];
1811
+ this.entries = [];
1540
1812
  this.offset = 0;
1541
- this.patch({ ...blank2(this.state.filter), isInitialLoading: true });
1813
+ this.cursor = null;
1814
+ this.patch({ ...blank2(this.state.filter), currentUserId: this.currentUserId(), isInitialLoading: true });
1815
+ const filter = this.state.filter;
1542
1816
  try {
1543
- await this.loadUntilVisible(generation, this.state.filter);
1817
+ await this.withFallback(
1818
+ generation,
1819
+ () => this.loadInboxUntilVisible(generation, filter),
1820
+ () => this.loadUntilVisible(generation, filter)
1821
+ );
1544
1822
  } catch (cause) {
1545
1823
  this.fail(cause, generation);
1546
1824
  } finally {
@@ -1562,6 +1840,47 @@ var ConversationListStore = class {
1562
1840
  void this.refresh();
1563
1841
  });
1564
1842
  }
1843
+ /** Re-walk the inbox from the head until the loaded window is covered and something is visible, or the inbox
1844
+ * ends. Rooms that moved are re-positioned by the merge; an exhausted inbox publishes what it found.
1845
+ */
1846
+ async refreshInbox(generation, filter) {
1847
+ const target = Math.max(this.pageSize, this.entries.length);
1848
+ let rows = [], consumed = 0, cursor = null;
1849
+ while (this.alive(generation)) {
1850
+ const remaining = target - consumed;
1851
+ const limit = remaining >= 1 ? Math.min(100, remaining) : this.pageSize;
1852
+ const page = await this.options.client.listInbox({ limit, cursor, archived: filter.archived ?? false });
1853
+ if (!this.alive(generation)) return;
1854
+ this.validateInboxPage(page, limit, cursor);
1855
+ rows = mergeInboxEntries(rows, page.entries);
1856
+ consumed += page.entries.length;
1857
+ cursor = page.nextCursor;
1858
+ if (cursor === null || consumed >= target && applyConversationFilter(ids(rows), this.state.filter).length > 0) break;
1859
+ }
1860
+ if (!this.alive(generation)) return;
1861
+ this.commitInbox(rows, cursor);
1862
+ }
1863
+ async refreshLegacy(generation, filter) {
1864
+ const target = Math.max(this.pageSize, this.offset);
1865
+ const compare2 = (a, b) => a.createdAt.getTime() - b.createdAt.getTime() || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
1866
+ const boundary = this.options.pageLoader ? void 0 : this.source.reduce((oldest, row) => !oldest || compare2(row, oldest) < 0 ? row : oldest, void 0);
1867
+ let rows = [], offset = 0, hasMore = true;
1868
+ while (this.alive(generation)) {
1869
+ const page = this.options.pageLoader ? await this.options.pageLoader({ limit: this.pageSize, offset, filter }) : await this.options.client.getConversations({ limit: this.pageSize, offset, archived: filter.archived ?? false });
1870
+ if (!this.alive(generation)) return;
1871
+ if (page.length > this.pageSize || page.some((row) => !row.id.trim())) throw new Error("Invalid conversation page");
1872
+ const merged = mergeConversations(rows, page);
1873
+ if (page.length === this.pageSize && merged.length === rows.length) throw new Error("Conversation pagination did not advance");
1874
+ rows = merged;
1875
+ offset += page.length;
1876
+ hasMore = page.length === this.pageSize;
1877
+ if (!hasMore || offset >= target && applyConversationFilter(rows, this.state.filter).length > 0 && (!boundary || page.some((row) => compare2(row, boundary) <= 0))) break;
1878
+ }
1879
+ if (!this.alive(generation)) return;
1880
+ this.source = rows;
1881
+ this.offset = offset;
1882
+ this.patch({ conversations: applyConversationFilter(rows, this.state.filter), hasMore });
1883
+ }
1565
1884
  /** Replace the loaded window atomically, retaining filters and rows during transient failures. */
1566
1885
  refresh = async () => {
1567
1886
  if (!this.alive()) return;
@@ -1572,28 +1891,14 @@ var ConversationListStore = class {
1572
1891
  if (!this.state.hasLoaded) return this.loadInitial();
1573
1892
  const generation = this.generation;
1574
1893
  const filter = this.state.filter;
1575
- const target = Math.max(this.pageSize, this.offset);
1576
- const compare2 = (a, b) => a.createdAt.getTime() - b.createdAt.getTime() || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
1577
- const boundary = this.options.pageLoader ? void 0 : this.source.reduce((oldest, row) => !oldest || compare2(row, oldest) < 0 ? row : oldest, void 0);
1578
1894
  this.refreshing = true;
1579
1895
  this.patch({ error: null });
1580
1896
  try {
1581
- let rows = [], offset = 0, hasMore = true;
1582
- while (this.alive(generation)) {
1583
- const page = this.options.pageLoader ? await this.options.pageLoader({ limit: this.pageSize, offset, filter }) : await this.options.client.getConversations({ limit: this.pageSize, offset, archived: filter.archived ?? false });
1584
- if (!this.alive(generation)) return;
1585
- if (page.length > this.pageSize || page.some((row) => !row.id.trim())) throw new Error("Invalid conversation page");
1586
- const merged = mergeConversations(rows, page);
1587
- if (page.length === this.pageSize && merged.length === rows.length) throw new Error("Conversation pagination did not advance");
1588
- rows = merged;
1589
- offset += page.length;
1590
- hasMore = page.length === this.pageSize;
1591
- if (!hasMore || offset >= target && applyConversationFilter(rows, this.state.filter).length > 0 && (!boundary || page.some((row) => compare2(row, boundary) <= 0))) break;
1592
- }
1593
- if (!this.alive(generation)) return;
1594
- this.source = rows;
1595
- this.offset = offset;
1596
- this.patch({ conversations: applyConversationFilter(rows, this.state.filter), hasMore });
1897
+ await this.withFallback(
1898
+ generation,
1899
+ () => this.refreshInbox(generation, filter),
1900
+ () => this.refreshLegacy(generation, filter)
1901
+ );
1597
1902
  } catch (cause) {
1598
1903
  this.fail(cause, generation);
1599
1904
  } finally {
@@ -1606,9 +1911,14 @@ var ConversationListStore = class {
1606
1911
  loadMore = async () => {
1607
1912
  if (!this.alive() || this.refreshing || this.state.isInitialLoading || this.state.isLoadingMore || !this.state.hasMore) return;
1608
1913
  const generation = this.generation;
1914
+ const filter = this.state.filter;
1609
1915
  this.patch({ isLoadingMore: true, error: null });
1610
1916
  try {
1611
- await this.loadUntilVisible(generation, this.state.filter);
1917
+ await this.withFallback(
1918
+ generation,
1919
+ () => this.loadInboxUntilVisible(generation, filter),
1920
+ () => this.loadUntilVisible(generation, filter)
1921
+ );
1612
1922
  } catch (cause) {
1613
1923
  this.fail(cause, generation);
1614
1924
  } finally {
@@ -1660,6 +1970,8 @@ function useConversationList(options) {
1660
1970
  });
1661
1971
  return {
1662
1972
  conversations: field("conversations"),
1973
+ summaries: field("summaries"),
1974
+ currentUserId: field("currentUserId"),
1663
1975
  filter: field("filter"),
1664
1976
  isInitialLoading: field("isInitialLoading"),
1665
1977
  isLoadingMore: field("isLoadingMore"),
@@ -1685,6 +1997,8 @@ var appearanceProps3 = {
1685
1997
  var listViewProps = {
1686
1998
  ...appearanceProps3,
1687
1999
  conversations: { type: Array, required: true },
2000
+ summaries: { type: Object, default: void 0 },
2001
+ currentUserId: { type: String, default: void 0 },
1688
2002
  selectedConversationId: { type: String, default: void 0 },
1689
2003
  onConversationSelect: { type: Function, default: void 0 },
1690
2004
  onRefresh: { type: Function, default: void 0 },
@@ -1734,6 +2048,25 @@ var ConversationListView = (0, import_vue10.defineComponent)({
1734
2048
  const refresh = () => {
1735
2049
  return props.onRefresh?.();
1736
2050
  };
2051
+ const inlineRetry = () => {
2052
+ if (props.hasMore && props.onLoadMore) return () => {
2053
+ lastRequestedLength = null;
2054
+ void requestMore();
2055
+ };
2056
+ if (props.onRefresh) return () => {
2057
+ void refresh();
2058
+ };
2059
+ return void 0;
2060
+ };
2061
+ const unreadBadge = (summary) => {
2062
+ if (summary.unreadCount <= 0 && !summary.unreadCountCapped) return null;
2063
+ const capped = summary.unreadCountCapped || summary.unreadCount > 99;
2064
+ return (0, import_vue10.h)("span", {
2065
+ class: "ckui-unread-badge",
2066
+ role: "img",
2067
+ "aria-label": `${summary.unreadCountCapped ? "99+" : summary.unreadCount} unread`
2068
+ }, [(0, import_vue10.h)("span", { "aria-hidden": "true" }, capped ? "99+" : String(summary.unreadCount))]);
2069
+ };
1737
2070
  const renderContent = () => {
1738
2071
  const currentAppearance = appearance();
1739
2072
  if (props.isInitialLoading && props.conversations.length === 0) {
@@ -1765,10 +2098,21 @@ var ConversationListView = (0, import_vue10.defineComponent)({
1765
2098
  const children = props.conversations.flatMap((conversation, index) => {
1766
2099
  const selected = props.selectedConversationId === conversation.id;
1767
2100
  const select = () => selectConversation(conversation);
1768
- const slotProps = { conversation, index, selected, select };
2101
+ const summary = props.summaries?.get(conversation.id);
2102
+ const slotProps = {
2103
+ conversation,
2104
+ index,
2105
+ selected,
2106
+ select,
2107
+ ...summary ? { summary } : {},
2108
+ ...props.currentUserId === void 0 ? {} : { currentUserId: props.currentUserId }
2109
+ };
2110
+ const preview = inboxPreview(conversation, summary, props.currentUserId);
2111
+ const unread = summary !== void 0 && (summary.unreadCount > 0 || summary.unreadCountCapped);
1769
2112
  const item = slots["conversation-item"]?.(slotProps) ?? (0, import_vue10.h)("button", {
1770
2113
  type: "button",
1771
2114
  "data-selected": selected || void 0,
2115
+ "data-unread": unread || void 0,
1772
2116
  "aria-current": selected ? "true" : void 0,
1773
2117
  onClick: select,
1774
2118
  class: partClass("listItem", currentAppearance, "ckui-conversation-item"),
@@ -1782,8 +2126,14 @@ var ConversationListView = (0, import_vue10.defineComponent)({
1782
2126
  }),
1783
2127
  (0, import_vue10.h)("span", { class: "ckui-conversation-item__body" }, [
1784
2128
  (0, import_vue10.h)("strong", conversation.displayTitle),
1785
- (0, import_vue10.h)("span", conversation.participants.map((participant) => participant.name).join(", ") || conversation.description || "No participants")
2129
+ (0, import_vue10.h)("span", preview || conversation.participants.map((participant) => participant.name).join(", ") || conversation.description || "No participants")
1786
2130
  ]),
2131
+ // Spread rather than emit `null`: a null child renders a `<!---->` comment, and rows without a
2132
+ // summary must keep 0.5's exact markup.
2133
+ ...summary ? [(0, import_vue10.h)("span", { class: "ckui-conversation-item__meta" }, [
2134
+ (0, import_vue10.h)("time", { class: "ckui-conversation-item__time", datetime: summary.activityAt.toISOString() }, formatMessageTime(summary.activityAt)),
2135
+ unreadBadge(summary)
2136
+ ])] : [],
1787
2137
  (0, import_vue10.h)(import_vue9.ChevronRight, { size: 18, "aria-hidden": "true" })
1788
2138
  ]);
1789
2139
  const nodes = [(0, import_vue10.h)("div", { key: conversation.id, role: "listitem" }, [item])];
@@ -1793,13 +2143,15 @@ var ConversationListView = (0, import_vue10.defineComponent)({
1793
2143
  return nodes;
1794
2144
  });
1795
2145
  if (props.error) {
1796
- children.push(slots.error?.({ error: props.error, retry: requestMore }) ?? (0, import_vue10.h)("div", {
2146
+ const retry = inlineRetry();
2147
+ children.push(slots.error?.({ error: props.error, ...retry ? { retry } : {} }) ?? (0, import_vue10.h)("div", {
1797
2148
  class: partClass("error", currentAppearance, "ckui-inline-state ckui-state--error"),
1798
2149
  style: partStyle("error", currentAppearance),
1799
2150
  role: "alert"
1800
- }, [(0, import_vue10.h)("span", errorMessage(props.error)), (0, import_vue10.h)("button", { type: "button", class: "ckui-link-button", onClick: () => {
1801
- void requestMore();
1802
- } }, "Retry")]));
2151
+ }, [
2152
+ (0, import_vue10.h)("span", errorMessage(props.error)),
2153
+ ...retry ? [(0, import_vue10.h)("button", { type: "button", class: "ckui-link-button", onClick: retry }, "Retry")] : []
2154
+ ]));
1803
2155
  } else if (props.isLoadingMore) {
1804
2156
  children.push(slots["load-more"]?.() ?? (0, import_vue10.h)("div", {
1805
2157
  class: partClass("loading", currentAppearance, "ckui-inline-state"),
@@ -1860,6 +2212,7 @@ var ConversationList = (0, import_vue10.defineComponent)({
1860
2212
  initialFilter: { type: Object, default: void 0 },
1861
2213
  pageSize: { type: Number, default: 30 },
1862
2214
  autoLoad: { type: Boolean, default: true },
2215
+ activityRefreshWindowMs: { type: Number, default: void 0 },
1863
2216
  onControllerChange: { type: Function, default: void 0 }
1864
2217
  },
1865
2218
  emits: ["conversation-select", "controller-change"],
@@ -1869,7 +2222,8 @@ var ConversationList = (0, import_vue10.defineComponent)({
1869
2222
  ...props.pageLoader ? { pageLoader: props.pageLoader } : {},
1870
2223
  ...props.initialFilter ? { initialFilter: props.initialFilter } : {},
1871
2224
  pageSize: props.pageSize,
1872
- autoLoad: props.autoLoad
2225
+ autoLoad: props.autoLoad,
2226
+ ...props.activityRefreshWindowMs === void 0 ? {} : { activityRefreshWindowMs: props.activityRefreshWindowMs }
1873
2227
  });
1874
2228
  expose({ controller });
1875
2229
  (0, import_vue10.watchEffect)(() => {
@@ -1882,8 +2236,11 @@ var ConversationList = (0, import_vue10.defineComponent)({
1882
2236
  initialFilter: _initialFilter,
1883
2237
  pageSize: _pageSize,
1884
2238
  autoLoad: _autoLoad,
2239
+ activityRefreshWindowMs: _activityRefreshWindowMs,
1885
2240
  onControllerChange: _onControllerChange,
1886
2241
  conversations: _conversations,
2242
+ summaries: _summaries,
2243
+ currentUserId: _currentUserId,
1887
2244
  onRefresh: _onRefresh,
1888
2245
  onLoadMore: _onLoadMore,
1889
2246
  isInitialLoading: _isInitialLoading,
@@ -1896,6 +2253,8 @@ var ConversationList = (0, import_vue10.defineComponent)({
1896
2253
  ...attrs,
1897
2254
  ...forwarded,
1898
2255
  conversations: controller.conversations.value,
2256
+ summaries: controller.summaries.value,
2257
+ currentUserId: controller.currentUserId.value,
1899
2258
  onRefresh: controller.refresh,
1900
2259
  onLoadMore: controller.loadMore,
1901
2260
  isInitialLoading: controller.isInitialLoading.value,
@@ -1923,6 +2282,7 @@ var defaultConvoKitTheme = {
1923
2282
  incomingBubble: "#f4f4f5",
1924
2283
  outgoingBubble: "#18181b",
1925
2284
  outgoingText: "#fafafa",
2285
+ badge: "#18181b",
1926
2286
  radius: "10px",
1927
2287
  avatarSize: "40px",
1928
2288
  fontFamily: 'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'
@@ -1954,6 +2314,7 @@ var ConvoKitThemeProvider = (0, import_vue11.defineComponent)({
1954
2314
  "--ckui-incoming": theme.incomingBubble,
1955
2315
  "--ckui-outgoing": theme.outgoingBubble,
1956
2316
  "--ckui-outgoing-text": theme.outgoingText,
2317
+ "--ckui-badge": theme.badge,
1957
2318
  "--ckui-radius": theme.radius,
1958
2319
  "--ckui-avatar-size": theme.avatarSize,
1959
2320
  "--ckui-font": theme.fontFamily
@@ -1986,6 +2347,7 @@ function useConvoKitTheme() {
1986
2347
  isConvoKitPendingMessage,
1987
2348
  matchesConversation,
1988
2349
  mergeConversations,
2350
+ mergeInboxEntries,
1989
2351
  mergeMessages,
1990
2352
  readerIdsFor,
1991
2353
  useConversation,