@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.js CHANGED
@@ -9,6 +9,10 @@ function createConvoKitUiClient(client) {
9
9
  onEvent: handler,
10
10
  ...onError ? { onError } : {}
11
11
  }),
12
+ onInboxActivity: (handler, onError) => client.realtime.onInboxActivity(client.clientId, {
13
+ onEvent: handler,
14
+ ...onError ? { onError } : {}
15
+ }),
12
16
  onMessageDeleted: (conversationId, handler, onError) => client.realtime.onMessageDeleted(conversationId, {
13
17
  onEvent: handler,
14
18
  ...onError ? { onError } : {}
@@ -17,11 +21,12 @@ function createConvoKitUiClient(client) {
17
21
  return client.connected ? client.currentUserId : "";
18
22
  },
19
23
  getConversations: (options) => client.getConversations(options),
24
+ listInbox: (options) => client.listInbox(options),
20
25
  getConversation: (conversationId) => client.getConversation(conversationId),
21
26
  getMessages: (options) => client.getMessages(options),
22
27
  getMessage: (id) => client.getMessage(id),
23
28
  sendMessage: (input) => client.sendMessage(input),
24
- markConversationRead: (conversationId) => client.markConversationRead(conversationId),
29
+ markConversationRead: (conversationId, options) => client.markConversationRead(conversationId, options),
25
30
  sendTyping: (input) => client.sendTyping(input),
26
31
  onMessage: (conversationId, handler, onError) => client.realtime.onMessage(conversationId, {
27
32
  onEvent: handler,
@@ -43,9 +48,13 @@ import { AvatarFallback, AvatarImage, AvatarRoot } from "reka-ui";
43
48
  import { defineComponent, h } from "vue";
44
49
 
45
50
  // src/utils.ts
51
+ import { readThrough } from "@convokitapp/sdk";
46
52
  import { clsx } from "clsx";
47
53
  import { normalizeClass } from "vue";
48
54
  var pendingMessageIdPrefix = "convokit-pending-";
55
+ function compareMessageOrder(left, right) {
56
+ return left.createdAt.getTime() - right.createdAt.getTime() || (left.id < right.id ? -1 : left.id > right.id ? 1 : 0);
57
+ }
49
58
  function isConvoKitPendingMessage(message) {
50
59
  return message.id.startsWith(pendingMessageIdPrefix);
51
60
  }
@@ -87,6 +96,31 @@ function mergeConversations(current, incoming) {
87
96
  for (const conversation of incoming) byId.set(conversation.id, conversation);
88
97
  return [...byId.values()];
89
98
  }
99
+ function compareInboxOrder(left, right) {
100
+ return right.activityAt.getTime() - left.activityAt.getTime() || (left.conversation.id < right.conversation.id ? 1 : left.conversation.id > right.conversation.id ? -1 : 0);
101
+ }
102
+ function mergeInboxEntries(current, incoming) {
103
+ const byId = new Map(current.map((entry) => [entry.conversation.id, entry]));
104
+ for (const entry of incoming) byId.set(entry.conversation.id, entry);
105
+ return [...byId.values()].sort(compareInboxOrder);
106
+ }
107
+ function inboxPreview(conversation, summary, currentUserId) {
108
+ const message = summary?.latestMessage;
109
+ if (!message) return "";
110
+ const first = message.media[0];
111
+ 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" : "");
112
+ if (!body) return "";
113
+ if (currentUserId !== void 0 && message.senderId === currentUserId) return `You: ${body}`;
114
+ if (conversation.participants.length > 2) {
115
+ const sender = conversation.participants.find((participant) => participant.appUserId === message.senderId || participant.id === message.senderId);
116
+ const name = sender?.name.trim();
117
+ if (name) return `${name}: ${body}`;
118
+ }
119
+ return body;
120
+ }
121
+ function formatMessageTime(date) {
122
+ return new Intl.DateTimeFormat(void 0, { hour: "numeric", minute: "2-digit" }).format(date);
123
+ }
90
124
  function mergeMessages(current, incoming) {
91
125
  const byId = new Map(current.map((message) => [message.id, message]));
92
126
  for (const message of incoming) byId.set(message.id, message);
@@ -94,13 +128,16 @@ function mergeMessages(current, incoming) {
94
128
  const leftPending = isConvoKitPendingMessage(left);
95
129
  const rightPending = isConvoKitPendingMessage(right);
96
130
  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;
131
+ return compareMessageOrder(left, right);
99
132
  });
100
133
  }
101
- function readerIdsFor(message, readAtByUserId) {
134
+ function readerIdsFor(message, readAtByUserId, readPositionByUserId = /* @__PURE__ */ new Map()) {
102
135
  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));
136
+ const userIds = /* @__PURE__ */ new Set([...readAtByUserId.keys(), ...readPositionByUserId.keys()]);
137
+ return new Set([...userIds].filter((userId) => userId !== message.senderId && readThrough({
138
+ readPosition: readPositionByUserId.get(userId) ?? null,
139
+ lastReadAt: readAtByUserId.get(userId) ?? null
140
+ }, message)));
104
141
  }
105
142
  function partClass(part, appearance, defaultClass) {
106
143
  return cx(!appearance.unstyled && defaultClass, appearance.classNames?.[part]);
@@ -151,6 +188,7 @@ import { ArrowLeft, LoaderCircle as LoaderCircle2, Paperclip, RefreshCw, Send }
151
188
  import {
152
189
  defineComponent as defineComponent3,
153
190
  h as h3,
191
+ onBeforeUnmount,
154
192
  ref as ref2,
155
193
  watchEffect
156
194
  } from "vue";
@@ -169,8 +207,20 @@ function hasContent(message) {
169
207
  function newest(current, incoming, incomingComplete = true) {
170
208
  return version(current) > version(incoming) || !incomingComplete && version(current) === version(incoming) ? current : incoming;
171
209
  }
172
- function compare(left, right) {
173
- return left.createdAt.getTime() - right.createdAt.getTime() || (left.id < right.id ? -1 : left.id > right.id ? 1 : 0);
210
+ var compare = compareMessageOrder;
211
+ function positionCursor(position) {
212
+ return { createdAt: position.createdAt, id: position.messageId };
213
+ }
214
+ function readEntry(participant) {
215
+ return { userId: participant.appUserId, readAt: participant.lastReadAt, readPosition: participant.readPosition };
216
+ }
217
+ function acknowledgement() {
218
+ return { inFlight: void 0, followUp: false, suppressed: false, target: void 0, acknowledged: void 0, unacknowledgeable: /* @__PURE__ */ new Set() };
219
+ }
220
+ function isTargetMiss(cause) {
221
+ if (typeof cause !== "object" || cause === null) return false;
222
+ const { code, status } = cause;
223
+ return code === "MESSAGE_NOT_FOUND" || code === void 0 && status === 404;
174
224
  }
175
225
  function blank(currentUserId = "") {
176
226
  return {
@@ -178,6 +228,7 @@ function blank(currentUserId = "") {
178
228
  messages: [],
179
229
  typingUserIds: /* @__PURE__ */ new Set(),
180
230
  readAtByUserId: /* @__PURE__ */ new Map(),
231
+ readPositionByUserId: /* @__PURE__ */ new Map(),
181
232
  isInitialLoading: false,
182
233
  isLoadingOlder: false,
183
234
  isReconciling: false,
@@ -225,6 +276,9 @@ var ConversationStore = class {
225
276
  hydrationPool = { running: /* @__PURE__ */ new Set(), queued: /* @__PURE__ */ new Map() };
226
277
  // Keep tombstones until an explicit reload/session change, including across refreshes.
227
278
  deleted = /* @__PURE__ */ new Set();
279
+ ack = acknowledgement();
280
+ // Visible until the platform reports otherwise; unknown/prerender/no document count as visible.
281
+ visible = true;
228
282
  sendRevision;
229
283
  activeSend;
230
284
  refreshQueued = false;
@@ -287,6 +341,7 @@ var ConversationStore = class {
287
341
  this.hydrations.clear();
288
342
  this.hydrationPool.queued.clear();
289
343
  this.deleted.clear();
344
+ this.ack = acknowledgement();
290
345
  this.sendRevision = void 0;
291
346
  this.activeSend = void 0;
292
347
  this.refreshQueued = false;
@@ -342,8 +397,8 @@ var ConversationStore = class {
342
397
  if (!this.alive(generation) || conversationId !== this.room || !id.trim()) return;
343
398
  this.removeMessage(id);
344
399
  }, report));
345
- add(() => this.client.onReadReceipt(this.room, ({ userId, readAt }) => {
346
- if (this.alive(generation)) this.mergeReads([[userId, readAt]]);
400
+ add(() => this.client.onReadReceipt(this.room, ({ userId, readAt, readPosition }) => {
401
+ if (this.alive(generation)) this.mergeReads([{ userId, readAt, readPosition }]);
347
402
  }, report));
348
403
  add(() => this.client.onTyping(this.room, ({ userId, isTyping }) => {
349
404
  if (!this.alive(generation) || !userId.trim() || userId === this.user) return;
@@ -386,9 +441,6 @@ var ConversationStore = class {
386
441
  this.hydrations.set(message.id, job);
387
442
  this.hydrationPool.queued.set(message.id, job);
388
443
  this.drainHydration();
389
- if (type === "insert" && !existing && message.senderId !== this.user && (this.options.markReadOnReceive ?? true)) {
390
- void this.markRead();
391
- }
392
444
  }
393
445
  record(message, insert, revision, complete) {
394
446
  const existing = this.state.messages.find((item) => item.id === message.id);
@@ -398,7 +450,9 @@ var ConversationStore = class {
398
450
  this.confirmSend(message);
399
451
  }
400
452
  this.changes.set(message.id, { revision, message, insert, complete });
401
- if (existing || insert && hasContent(message)) this.patch({ messages: mergeMessages(this.state.messages, [message]) });
453
+ if (!existing && !(insert && hasContent(message))) return;
454
+ this.patch({ messages: mergeMessages(this.state.messages, [message]) });
455
+ if (!existing && message.senderId !== this.user && (this.options.markReadOnReceive ?? true)) void this.acknowledge(true);
402
456
  }
403
457
  confirmSend(message) {
404
458
  const send = this.activeSend;
@@ -410,11 +464,19 @@ var ConversationStore = class {
410
464
  return this.alive(job.generation) && !this.deleted.has(job.message.id) && this.hydrations.get(job.message.id) === job;
411
465
  }
412
466
  removeMessage(id) {
467
+ this.forget(id);
468
+ this.patch({ messages: this.state.messages.filter((message) => message.id !== id) });
469
+ }
470
+ /** Tombstone a row learned to be gone; a removed acknowledgement target is re-resolved from what remains. */
471
+ forget(id) {
413
472
  this.deleted.add(id);
414
473
  this.changes.delete(id);
415
474
  this.hydrations.delete(id);
416
475
  this.hydrationPool.queued.delete(id);
417
- this.patch({ messages: this.state.messages.filter((message) => message.id !== id) });
476
+ const ack = this.ack;
477
+ if (ack.target !== id && ack.acknowledged?.id !== id) return;
478
+ ack.unacknowledgeable.add(id);
479
+ if (ack.target === id) ack.followUp = true;
418
480
  }
419
481
  drainHydration() {
420
482
  const pool = this.hydrationPool;
@@ -451,13 +513,23 @@ var ConversationStore = class {
451
513
  });
452
514
  }
453
515
  }
516
+ /** Both maps only ever advance: acknowledgement times by time, positions by (createdAt, id). Participants and
517
+ * read events are the only sources; the local user's own read is never written from the device clock.
518
+ */
454
519
  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);
520
+ const readAt = new Map(this.state.readAtByUserId);
521
+ const positions = new Map(this.state.readPositionByUserId);
522
+ for (const entry of entries) {
523
+ const { userId, readPosition } = entry;
524
+ if (!userId.trim()) continue;
525
+ if (entry.readAt && Number.isFinite(entry.readAt.getTime()) && entry.readAt.getTime() > (readAt.get(userId)?.getTime() ?? -Infinity)) {
526
+ readAt.set(userId, entry.readAt);
527
+ }
528
+ if (!readPosition || typeof readPosition.messageId !== "string" || !readPosition.messageId.trim() || !(readPosition.createdAt instanceof Date) || !Number.isFinite(readPosition.createdAt.getTime())) continue;
529
+ const current = positions.get(userId);
530
+ if (!current || compare(positionCursor(readPosition), positionCursor(current)) > 0) positions.set(userId, readPosition);
459
531
  }
460
- this.patch({ readAtByUserId: next });
532
+ this.patch({ readAtByUserId: readAt, readPositionByUserId: positions });
461
533
  }
462
534
  validatePage(page, before) {
463
535
  if (page.length > this.pageSize) throw new Error("Message page exceeds the requested limit");
@@ -514,9 +586,9 @@ var ConversationStore = class {
514
586
  this.validatePage(page);
515
587
  this.cursor = page.at(-1);
516
588
  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]] : []));
589
+ this.mergeReads(conversation.participants.map(readEntry));
518
590
  this.prune(revision);
519
- if (this.options.markReadOnLoad ?? true) await this.markRead();
591
+ if (this.options.markReadOnLoad ?? true) await this.acknowledge(true);
520
592
  } catch (cause) {
521
593
  this.fail(cause, generation, true);
522
594
  } finally {
@@ -572,14 +644,9 @@ var ConversationStore = class {
572
644
  const reconciled = this.overlay(rows, revision);
573
645
  const survivingIds = new Set(reconciled.map((message) => message.id));
574
646
  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
- }
647
+ for (const id of known) if (!survivingIds.has(id)) this.forget(id);
581
648
  this.patch({ conversation, messages: reconciled, hasOlderMessages: hasOlder });
582
- this.mergeReads(conversation.participants.flatMap((participant) => participant.lastReadAt ? [[participant.appUserId, participant.lastReadAt]] : []));
649
+ this.mergeReads(conversation.participants.map(readEntry));
583
650
  this.prune(revision);
584
651
  } catch (cause) {
585
652
  this.fail(cause, generation, true);
@@ -614,15 +681,68 @@ var ConversationStore = class {
614
681
  }
615
682
  }
616
683
  };
617
- markRead = async () => {
618
- if (!this.alive()) return;
619
- const generation = this.generation;
684
+ /** Acknowledge through the newest rendered row now, regardless of visibility; no request without a target. */
685
+ markRead = () => this.alive() ? this.acknowledge(false) : Promise.resolve();
686
+ /** Automatic acknowledgements wait while hidden and are re-issued (once) on becoming visible. */
687
+ setVisible = (visible) => {
688
+ this.visible = visible;
689
+ if (!visible || !this.ack.suppressed || !this.alive()) return;
690
+ this.ack.suppressed = false;
691
+ void this.acknowledge(true);
692
+ };
693
+ /** The newest non-pending rendered row by (createdAt, id), never by list index and never a raw realtime
694
+ * row; rows the server does not know are skipped, and nothing at or before the accepted target is re-sent.
695
+ */
696
+ ackTarget(ack) {
697
+ let target;
698
+ for (const message of this.state.messages) {
699
+ if (isConvoKitPendingMessage(message) || ack.unacknowledgeable.has(message.id)) continue;
700
+ if (!target || compare(message, target) > 0) target = { createdAt: message.createdAt, id: message.id };
701
+ }
702
+ return target && (!ack.acknowledged || compare(target, ack.acknowledged) > 0) ? target : void 0;
703
+ }
704
+ /** Resolves when the request this call issued or joined settles; a follow-up is issued, not awaited. */
705
+ acknowledge(automatic) {
706
+ const ack = this.ack;
707
+ if (automatic && !this.visible) {
708
+ ack.suppressed = true;
709
+ return Promise.resolve();
710
+ }
711
+ if (ack.inFlight) {
712
+ ack.followUp = true;
713
+ return ack.inFlight;
714
+ }
715
+ return this.issue(ack, this.generation) ?? Promise.resolve();
716
+ }
717
+ issue(ack, generation) {
718
+ ack.followUp = false;
719
+ const target = this.ackTarget(ack);
720
+ if (!target) return void 0;
721
+ ack.target = target.id;
722
+ ack.inFlight = this.send(ack, generation, target).finally(() => {
723
+ ack.inFlight = void 0;
724
+ ack.target = void 0;
725
+ if (!this.alive(generation) || !ack.followUp) return;
726
+ if (!this.visible) {
727
+ ack.followUp = false;
728
+ ack.suppressed = true;
729
+ } else this.issue(ack, generation);
730
+ });
731
+ return ack.inFlight;
732
+ }
733
+ async send(ack, generation, target) {
620
734
  try {
621
- await this.client.markConversationRead(this.room);
735
+ await this.client.markConversationRead(this.room, { throughMessageId: target.id });
736
+ if (!this.alive(generation)) return;
737
+ if (!ack.acknowledged || compare(target, ack.acknowledged) > 0) ack.acknowledged = target;
622
738
  } catch (cause) {
623
- this.fail(cause, generation);
739
+ if (!this.alive(generation)) return;
740
+ if (isTargetMiss(cause)) {
741
+ ack.unacknowledgeable.add(target.id);
742
+ ack.followUp = true;
743
+ } else this.fail(cause, generation);
624
744
  }
625
- };
745
+ }
626
746
  updateTyping = async (isTyping) => {
627
747
  if (!this.alive()) return;
628
748
  const generation = this.generation;
@@ -705,7 +825,7 @@ var ConversationStore = class {
705
825
  }
706
826
  }
707
827
  };
708
- readerIdsFor = (message) => readerIdsFor(message, this.state.readAtByUserId);
828
+ readerIdsFor = (message) => readerIdsFor(message, this.state.readAtByUserId, this.state.readPositionByUserId);
709
829
  };
710
830
 
711
831
  // src/composables/use-conversation.ts
@@ -718,6 +838,7 @@ function useConversation(options) {
718
838
  let store = createStore();
719
839
  const snapshot = shallowRef(store.getSnapshot());
720
840
  let unsubscribe;
841
+ let visible = true;
721
842
  const stop = watch(
722
843
  () => [toValue(options.client), toValue(options.client).sessionIdentity, toValue(options.conversationId)],
723
844
  () => {
@@ -728,6 +849,7 @@ function useConversation(options) {
728
849
  unsubscribe = store.subscribe(() => {
729
850
  snapshot.value = store.getSnapshot();
730
851
  });
852
+ store.setVisible(visible);
731
853
  store.start(options.autoLoad ?? true);
732
854
  },
733
855
  { immediate: true, flush: "sync" }
@@ -747,6 +869,7 @@ function useConversation(options) {
747
869
  messages: field("messages"),
748
870
  typingUserIds: field("typingUserIds"),
749
871
  readAtByUserId: field("readAtByUserId"),
872
+ readPositionByUserId: field("readPositionByUserId"),
750
873
  isInitialLoading: field("isInitialLoading"),
751
874
  isLoadingOlder: field("isLoadingOlder"),
752
875
  isReconciling: field("isReconciling"),
@@ -762,6 +885,10 @@ function useConversation(options) {
762
885
  sendMessage: (input) => store.sendMessage(input),
763
886
  markRead: () => store.markRead(),
764
887
  updateTyping: (isTyping) => store.updateTyping(isTyping),
888
+ setVisible: (value) => {
889
+ visible = value;
890
+ store.setVisible(value);
891
+ },
765
892
  dispose
766
893
  };
767
894
  }
@@ -792,9 +919,6 @@ var appearanceProps = {
792
919
  density: { type: String, default: "comfortable" },
793
920
  unstyled: { type: Boolean, default: false }
794
921
  };
795
- function defaultFormatTime(date) {
796
- return new Intl.DateTimeFormat(void 0, { hour: "numeric", minute: "2-digit" }).format(date);
797
- }
798
922
  function defaultMedia(media, open, imageLoading) {
799
923
  const tag = open ? "button" : "div";
800
924
  const interactive = open ? { type: "button", onClick: open } : {};
@@ -834,6 +958,7 @@ var MessageListView = defineComponent2({
834
958
  messages: { type: Array, required: true },
835
959
  currentUserId: { type: String, required: true },
836
960
  readAtByUserId: { type: Object, default: () => /* @__PURE__ */ new Map() },
961
+ readPositionByUserId: { type: Object, default: () => /* @__PURE__ */ new Map() },
837
962
  readersResolver: { type: Function, default: void 0 },
838
963
  onLoadOlder: { type: Function, default: void 0 },
839
964
  hasOlderMessages: { type: Boolean, default: false },
@@ -844,7 +969,7 @@ var MessageListView = defineComponent2({
844
969
  paginationThreshold: { type: Number, default: 240 },
845
970
  reverse: { type: Boolean, default: true },
846
971
  stickToBottom: { type: Boolean, default: true },
847
- formatTime: { type: Function, default: defaultFormatTime },
972
+ formatTime: { type: Function, default: formatMessageTime },
848
973
  imageLoading: { type: String, default: "lazy" }
849
974
  },
850
975
  emits: ["load-older", "attachment-click"],
@@ -893,7 +1018,7 @@ var MessageListView = defineComponent2({
893
1018
  const isCurrentUser = message.senderId === props.currentUserId;
894
1019
  const sender = participants.value.get(message.senderId);
895
1020
  const isPending = isConvoKitPendingMessage(message);
896
- const readerIds = isPending ? /* @__PURE__ */ new Set() : props.readersResolver ? props.readersResolver(message) : readerIdsFor(message, props.readAtByUserId);
1021
+ const readerIds = isPending ? /* @__PURE__ */ new Set() : props.readersResolver ? props.readersResolver(message) : readerIdsFor(message, props.readAtByUserId, props.readPositionByUserId);
897
1022
  const slotProps = { message, chronologicalIndex: index, isCurrentUser, sender, readerIds };
898
1023
  const custom = slots.message?.(slotProps);
899
1024
  if (custom) return h2("div", { key: message.id, role: "listitem" }, custom);
@@ -992,8 +1117,8 @@ var MessageListView = defineComponent2({
992
1117
  };
993
1118
  }
994
1119
  });
995
- function defaultReadersResolver(readAtByUserId) {
996
- return (message) => readerIdsFor(message, readAtByUserId);
1120
+ function defaultReadersResolver(readAtByUserId, readPositionByUserId = /* @__PURE__ */ new Map()) {
1121
+ return (message) => readerIdsFor(message, readAtByUserId, readPositionByUserId);
997
1122
  }
998
1123
 
999
1124
  // src/components/conversation.ts
@@ -1011,6 +1136,7 @@ var viewProps = {
1011
1136
  onSendMessage: { type: Function, required: true },
1012
1137
  typingUserIds: { type: Object, default: () => /* @__PURE__ */ new Set() },
1013
1138
  readAtByUserId: { type: Object, default: () => /* @__PURE__ */ new Map() },
1139
+ readPositionByUserId: { type: Object, default: () => /* @__PURE__ */ new Map() },
1014
1140
  readersResolver: { type: Function, default: void 0 },
1015
1141
  onBack: { type: Function, default: void 0 },
1016
1142
  onRefresh: { type: Function, default: void 0 },
@@ -1239,6 +1365,7 @@ var ConversationView = defineComponent3({
1239
1365
  messages: props.messages,
1240
1366
  currentUserId: props.currentUserId,
1241
1367
  readAtByUserId: props.readAtByUserId,
1368
+ readPositionByUserId: props.readPositionByUserId,
1242
1369
  ...props.readersResolver ? { readersResolver: props.readersResolver } : {},
1243
1370
  ...props.onLoadOlder ? { onLoadOlder: loadOlder } : {},
1244
1371
  hasOlderMessages: props.hasOlderMessages,
@@ -1301,6 +1428,12 @@ var Conversation = defineComponent3({
1301
1428
  watchEffect(() => {
1302
1429
  emit("controller-change", controller);
1303
1430
  });
1431
+ if (typeof document !== "undefined") {
1432
+ const syncVisibility = () => controller.setVisible(document.visibilityState !== "hidden");
1433
+ syncVisibility();
1434
+ document.addEventListener("visibilitychange", syncVisibility);
1435
+ onBeforeUnmount(() => document.removeEventListener("visibilitychange", syncVisibility));
1436
+ }
1304
1437
  return () => {
1305
1438
  const loadedConversation = controller.conversation.value;
1306
1439
  if (!loadedConversation) {
@@ -1335,6 +1468,7 @@ var Conversation = defineComponent3({
1335
1468
  onSendMessage: _onSendMessage,
1336
1469
  typingUserIds: _typingUserIds,
1337
1470
  readAtByUserId: _readAtByUserId,
1471
+ readPositionByUserId: _readPositionByUserId,
1338
1472
  onRefresh: _onRefresh,
1339
1473
  onLoadOlder: _onLoadOlder,
1340
1474
  onTypingChange: _onTypingChange,
@@ -1357,6 +1491,7 @@ var Conversation = defineComponent3({
1357
1491
  },
1358
1492
  typingUserIds: controller.typingUserIds.value,
1359
1493
  readAtByUserId: controller.readAtByUserId.value,
1494
+ readPositionByUserId: controller.readPositionByUserId.value,
1360
1495
  onRefresh: controller.refresh,
1361
1496
  onLoadOlder: controller.loadOlderMessages,
1362
1497
  onTypingChange: controller.updateTyping,
@@ -1396,30 +1531,64 @@ import {
1396
1531
  import { computed as computed3, getCurrentScope as getCurrentScope2, onScopeDispose as onScopeDispose2, shallowRef as shallowRef2, toValue as toValue2, watch as watch3 } from "vue";
1397
1532
 
1398
1533
  // src/conversation-list-store.ts
1534
+ var defaultActivityRefreshWindowMs = 500;
1399
1535
  function blank2(filter) {
1400
- return { conversations: [], filter, isInitialLoading: false, isLoadingMore: false, hasMore: true, hasLoaded: false, error: null };
1536
+ return {
1537
+ conversations: [],
1538
+ summaries: /* @__PURE__ */ new Map(),
1539
+ currentUserId: "",
1540
+ filter,
1541
+ isInitialLoading: false,
1542
+ isLoadingMore: false,
1543
+ hasMore: true,
1544
+ hasLoaded: false,
1545
+ error: null
1546
+ };
1547
+ }
1548
+ function statusOf(cause) {
1549
+ return typeof cause === "object" && cause !== null && "status" in cause ? cause.status : void 0;
1550
+ }
1551
+ function summaryOf(entry) {
1552
+ const { conversation: _conversation, ...summary } = entry;
1553
+ return summary;
1554
+ }
1555
+ function ids(entries) {
1556
+ return entries.map((entry) => entry.conversation);
1401
1557
  }
1402
1558
  var ConversationListStore = class {
1403
1559
  constructor(options) {
1404
1560
  this.options = options;
1405
1561
  this.owner = options.client.sessionIdentity;
1562
+ this.user = this.owner ? options.client.currentUserId : "";
1406
1563
  this.pageSize = options.pageSize ?? 30;
1407
1564
  if (!Number.isInteger(this.pageSize) || this.pageSize < 1 || this.pageSize > 100) {
1408
1565
  throw new RangeError("pageSize must be an integer between 1 and 100");
1409
1566
  }
1567
+ this.activityRefreshWindowMs = options.activityRefreshWindowMs ?? defaultActivityRefreshWindowMs;
1568
+ if (!Number.isFinite(this.activityRefreshWindowMs) || this.activityRefreshWindowMs < 0) {
1569
+ throw new RangeError("activityRefreshWindowMs must be a non-negative number");
1570
+ }
1410
1571
  this.state = blank2(options.initialFilter ?? {});
1411
1572
  }
1412
1573
  options;
1413
1574
  owner;
1575
+ user;
1414
1576
  pageSize;
1577
+ activityRefreshWindowMs;
1415
1578
  state;
1416
1579
  source = [];
1580
+ entries = [];
1417
1581
  offset = 0;
1582
+ cursor = null;
1583
+ inboxUnavailable = false;
1584
+ inboxWarned = false;
1418
1585
  generation = 0;
1419
1586
  lifecycleGeneration = 0;
1420
1587
  disposed = true;
1421
1588
  lifecycle;
1422
1589
  inbox;
1590
+ activity;
1591
+ activityTimer;
1423
1592
  refreshQueued = false;
1424
1593
  refreshing = false;
1425
1594
  listeners = /* @__PURE__ */ new Set();
@@ -1437,12 +1606,27 @@ var ConversationListStore = class {
1437
1606
  alive(generation = this.generation) {
1438
1607
  return !this.disposed && generation === this.generation && this.owner !== null && this.options.client.sessionIdentity === this.owner;
1439
1608
  }
1609
+ get inboxMode() {
1610
+ return !this.options.pageLoader && typeof this.options.client.listInbox === "function" && !this.inboxUnavailable;
1611
+ }
1612
+ currentUserId() {
1613
+ return this.inboxMode ? this.user : "";
1614
+ }
1440
1615
  start = (autoLoad = true) => {
1441
1616
  if (!this.owner || this.options.client.sessionIdentity !== this.owner) return;
1442
1617
  if (!this.disposed) return;
1443
1618
  this.disposed = false;
1619
+ this.inboxUnavailable = false;
1444
1620
  const lifecycleGeneration = ++this.lifecycleGeneration;
1621
+ const current = () => this.alive() && lifecycleGeneration === this.lifecycleGeneration;
1622
+ const reconcile = (cause) => {
1623
+ if (current()) {
1624
+ this.patch({ error: cause });
1625
+ this.queueRefresh();
1626
+ }
1627
+ };
1445
1628
  try {
1629
+ this.patch({ currentUserId: this.currentUserId() });
1446
1630
  const subscription = this.options.client.onConnectionEvent({
1447
1631
  onEvent: () => {
1448
1632
  },
@@ -1454,15 +1638,20 @@ var ConversationListStore = class {
1454
1638
  else void subscription.unsubscribe().catch(() => void 0);
1455
1639
  if (!this.alive()) return;
1456
1640
  const inbox = this.options.client.onInboxChanged(() => {
1457
- if (this.alive() && lifecycleGeneration === this.lifecycleGeneration) this.queueRefresh();
1458
- }, (cause) => {
1459
- if (this.alive() && lifecycleGeneration === this.lifecycleGeneration) {
1460
- this.patch({ error: cause });
1461
- this.queueRefresh();
1462
- }
1463
- });
1641
+ if (!current()) return;
1642
+ this.clearActivityTimer();
1643
+ this.queueRefresh();
1644
+ }, reconcile);
1464
1645
  if (this.alive()) this.inbox = inbox;
1465
1646
  else void inbox.unsubscribe().catch(() => void 0);
1647
+ if (!this.alive()) return;
1648
+ if (this.inboxMode && typeof this.options.client.onInboxActivity === "function") {
1649
+ const activity = this.options.client.onInboxActivity(() => {
1650
+ if (current()) this.scheduleActivityRefresh(lifecycleGeneration);
1651
+ }, reconcile);
1652
+ if (this.alive()) this.activity = activity;
1653
+ else void activity.unsubscribe().catch(() => void 0);
1654
+ }
1466
1655
  if (autoLoad) void this.loadInitial();
1467
1656
  } catch (cause) {
1468
1657
  if (this.alive()) this.patch({ error: cause });
@@ -1472,27 +1661,109 @@ var ConversationListStore = class {
1472
1661
  this.disposed = true;
1473
1662
  this.generation++;
1474
1663
  this.lifecycleGeneration++;
1664
+ this.clearActivityTimer();
1475
1665
  const subscription = this.lifecycle;
1476
1666
  this.lifecycle = void 0;
1477
1667
  if (subscription) void subscription.unsubscribe().catch(() => void 0);
1478
1668
  if (this.inbox) void this.inbox.unsubscribe().catch(() => void 0);
1479
1669
  this.inbox = void 0;
1670
+ this.stopActivity();
1480
1671
  this.refreshQueued = false;
1481
1672
  this.refreshing = false;
1482
1673
  this.source = [];
1674
+ this.entries = [];
1483
1675
  this.offset = 0;
1676
+ this.cursor = null;
1484
1677
  this.patch(blank2(this.state.filter));
1485
1678
  };
1679
+ stopActivity() {
1680
+ if (this.activity) void this.activity.unsubscribe().catch(() => void 0);
1681
+ this.activity = void 0;
1682
+ }
1683
+ clearActivityTimer() {
1684
+ if (this.activityTimer !== void 0) clearTimeout(this.activityTimer);
1685
+ this.activityTimer = void 0;
1686
+ }
1687
+ /** Max-wait throttle: the first signal opens a window; later signals wait for it; one refresh runs when it closes. */
1688
+ scheduleActivityRefresh(lifecycleGeneration) {
1689
+ if (this.activityRefreshWindowMs === 0) return this.queueRefresh();
1690
+ if (this.activityTimer !== void 0) return;
1691
+ const timer = setTimeout(() => {
1692
+ this.activityTimer = void 0;
1693
+ if (this.alive() && lifecycleGeneration === this.lifecycleGeneration) this.queueRefresh();
1694
+ }, this.activityRefreshWindowMs);
1695
+ timer.unref?.();
1696
+ this.activityTimer = timer;
1697
+ }
1486
1698
  fail(cause, generation) {
1487
1699
  if (!this.alive(generation)) return;
1488
- const status = typeof cause === "object" && cause !== null && "status" in cause ? cause.status : void 0;
1700
+ const status = statusOf(cause);
1489
1701
  if (status === 401 || status === 403 || status === 404) {
1490
1702
  this.source = [];
1703
+ this.entries = [];
1491
1704
  this.offset = 0;
1492
- this.patch({ conversations: [], hasMore: false });
1705
+ this.cursor = null;
1706
+ this.patch({ conversations: [], summaries: /* @__PURE__ */ new Map(), hasMore: false });
1493
1707
  }
1494
1708
  this.patch({ error: cause });
1495
1709
  }
1710
+ /** Run an operation in inbox mode, falling back to the legacy path for the rest of this store's life when the
1711
+ * inbox route is absent (404: rollback, staging). Loaded rows are kept and the same operation continues.
1712
+ */
1713
+ async withFallback(generation, inbox, legacy) {
1714
+ if (!this.inboxMode) return legacy();
1715
+ try {
1716
+ await inbox();
1717
+ } catch (cause) {
1718
+ if (!this.alive(generation) || statusOf(cause) !== 404) throw cause;
1719
+ this.inboxUnavailable = true;
1720
+ this.clearActivityTimer();
1721
+ this.stopActivity();
1722
+ this.entries = [];
1723
+ this.cursor = null;
1724
+ this.offset = this.source.length;
1725
+ if (!this.inboxWarned) {
1726
+ this.inboxWarned = true;
1727
+ console.warn("ConvoKit inbox endpoint unavailable (404); using getConversations without previews or unread counts.");
1728
+ }
1729
+ this.patch({ summaries: /* @__PURE__ */ new Map(), currentUserId: "" });
1730
+ await legacy();
1731
+ }
1732
+ }
1733
+ validateInboxPage(page, limit, requestedCursor) {
1734
+ const seen = /* @__PURE__ */ new Set();
1735
+ for (const entry of page.entries) {
1736
+ const id = entry.conversation.id;
1737
+ if (!id.trim() || seen.has(id)) throw new Error("Invalid conversation page");
1738
+ seen.add(id);
1739
+ }
1740
+ if (page.entries.length > limit) throw new Error("Invalid conversation page");
1741
+ if (page.nextCursor !== null && (page.nextCursor === requestedCursor || page.entries.length === 0)) {
1742
+ throw new Error("Inbox pagination did not advance");
1743
+ }
1744
+ }
1745
+ /** Swap rows, summaries, cursor and hasMore together, filtered by the filter current at commit time. */
1746
+ commitInbox(entries, cursor) {
1747
+ this.entries = entries;
1748
+ this.source = ids(entries);
1749
+ this.cursor = cursor;
1750
+ this.patch({
1751
+ conversations: applyConversationFilter(this.source, this.state.filter),
1752
+ summaries: new Map(entries.map((entry) => [entry.conversation.id, summaryOf(entry)])),
1753
+ hasMore: cursor !== null
1754
+ });
1755
+ }
1756
+ async loadInboxUntilVisible(generation, filter) {
1757
+ const visibleBefore = applyConversationFilter(this.source, filter).length;
1758
+ while (this.alive(generation)) {
1759
+ const requested = this.cursor;
1760
+ const page = await this.options.client.listInbox({ limit: this.pageSize, cursor: requested, archived: filter.archived ?? false });
1761
+ if (!this.alive(generation)) return;
1762
+ this.validateInboxPage(page, this.pageSize, requested);
1763
+ this.commitInbox(mergeInboxEntries(this.entries, page.entries), page.nextCursor);
1764
+ if (page.nextCursor === null || this.state.conversations.length > visibleBefore) return;
1765
+ }
1766
+ }
1496
1767
  async loadUntilVisible(generation, filter) {
1497
1768
  const visibleBefore = applyConversationFilter(this.source, filter).length;
1498
1769
  while (this.alive(generation)) {
@@ -1519,10 +1790,17 @@ var ConversationListStore = class {
1519
1790
  const generation = ++this.generation;
1520
1791
  this.refreshing = false;
1521
1792
  this.source = [];
1793
+ this.entries = [];
1522
1794
  this.offset = 0;
1523
- this.patch({ ...blank2(this.state.filter), isInitialLoading: true });
1795
+ this.cursor = null;
1796
+ this.patch({ ...blank2(this.state.filter), currentUserId: this.currentUserId(), isInitialLoading: true });
1797
+ const filter = this.state.filter;
1524
1798
  try {
1525
- await this.loadUntilVisible(generation, this.state.filter);
1799
+ await this.withFallback(
1800
+ generation,
1801
+ () => this.loadInboxUntilVisible(generation, filter),
1802
+ () => this.loadUntilVisible(generation, filter)
1803
+ );
1526
1804
  } catch (cause) {
1527
1805
  this.fail(cause, generation);
1528
1806
  } finally {
@@ -1544,6 +1822,47 @@ var ConversationListStore = class {
1544
1822
  void this.refresh();
1545
1823
  });
1546
1824
  }
1825
+ /** Re-walk the inbox from the head until the loaded window is covered and something is visible, or the inbox
1826
+ * ends. Rooms that moved are re-positioned by the merge; an exhausted inbox publishes what it found.
1827
+ */
1828
+ async refreshInbox(generation, filter) {
1829
+ const target = Math.max(this.pageSize, this.entries.length);
1830
+ let rows = [], consumed = 0, cursor = null;
1831
+ while (this.alive(generation)) {
1832
+ const remaining = target - consumed;
1833
+ const limit = remaining >= 1 ? Math.min(100, remaining) : this.pageSize;
1834
+ const page = await this.options.client.listInbox({ limit, cursor, archived: filter.archived ?? false });
1835
+ if (!this.alive(generation)) return;
1836
+ this.validateInboxPage(page, limit, cursor);
1837
+ rows = mergeInboxEntries(rows, page.entries);
1838
+ consumed += page.entries.length;
1839
+ cursor = page.nextCursor;
1840
+ if (cursor === null || consumed >= target && applyConversationFilter(ids(rows), this.state.filter).length > 0) break;
1841
+ }
1842
+ if (!this.alive(generation)) return;
1843
+ this.commitInbox(rows, cursor);
1844
+ }
1845
+ async refreshLegacy(generation, filter) {
1846
+ const target = Math.max(this.pageSize, this.offset);
1847
+ const compare2 = (a, b) => a.createdAt.getTime() - b.createdAt.getTime() || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
1848
+ const boundary = this.options.pageLoader ? void 0 : this.source.reduce((oldest, row) => !oldest || compare2(row, oldest) < 0 ? row : oldest, void 0);
1849
+ let rows = [], offset = 0, hasMore = true;
1850
+ while (this.alive(generation)) {
1851
+ 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 });
1852
+ if (!this.alive(generation)) return;
1853
+ if (page.length > this.pageSize || page.some((row) => !row.id.trim())) throw new Error("Invalid conversation page");
1854
+ const merged = mergeConversations(rows, page);
1855
+ if (page.length === this.pageSize && merged.length === rows.length) throw new Error("Conversation pagination did not advance");
1856
+ rows = merged;
1857
+ offset += page.length;
1858
+ hasMore = page.length === this.pageSize;
1859
+ if (!hasMore || offset >= target && applyConversationFilter(rows, this.state.filter).length > 0 && (!boundary || page.some((row) => compare2(row, boundary) <= 0))) break;
1860
+ }
1861
+ if (!this.alive(generation)) return;
1862
+ this.source = rows;
1863
+ this.offset = offset;
1864
+ this.patch({ conversations: applyConversationFilter(rows, this.state.filter), hasMore });
1865
+ }
1547
1866
  /** Replace the loaded window atomically, retaining filters and rows during transient failures. */
1548
1867
  refresh = async () => {
1549
1868
  if (!this.alive()) return;
@@ -1554,28 +1873,14 @@ var ConversationListStore = class {
1554
1873
  if (!this.state.hasLoaded) return this.loadInitial();
1555
1874
  const generation = this.generation;
1556
1875
  const filter = this.state.filter;
1557
- const target = Math.max(this.pageSize, this.offset);
1558
- const compare2 = (a, b) => a.createdAt.getTime() - b.createdAt.getTime() || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
1559
- const boundary = this.options.pageLoader ? void 0 : this.source.reduce((oldest, row) => !oldest || compare2(row, oldest) < 0 ? row : oldest, void 0);
1560
1876
  this.refreshing = true;
1561
1877
  this.patch({ error: null });
1562
1878
  try {
1563
- let rows = [], offset = 0, hasMore = true;
1564
- while (this.alive(generation)) {
1565
- 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 });
1566
- if (!this.alive(generation)) return;
1567
- if (page.length > this.pageSize || page.some((row) => !row.id.trim())) throw new Error("Invalid conversation page");
1568
- const merged = mergeConversations(rows, page);
1569
- if (page.length === this.pageSize && merged.length === rows.length) throw new Error("Conversation pagination did not advance");
1570
- rows = merged;
1571
- offset += page.length;
1572
- hasMore = page.length === this.pageSize;
1573
- if (!hasMore || offset >= target && applyConversationFilter(rows, this.state.filter).length > 0 && (!boundary || page.some((row) => compare2(row, boundary) <= 0))) break;
1574
- }
1575
- if (!this.alive(generation)) return;
1576
- this.source = rows;
1577
- this.offset = offset;
1578
- this.patch({ conversations: applyConversationFilter(rows, this.state.filter), hasMore });
1879
+ await this.withFallback(
1880
+ generation,
1881
+ () => this.refreshInbox(generation, filter),
1882
+ () => this.refreshLegacy(generation, filter)
1883
+ );
1579
1884
  } catch (cause) {
1580
1885
  this.fail(cause, generation);
1581
1886
  } finally {
@@ -1588,9 +1893,14 @@ var ConversationListStore = class {
1588
1893
  loadMore = async () => {
1589
1894
  if (!this.alive() || this.refreshing || this.state.isInitialLoading || this.state.isLoadingMore || !this.state.hasMore) return;
1590
1895
  const generation = this.generation;
1896
+ const filter = this.state.filter;
1591
1897
  this.patch({ isLoadingMore: true, error: null });
1592
1898
  try {
1593
- await this.loadUntilVisible(generation, this.state.filter);
1899
+ await this.withFallback(
1900
+ generation,
1901
+ () => this.loadInboxUntilVisible(generation, filter),
1902
+ () => this.loadUntilVisible(generation, filter)
1903
+ );
1594
1904
  } catch (cause) {
1595
1905
  this.fail(cause, generation);
1596
1906
  } finally {
@@ -1642,6 +1952,8 @@ function useConversationList(options) {
1642
1952
  });
1643
1953
  return {
1644
1954
  conversations: field("conversations"),
1955
+ summaries: field("summaries"),
1956
+ currentUserId: field("currentUserId"),
1645
1957
  filter: field("filter"),
1646
1958
  isInitialLoading: field("isInitialLoading"),
1647
1959
  isLoadingMore: field("isLoadingMore"),
@@ -1667,6 +1979,8 @@ var appearanceProps3 = {
1667
1979
  var listViewProps = {
1668
1980
  ...appearanceProps3,
1669
1981
  conversations: { type: Array, required: true },
1982
+ summaries: { type: Object, default: void 0 },
1983
+ currentUserId: { type: String, default: void 0 },
1670
1984
  selectedConversationId: { type: String, default: void 0 },
1671
1985
  onConversationSelect: { type: Function, default: void 0 },
1672
1986
  onRefresh: { type: Function, default: void 0 },
@@ -1716,6 +2030,25 @@ var ConversationListView = defineComponent4({
1716
2030
  const refresh = () => {
1717
2031
  return props.onRefresh?.();
1718
2032
  };
2033
+ const inlineRetry = () => {
2034
+ if (props.hasMore && props.onLoadMore) return () => {
2035
+ lastRequestedLength = null;
2036
+ void requestMore();
2037
+ };
2038
+ if (props.onRefresh) return () => {
2039
+ void refresh();
2040
+ };
2041
+ return void 0;
2042
+ };
2043
+ const unreadBadge = (summary) => {
2044
+ if (summary.unreadCount <= 0 && !summary.unreadCountCapped) return null;
2045
+ const capped = summary.unreadCountCapped || summary.unreadCount > 99;
2046
+ return h4("span", {
2047
+ class: "ckui-unread-badge",
2048
+ role: "img",
2049
+ "aria-label": `${summary.unreadCountCapped ? "99+" : summary.unreadCount} unread`
2050
+ }, [h4("span", { "aria-hidden": "true" }, capped ? "99+" : String(summary.unreadCount))]);
2051
+ };
1719
2052
  const renderContent = () => {
1720
2053
  const currentAppearance = appearance();
1721
2054
  if (props.isInitialLoading && props.conversations.length === 0) {
@@ -1747,10 +2080,21 @@ var ConversationListView = defineComponent4({
1747
2080
  const children = props.conversations.flatMap((conversation, index) => {
1748
2081
  const selected = props.selectedConversationId === conversation.id;
1749
2082
  const select = () => selectConversation(conversation);
1750
- const slotProps = { conversation, index, selected, select };
2083
+ const summary = props.summaries?.get(conversation.id);
2084
+ const slotProps = {
2085
+ conversation,
2086
+ index,
2087
+ selected,
2088
+ select,
2089
+ ...summary ? { summary } : {},
2090
+ ...props.currentUserId === void 0 ? {} : { currentUserId: props.currentUserId }
2091
+ };
2092
+ const preview = inboxPreview(conversation, summary, props.currentUserId);
2093
+ const unread = summary !== void 0 && (summary.unreadCount > 0 || summary.unreadCountCapped);
1751
2094
  const item = slots["conversation-item"]?.(slotProps) ?? h4("button", {
1752
2095
  type: "button",
1753
2096
  "data-selected": selected || void 0,
2097
+ "data-unread": unread || void 0,
1754
2098
  "aria-current": selected ? "true" : void 0,
1755
2099
  onClick: select,
1756
2100
  class: partClass("listItem", currentAppearance, "ckui-conversation-item"),
@@ -1764,8 +2108,14 @@ var ConversationListView = defineComponent4({
1764
2108
  }),
1765
2109
  h4("span", { class: "ckui-conversation-item__body" }, [
1766
2110
  h4("strong", conversation.displayTitle),
1767
- h4("span", conversation.participants.map((participant) => participant.name).join(", ") || conversation.description || "No participants")
2111
+ h4("span", preview || conversation.participants.map((participant) => participant.name).join(", ") || conversation.description || "No participants")
1768
2112
  ]),
2113
+ // Spread rather than emit `null`: a null child renders a `<!---->` comment, and rows without a
2114
+ // summary must keep 0.5's exact markup.
2115
+ ...summary ? [h4("span", { class: "ckui-conversation-item__meta" }, [
2116
+ h4("time", { class: "ckui-conversation-item__time", datetime: summary.activityAt.toISOString() }, formatMessageTime(summary.activityAt)),
2117
+ unreadBadge(summary)
2118
+ ])] : [],
1769
2119
  h4(ChevronRight, { size: 18, "aria-hidden": "true" })
1770
2120
  ]);
1771
2121
  const nodes = [h4("div", { key: conversation.id, role: "listitem" }, [item])];
@@ -1775,13 +2125,15 @@ var ConversationListView = defineComponent4({
1775
2125
  return nodes;
1776
2126
  });
1777
2127
  if (props.error) {
1778
- children.push(slots.error?.({ error: props.error, retry: requestMore }) ?? h4("div", {
2128
+ const retry = inlineRetry();
2129
+ children.push(slots.error?.({ error: props.error, ...retry ? { retry } : {} }) ?? h4("div", {
1779
2130
  class: partClass("error", currentAppearance, "ckui-inline-state ckui-state--error"),
1780
2131
  style: partStyle("error", currentAppearance),
1781
2132
  role: "alert"
1782
- }, [h4("span", errorMessage(props.error)), h4("button", { type: "button", class: "ckui-link-button", onClick: () => {
1783
- void requestMore();
1784
- } }, "Retry")]));
2133
+ }, [
2134
+ h4("span", errorMessage(props.error)),
2135
+ ...retry ? [h4("button", { type: "button", class: "ckui-link-button", onClick: retry }, "Retry")] : []
2136
+ ]));
1785
2137
  } else if (props.isLoadingMore) {
1786
2138
  children.push(slots["load-more"]?.() ?? h4("div", {
1787
2139
  class: partClass("loading", currentAppearance, "ckui-inline-state"),
@@ -1842,6 +2194,7 @@ var ConversationList = defineComponent4({
1842
2194
  initialFilter: { type: Object, default: void 0 },
1843
2195
  pageSize: { type: Number, default: 30 },
1844
2196
  autoLoad: { type: Boolean, default: true },
2197
+ activityRefreshWindowMs: { type: Number, default: void 0 },
1845
2198
  onControllerChange: { type: Function, default: void 0 }
1846
2199
  },
1847
2200
  emits: ["conversation-select", "controller-change"],
@@ -1851,7 +2204,8 @@ var ConversationList = defineComponent4({
1851
2204
  ...props.pageLoader ? { pageLoader: props.pageLoader } : {},
1852
2205
  ...props.initialFilter ? { initialFilter: props.initialFilter } : {},
1853
2206
  pageSize: props.pageSize,
1854
- autoLoad: props.autoLoad
2207
+ autoLoad: props.autoLoad,
2208
+ ...props.activityRefreshWindowMs === void 0 ? {} : { activityRefreshWindowMs: props.activityRefreshWindowMs }
1855
2209
  });
1856
2210
  expose({ controller });
1857
2211
  watchEffect2(() => {
@@ -1864,8 +2218,11 @@ var ConversationList = defineComponent4({
1864
2218
  initialFilter: _initialFilter,
1865
2219
  pageSize: _pageSize,
1866
2220
  autoLoad: _autoLoad,
2221
+ activityRefreshWindowMs: _activityRefreshWindowMs,
1867
2222
  onControllerChange: _onControllerChange,
1868
2223
  conversations: _conversations,
2224
+ summaries: _summaries,
2225
+ currentUserId: _currentUserId,
1869
2226
  onRefresh: _onRefresh,
1870
2227
  onLoadMore: _onLoadMore,
1871
2228
  isInitialLoading: _isInitialLoading,
@@ -1878,6 +2235,8 @@ var ConversationList = defineComponent4({
1878
2235
  ...attrs,
1879
2236
  ...forwarded,
1880
2237
  conversations: controller.conversations.value,
2238
+ summaries: controller.summaries.value,
2239
+ currentUserId: controller.currentUserId.value,
1881
2240
  onRefresh: controller.refresh,
1882
2241
  onLoadMore: controller.loadMore,
1883
2242
  isInitialLoading: controller.isInitialLoading.value,
@@ -1911,6 +2270,7 @@ var defaultConvoKitTheme = {
1911
2270
  incomingBubble: "#f4f4f5",
1912
2271
  outgoingBubble: "#18181b",
1913
2272
  outgoingText: "#fafafa",
2273
+ badge: "#18181b",
1914
2274
  radius: "10px",
1915
2275
  avatarSize: "40px",
1916
2276
  fontFamily: 'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'
@@ -1942,6 +2302,7 @@ var ConvoKitThemeProvider = defineComponent5({
1942
2302
  "--ckui-incoming": theme.incomingBubble,
1943
2303
  "--ckui-outgoing": theme.outgoingBubble,
1944
2304
  "--ckui-outgoing-text": theme.outgoingText,
2305
+ "--ckui-badge": theme.badge,
1945
2306
  "--ckui-radius": theme.radius,
1946
2307
  "--ckui-avatar-size": theme.avatarSize,
1947
2308
  "--ckui-font": theme.fontFamily
@@ -1973,6 +2334,7 @@ export {
1973
2334
  isConvoKitPendingMessage,
1974
2335
  matchesConversation,
1975
2336
  mergeConversations,
2337
+ mergeInboxEntries,
1976
2338
  mergeMessages,
1977
2339
  readerIdsFor,
1978
2340
  useConversation,