@convokitapp/vue-ui 0.5.0 → 0.7.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,14 @@ 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
75
  markConversationRead: (conversationId, options) => client.markConversationRead(conversationId, options),
76
+ markConversationUnread: (conversationId) => client.markConversationUnread(conversationId),
77
+ clearConversationUnread: (conversationId, options) => client.clearConversationUnread(conversationId, options),
70
78
  sendTyping: (input) => client.sendTyping(input),
71
79
  onMessage: (conversationId, handler, onError) => client.realtime.onMessage(conversationId, {
72
80
  onEvent: handler,
@@ -136,6 +144,31 @@ function mergeConversations(current, incoming) {
136
144
  for (const conversation of incoming) byId.set(conversation.id, conversation);
137
145
  return [...byId.values()];
138
146
  }
147
+ function compareInboxOrder(left, right) {
148
+ return right.activityAt.getTime() - left.activityAt.getTime() || (left.conversation.id < right.conversation.id ? 1 : left.conversation.id > right.conversation.id ? -1 : 0);
149
+ }
150
+ function mergeInboxEntries(current, incoming) {
151
+ const byId = new Map(current.map((entry) => [entry.conversation.id, entry]));
152
+ for (const entry of incoming) byId.set(entry.conversation.id, entry);
153
+ return [...byId.values()].sort(compareInboxOrder);
154
+ }
155
+ function inboxPreview(conversation, summary, currentUserId) {
156
+ const message = summary?.latestMessage;
157
+ if (!message) return "";
158
+ const first = message.media[0];
159
+ 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" : "");
160
+ if (!body) return "";
161
+ if (currentUserId !== void 0 && message.senderId === currentUserId) return `You: ${body}`;
162
+ if (conversation.participants.length > 2) {
163
+ const sender = conversation.participants.find((participant) => participant.appUserId === message.senderId || participant.id === message.senderId);
164
+ const name = sender?.name.trim();
165
+ if (name) return `${name}: ${body}`;
166
+ }
167
+ return body;
168
+ }
169
+ function formatMessageTime(date) {
170
+ return new Intl.DateTimeFormat(void 0, { hour: "numeric", minute: "2-digit" }).format(date);
171
+ }
139
172
  function mergeMessages(current, incoming) {
140
173
  const byId = new Map(current.map((message) => [message.id, message]));
141
174
  for (const message of incoming) byId.set(message.id, message);
@@ -226,6 +259,10 @@ function readEntry(participant) {
226
259
  function acknowledgement() {
227
260
  return { inFlight: void 0, followUp: false, suppressed: false, target: void 0, acknowledged: void 0, unacknowledgeable: /* @__PURE__ */ new Set() };
228
261
  }
262
+ function capture(conversation) {
263
+ const membership = conversation?.membership;
264
+ return { version: membership?.privateStateVersion, clearPending: membership?.unreadMarkedAt != null };
265
+ }
229
266
  function isTargetMiss(cause) {
230
267
  if (typeof cause !== "object" || cause === null) return false;
231
268
  const { code, status } = cause;
@@ -286,6 +323,7 @@ var ConversationStore = class {
286
323
  // Keep tombstones until an explicit reload/session change, including across refreshes.
287
324
  deleted = /* @__PURE__ */ new Set();
288
325
  ack = acknowledgement();
326
+ captured = capture();
289
327
  // Visible until the platform reports otherwise; unknown/prerender/no document count as visible.
290
328
  visible = true;
291
329
  sendRevision;
@@ -351,6 +389,7 @@ var ConversationStore = class {
351
389
  this.hydrationPool.queued.clear();
352
390
  this.deleted.clear();
353
391
  this.ack = acknowledgement();
392
+ this.captured = capture();
354
393
  this.sendRevision = void 0;
355
394
  this.activeSend = void 0;
356
395
  this.refreshQueued = false;
@@ -595,9 +634,13 @@ var ConversationStore = class {
595
634
  this.validatePage(page);
596
635
  this.cursor = page.at(-1);
597
636
  this.patch({ conversation, messages: this.overlay(page, revision), hasOlderMessages: page.length === this.pageSize });
637
+ this.captured = capture(conversation);
598
638
  this.mergeReads(conversation.participants.map(readEntry));
599
639
  this.prune(revision);
600
- if (this.options.markReadOnLoad ?? true) await this.acknowledge(true);
640
+ if ((this.options.markReadOnLoad ?? true) || this.ack.suppressed) {
641
+ this.ack.suppressed = false;
642
+ await this.acknowledge(true);
643
+ }
601
644
  } catch (cause) {
602
645
  this.fail(cause, generation, true);
603
646
  } finally {
@@ -654,9 +697,12 @@ var ConversationStore = class {
654
697
  const survivingIds = new Set(reconciled.map((message) => message.id));
655
698
  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));
656
699
  for (const id of known) if (!survivingIds.has(id)) this.forget(id);
700
+ const opening = this.state.conversation === null;
657
701
  this.patch({ conversation, messages: reconciled, hasOlderMessages: hasOlder });
702
+ if (opening) this.captured = capture(conversation);
658
703
  this.mergeReads(conversation.participants.map(readEntry));
659
704
  this.prune(revision);
705
+ if (opening) void this.resumeAcknowledgement();
660
706
  } catch (cause) {
661
707
  this.fail(cause, generation, true);
662
708
  } finally {
@@ -690,15 +736,22 @@ var ConversationStore = class {
690
736
  }
691
737
  }
692
738
  };
693
- /** Acknowledge through the newest rendered row now, regardless of visibility; no request without a target. */
739
+ /** Acknowledge through the newest rendered row now, regardless of visibility; no acknowledgement without a
740
+ * target (a room opened with a marker that renders nothing clears the marker instead, once).
741
+ */
694
742
  markRead = () => this.alive() ? this.acknowledge(false) : Promise.resolve();
695
743
  /** Automatic acknowledgements wait while hidden and are re-issued (once) on becoming visible. */
696
744
  setVisible = (visible) => {
697
745
  this.visible = visible;
698
- if (!visible || !this.ack.suppressed || !this.alive()) return;
699
- this.ack.suppressed = false;
700
- void this.acknowledge(true);
746
+ if (!visible || !this.alive()) return;
747
+ void this.resumeAcknowledgement();
701
748
  };
749
+ /** Re-issue (once) the automatic acknowledgement that waited while hidden or before this open's DTO, if any. */
750
+ resumeAcknowledgement() {
751
+ if (!this.ack.suppressed) return Promise.resolve();
752
+ this.ack.suppressed = false;
753
+ return this.acknowledge(true);
754
+ }
702
755
  /** The newest non-pending rendered row by (createdAt, id), never by list index and never a raw realtime
703
756
  * row; rows the server does not know are skipped, and nothing at or before the accepted target is re-sent.
704
757
  */
@@ -713,7 +766,7 @@ var ConversationStore = class {
713
766
  /** Resolves when the request this call issued or joined settles; a follow-up is issued, not awaited. */
714
767
  acknowledge(automatic) {
715
768
  const ack = this.ack;
716
- if (automatic && !this.visible) {
769
+ if (automatic && (!this.visible || this.state.conversation === null)) {
717
770
  ack.suppressed = true;
718
771
  return Promise.resolve();
719
772
  }
@@ -726,9 +779,10 @@ var ConversationStore = class {
726
779
  issue(ack, generation) {
727
780
  ack.followUp = false;
728
781
  const target = this.ackTarget(ack);
729
- if (!target) return void 0;
730
- ack.target = target.id;
731
- ack.inFlight = this.send(ack, generation, target).finally(() => {
782
+ const request = target ? this.send(ack, generation, target) : this.clearMarker(ack, generation);
783
+ if (!request) return void 0;
784
+ ack.target = target?.id;
785
+ ack.inFlight = request.finally(() => {
732
786
  ack.inFlight = void 0;
733
787
  ack.target = void 0;
734
788
  if (!this.alive(generation) || !ack.followUp) return;
@@ -739,9 +793,31 @@ var ConversationStore = class {
739
793
  });
740
794
  return ack.inFlight;
741
795
  }
796
+ /** A room opened with the caller's unread marker that renders no non-pending, acknowledgeable row cannot clear
797
+ * it through a targeted acknowledgement, so it asks the adapter to clear the marker conditionally on the captured
798
+ * version, once per open, under the acknowledgement triggers and visibility gating. Rows rendered later clear it
799
+ * through their acknowledgements; adapters without the member leave it; `cleared: false` is not an error.
800
+ */
801
+ clearMarker(ack, generation) {
802
+ const captured = this.captured;
803
+ if (!captured.clearPending || captured.version === void 0 || typeof this.client.clearConversationUnread !== "function" || this.state.messages.some((message) => !isConvoKitPendingMessage(message) && !ack.unacknowledgeable.has(message.id))) return void 0;
804
+ captured.clearPending = false;
805
+ return this.clearUnread(captured.version, generation);
806
+ }
807
+ async clearUnread(version2, generation) {
808
+ try {
809
+ await this.client.clearConversationUnread(this.room, { ifVersion: version2 });
810
+ } catch (cause) {
811
+ if (this.alive(generation)) this.fail(cause, generation);
812
+ }
813
+ }
742
814
  async send(ack, generation, target) {
743
815
  try {
744
- await this.client.markConversationRead(this.room, { throughMessageId: target.id });
816
+ const version2 = this.captured.version;
817
+ await this.client.markConversationRead(this.room, {
818
+ throughMessageId: target.id,
819
+ ...version2 === void 0 ? {} : { privateStateVersion: version2 }
820
+ });
745
821
  if (!this.alive(generation)) return;
746
822
  if (!ack.acknowledged || compare(target, ack.acknowledged) > 0) ack.acknowledged = target;
747
823
  } catch (cause) {
@@ -911,9 +987,6 @@ var appearanceProps = {
911
987
  density: { type: String, default: "comfortable" },
912
988
  unstyled: { type: Boolean, default: false }
913
989
  };
914
- function defaultFormatTime(date) {
915
- return new Intl.DateTimeFormat(void 0, { hour: "numeric", minute: "2-digit" }).format(date);
916
- }
917
990
  function defaultMedia(media, open, imageLoading) {
918
991
  const tag = open ? "button" : "div";
919
992
  const interactive = open ? { type: "button", onClick: open } : {};
@@ -964,7 +1037,7 @@ var MessageListView = (0, import_vue5.defineComponent)({
964
1037
  paginationThreshold: { type: Number, default: 240 },
965
1038
  reverse: { type: Boolean, default: true },
966
1039
  stickToBottom: { type: Boolean, default: true },
967
- formatTime: { type: Function, default: defaultFormatTime },
1040
+ formatTime: { type: Function, default: formatMessageTime },
968
1041
  imageLoading: { type: String, default: "lazy" }
969
1042
  },
970
1043
  emits: ["load-older", "attachment-click"],
@@ -1521,30 +1594,64 @@ var import_vue10 = require("vue");
1521
1594
  var import_vue8 = require("vue");
1522
1595
 
1523
1596
  // src/conversation-list-store.ts
1597
+ var defaultActivityRefreshWindowMs = 500;
1524
1598
  function blank2(filter) {
1525
- return { conversations: [], filter, isInitialLoading: false, isLoadingMore: false, hasMore: true, hasLoaded: false, error: null };
1599
+ return {
1600
+ conversations: [],
1601
+ summaries: /* @__PURE__ */ new Map(),
1602
+ currentUserId: "",
1603
+ filter,
1604
+ isInitialLoading: false,
1605
+ isLoadingMore: false,
1606
+ hasMore: true,
1607
+ hasLoaded: false,
1608
+ error: null
1609
+ };
1610
+ }
1611
+ function statusOf(cause) {
1612
+ return typeof cause === "object" && cause !== null && "status" in cause ? cause.status : void 0;
1613
+ }
1614
+ function summaryOf(entry) {
1615
+ const { conversation: _conversation, ...summary } = entry;
1616
+ return summary;
1617
+ }
1618
+ function ids(entries) {
1619
+ return entries.map((entry) => entry.conversation);
1526
1620
  }
1527
1621
  var ConversationListStore = class {
1528
1622
  constructor(options) {
1529
1623
  this.options = options;
1530
1624
  this.owner = options.client.sessionIdentity;
1625
+ this.user = this.owner ? options.client.currentUserId : "";
1531
1626
  this.pageSize = options.pageSize ?? 30;
1532
1627
  if (!Number.isInteger(this.pageSize) || this.pageSize < 1 || this.pageSize > 100) {
1533
1628
  throw new RangeError("pageSize must be an integer between 1 and 100");
1534
1629
  }
1630
+ this.activityRefreshWindowMs = options.activityRefreshWindowMs ?? defaultActivityRefreshWindowMs;
1631
+ if (!Number.isFinite(this.activityRefreshWindowMs) || this.activityRefreshWindowMs < 0) {
1632
+ throw new RangeError("activityRefreshWindowMs must be a non-negative number");
1633
+ }
1535
1634
  this.state = blank2(options.initialFilter ?? {});
1536
1635
  }
1537
1636
  options;
1538
1637
  owner;
1638
+ user;
1539
1639
  pageSize;
1640
+ activityRefreshWindowMs;
1540
1641
  state;
1541
1642
  source = [];
1643
+ entries = [];
1542
1644
  offset = 0;
1645
+ cursor = null;
1646
+ inboxUnavailable = false;
1647
+ inboxWarned = false;
1543
1648
  generation = 0;
1544
1649
  lifecycleGeneration = 0;
1545
1650
  disposed = true;
1546
1651
  lifecycle;
1547
1652
  inbox;
1653
+ activity;
1654
+ activityTimer;
1548
1655
  refreshQueued = false;
1549
1656
  refreshing = false;
1550
1657
  listeners = /* @__PURE__ */ new Set();
@@ -1562,12 +1669,27 @@ var ConversationListStore = class {
1562
1669
  alive(generation = this.generation) {
1563
1670
  return !this.disposed && generation === this.generation && this.owner !== null && this.options.client.sessionIdentity === this.owner;
1564
1671
  }
1672
+ get inboxMode() {
1673
+ return !this.options.pageLoader && typeof this.options.client.listInbox === "function" && !this.inboxUnavailable;
1674
+ }
1675
+ currentUserId() {
1676
+ return this.inboxMode ? this.user : "";
1677
+ }
1565
1678
  start = (autoLoad = true) => {
1566
1679
  if (!this.owner || this.options.client.sessionIdentity !== this.owner) return;
1567
1680
  if (!this.disposed) return;
1568
1681
  this.disposed = false;
1682
+ this.inboxUnavailable = false;
1569
1683
  const lifecycleGeneration = ++this.lifecycleGeneration;
1684
+ const current = () => this.alive() && lifecycleGeneration === this.lifecycleGeneration;
1685
+ const reconcile = (cause) => {
1686
+ if (current()) {
1687
+ this.patch({ error: cause });
1688
+ this.queueRefresh();
1689
+ }
1690
+ };
1570
1691
  try {
1692
+ this.patch({ currentUserId: this.currentUserId() });
1571
1693
  const subscription = this.options.client.onConnectionEvent({
1572
1694
  onEvent: () => {
1573
1695
  },
@@ -1579,15 +1701,20 @@ var ConversationListStore = class {
1579
1701
  else void subscription.unsubscribe().catch(() => void 0);
1580
1702
  if (!this.alive()) return;
1581
1703
  const inbox = this.options.client.onInboxChanged(() => {
1582
- if (this.alive() && lifecycleGeneration === this.lifecycleGeneration) this.queueRefresh();
1583
- }, (cause) => {
1584
- if (this.alive() && lifecycleGeneration === this.lifecycleGeneration) {
1585
- this.patch({ error: cause });
1586
- this.queueRefresh();
1587
- }
1588
- });
1704
+ if (!current()) return;
1705
+ this.clearActivityTimer();
1706
+ this.queueRefresh();
1707
+ }, reconcile);
1589
1708
  if (this.alive()) this.inbox = inbox;
1590
1709
  else void inbox.unsubscribe().catch(() => void 0);
1710
+ if (!this.alive()) return;
1711
+ if (this.inboxMode && typeof this.options.client.onInboxActivity === "function") {
1712
+ const activity = this.options.client.onInboxActivity(() => {
1713
+ if (current()) this.scheduleActivityRefresh(lifecycleGeneration);
1714
+ }, reconcile);
1715
+ if (this.alive()) this.activity = activity;
1716
+ else void activity.unsubscribe().catch(() => void 0);
1717
+ }
1591
1718
  if (autoLoad) void this.loadInitial();
1592
1719
  } catch (cause) {
1593
1720
  if (this.alive()) this.patch({ error: cause });
@@ -1597,27 +1724,109 @@ var ConversationListStore = class {
1597
1724
  this.disposed = true;
1598
1725
  this.generation++;
1599
1726
  this.lifecycleGeneration++;
1727
+ this.clearActivityTimer();
1600
1728
  const subscription = this.lifecycle;
1601
1729
  this.lifecycle = void 0;
1602
1730
  if (subscription) void subscription.unsubscribe().catch(() => void 0);
1603
1731
  if (this.inbox) void this.inbox.unsubscribe().catch(() => void 0);
1604
1732
  this.inbox = void 0;
1733
+ this.stopActivity();
1605
1734
  this.refreshQueued = false;
1606
1735
  this.refreshing = false;
1607
1736
  this.source = [];
1737
+ this.entries = [];
1608
1738
  this.offset = 0;
1739
+ this.cursor = null;
1609
1740
  this.patch(blank2(this.state.filter));
1610
1741
  };
1742
+ stopActivity() {
1743
+ if (this.activity) void this.activity.unsubscribe().catch(() => void 0);
1744
+ this.activity = void 0;
1745
+ }
1746
+ clearActivityTimer() {
1747
+ if (this.activityTimer !== void 0) clearTimeout(this.activityTimer);
1748
+ this.activityTimer = void 0;
1749
+ }
1750
+ /** Max-wait throttle: the first signal opens a window; later signals wait for it; one refresh runs when it closes. */
1751
+ scheduleActivityRefresh(lifecycleGeneration) {
1752
+ if (this.activityRefreshWindowMs === 0) return this.queueRefresh();
1753
+ if (this.activityTimer !== void 0) return;
1754
+ const timer = setTimeout(() => {
1755
+ this.activityTimer = void 0;
1756
+ if (this.alive() && lifecycleGeneration === this.lifecycleGeneration) this.queueRefresh();
1757
+ }, this.activityRefreshWindowMs);
1758
+ timer.unref?.();
1759
+ this.activityTimer = timer;
1760
+ }
1611
1761
  fail(cause, generation) {
1612
1762
  if (!this.alive(generation)) return;
1613
- const status = typeof cause === "object" && cause !== null && "status" in cause ? cause.status : void 0;
1763
+ const status = statusOf(cause);
1614
1764
  if (status === 401 || status === 403 || status === 404) {
1615
1765
  this.source = [];
1766
+ this.entries = [];
1616
1767
  this.offset = 0;
1617
- this.patch({ conversations: [], hasMore: false });
1768
+ this.cursor = null;
1769
+ this.patch({ conversations: [], summaries: /* @__PURE__ */ new Map(), hasMore: false });
1618
1770
  }
1619
1771
  this.patch({ error: cause });
1620
1772
  }
1773
+ /** Run an operation in inbox mode, falling back to the legacy path for the rest of this store's life when the
1774
+ * inbox route is absent (404: rollback, staging). Loaded rows are kept and the same operation continues.
1775
+ */
1776
+ async withFallback(generation, inbox, legacy) {
1777
+ if (!this.inboxMode) return legacy();
1778
+ try {
1779
+ await inbox();
1780
+ } catch (cause) {
1781
+ if (!this.alive(generation) || statusOf(cause) !== 404) throw cause;
1782
+ this.inboxUnavailable = true;
1783
+ this.clearActivityTimer();
1784
+ this.stopActivity();
1785
+ this.entries = [];
1786
+ this.cursor = null;
1787
+ this.offset = this.source.length;
1788
+ if (!this.inboxWarned) {
1789
+ this.inboxWarned = true;
1790
+ console.warn("ConvoKit inbox endpoint unavailable (404); using getConversations without previews or unread counts.");
1791
+ }
1792
+ this.patch({ summaries: /* @__PURE__ */ new Map(), currentUserId: "" });
1793
+ await legacy();
1794
+ }
1795
+ }
1796
+ validateInboxPage(page, limit, requestedCursor) {
1797
+ const seen = /* @__PURE__ */ new Set();
1798
+ for (const entry of page.entries) {
1799
+ const id = entry.conversation.id;
1800
+ if (!id.trim() || seen.has(id)) throw new Error("Invalid conversation page");
1801
+ seen.add(id);
1802
+ }
1803
+ if (page.entries.length > limit) throw new Error("Invalid conversation page");
1804
+ if (page.nextCursor !== null && (page.nextCursor === requestedCursor || page.entries.length === 0)) {
1805
+ throw new Error("Inbox pagination did not advance");
1806
+ }
1807
+ }
1808
+ /** Swap rows, summaries, cursor and hasMore together, filtered by the filter current at commit time. */
1809
+ commitInbox(entries, cursor) {
1810
+ this.entries = entries;
1811
+ this.source = ids(entries);
1812
+ this.cursor = cursor;
1813
+ this.patch({
1814
+ conversations: applyConversationFilter(this.source, this.state.filter),
1815
+ summaries: new Map(entries.map((entry) => [entry.conversation.id, summaryOf(entry)])),
1816
+ hasMore: cursor !== null
1817
+ });
1818
+ }
1819
+ async loadInboxUntilVisible(generation, filter) {
1820
+ const visibleBefore = applyConversationFilter(this.source, filter).length;
1821
+ while (this.alive(generation)) {
1822
+ const requested = this.cursor;
1823
+ const page = await this.options.client.listInbox({ limit: this.pageSize, cursor: requested, archived: filter.archived ?? false });
1824
+ if (!this.alive(generation)) return;
1825
+ this.validateInboxPage(page, this.pageSize, requested);
1826
+ this.commitInbox(mergeInboxEntries(this.entries, page.entries), page.nextCursor);
1827
+ if (page.nextCursor === null || this.state.conversations.length > visibleBefore) return;
1828
+ }
1829
+ }
1621
1830
  async loadUntilVisible(generation, filter) {
1622
1831
  const visibleBefore = applyConversationFilter(this.source, filter).length;
1623
1832
  while (this.alive(generation)) {
@@ -1644,10 +1853,17 @@ var ConversationListStore = class {
1644
1853
  const generation = ++this.generation;
1645
1854
  this.refreshing = false;
1646
1855
  this.source = [];
1856
+ this.entries = [];
1647
1857
  this.offset = 0;
1648
- this.patch({ ...blank2(this.state.filter), isInitialLoading: true });
1858
+ this.cursor = null;
1859
+ this.patch({ ...blank2(this.state.filter), currentUserId: this.currentUserId(), isInitialLoading: true });
1860
+ const filter = this.state.filter;
1649
1861
  try {
1650
- await this.loadUntilVisible(generation, this.state.filter);
1862
+ await this.withFallback(
1863
+ generation,
1864
+ () => this.loadInboxUntilVisible(generation, filter),
1865
+ () => this.loadUntilVisible(generation, filter)
1866
+ );
1651
1867
  } catch (cause) {
1652
1868
  this.fail(cause, generation);
1653
1869
  } finally {
@@ -1669,6 +1885,47 @@ var ConversationListStore = class {
1669
1885
  void this.refresh();
1670
1886
  });
1671
1887
  }
1888
+ /** Re-walk the inbox from the head until the loaded window is covered and something is visible, or the inbox
1889
+ * ends. Rooms that moved are re-positioned by the merge; an exhausted inbox publishes what it found.
1890
+ */
1891
+ async refreshInbox(generation, filter) {
1892
+ const target = Math.max(this.pageSize, this.entries.length);
1893
+ let rows = [], consumed = 0, cursor = null;
1894
+ while (this.alive(generation)) {
1895
+ const remaining = target - consumed;
1896
+ const limit = remaining >= 1 ? Math.min(100, remaining) : this.pageSize;
1897
+ const page = await this.options.client.listInbox({ limit, cursor, archived: filter.archived ?? false });
1898
+ if (!this.alive(generation)) return;
1899
+ this.validateInboxPage(page, limit, cursor);
1900
+ rows = mergeInboxEntries(rows, page.entries);
1901
+ consumed += page.entries.length;
1902
+ cursor = page.nextCursor;
1903
+ if (cursor === null || consumed >= target && applyConversationFilter(ids(rows), this.state.filter).length > 0) break;
1904
+ }
1905
+ if (!this.alive(generation)) return;
1906
+ this.commitInbox(rows, cursor);
1907
+ }
1908
+ async refreshLegacy(generation, filter) {
1909
+ const target = Math.max(this.pageSize, this.offset);
1910
+ const compare2 = (a, b) => a.createdAt.getTime() - b.createdAt.getTime() || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
1911
+ const boundary = this.options.pageLoader ? void 0 : this.source.reduce((oldest, row) => !oldest || compare2(row, oldest) < 0 ? row : oldest, void 0);
1912
+ let rows = [], offset = 0, hasMore = true;
1913
+ while (this.alive(generation)) {
1914
+ 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 });
1915
+ if (!this.alive(generation)) return;
1916
+ if (page.length > this.pageSize || page.some((row) => !row.id.trim())) throw new Error("Invalid conversation page");
1917
+ const merged = mergeConversations(rows, page);
1918
+ if (page.length === this.pageSize && merged.length === rows.length) throw new Error("Conversation pagination did not advance");
1919
+ rows = merged;
1920
+ offset += page.length;
1921
+ hasMore = page.length === this.pageSize;
1922
+ if (!hasMore || offset >= target && applyConversationFilter(rows, this.state.filter).length > 0 && (!boundary || page.some((row) => compare2(row, boundary) <= 0))) break;
1923
+ }
1924
+ if (!this.alive(generation)) return;
1925
+ this.source = rows;
1926
+ this.offset = offset;
1927
+ this.patch({ conversations: applyConversationFilter(rows, this.state.filter), hasMore });
1928
+ }
1672
1929
  /** Replace the loaded window atomically, retaining filters and rows during transient failures. */
1673
1930
  refresh = async () => {
1674
1931
  if (!this.alive()) return;
@@ -1679,28 +1936,14 @@ var ConversationListStore = class {
1679
1936
  if (!this.state.hasLoaded) return this.loadInitial();
1680
1937
  const generation = this.generation;
1681
1938
  const filter = this.state.filter;
1682
- const target = Math.max(this.pageSize, this.offset);
1683
- const compare2 = (a, b) => a.createdAt.getTime() - b.createdAt.getTime() || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
1684
- const boundary = this.options.pageLoader ? void 0 : this.source.reduce((oldest, row) => !oldest || compare2(row, oldest) < 0 ? row : oldest, void 0);
1685
1939
  this.refreshing = true;
1686
1940
  this.patch({ error: null });
1687
1941
  try {
1688
- let rows = [], offset = 0, hasMore = true;
1689
- while (this.alive(generation)) {
1690
- 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 });
1691
- if (!this.alive(generation)) return;
1692
- if (page.length > this.pageSize || page.some((row) => !row.id.trim())) throw new Error("Invalid conversation page");
1693
- const merged = mergeConversations(rows, page);
1694
- if (page.length === this.pageSize && merged.length === rows.length) throw new Error("Conversation pagination did not advance");
1695
- rows = merged;
1696
- offset += page.length;
1697
- hasMore = page.length === this.pageSize;
1698
- if (!hasMore || offset >= target && applyConversationFilter(rows, this.state.filter).length > 0 && (!boundary || page.some((row) => compare2(row, boundary) <= 0))) break;
1699
- }
1700
- if (!this.alive(generation)) return;
1701
- this.source = rows;
1702
- this.offset = offset;
1703
- this.patch({ conversations: applyConversationFilter(rows, this.state.filter), hasMore });
1942
+ await this.withFallback(
1943
+ generation,
1944
+ () => this.refreshInbox(generation, filter),
1945
+ () => this.refreshLegacy(generation, filter)
1946
+ );
1704
1947
  } catch (cause) {
1705
1948
  this.fail(cause, generation);
1706
1949
  } finally {
@@ -1713,9 +1956,14 @@ var ConversationListStore = class {
1713
1956
  loadMore = async () => {
1714
1957
  if (!this.alive() || this.refreshing || this.state.isInitialLoading || this.state.isLoadingMore || !this.state.hasMore) return;
1715
1958
  const generation = this.generation;
1959
+ const filter = this.state.filter;
1716
1960
  this.patch({ isLoadingMore: true, error: null });
1717
1961
  try {
1718
- await this.loadUntilVisible(generation, this.state.filter);
1962
+ await this.withFallback(
1963
+ generation,
1964
+ () => this.loadInboxUntilVisible(generation, filter),
1965
+ () => this.loadUntilVisible(generation, filter)
1966
+ );
1719
1967
  } catch (cause) {
1720
1968
  this.fail(cause, generation);
1721
1969
  } finally {
@@ -1733,6 +1981,66 @@ var ConversationListStore = class {
1733
1981
  else if (!this.state.conversations.length && this.state.hasMore) await this.loadMore();
1734
1982
  };
1735
1983
  setQuery = (query) => this.setFilter({ ...this.state.filter, query });
1984
+ /** Mark a room unread for the viewer only; the row's summary takes the response (D10). Rejects when the adapter
1985
+ * lacks `markConversationUnread` or the store is not active; a request failure is reported through `error`
1986
+ * without evicting rows and rejects.
1987
+ */
1988
+ markUnread = async (conversationId) => {
1989
+ const client = this.options.client;
1990
+ if (typeof client.markConversationUnread !== "function") {
1991
+ throw new TypeError("markUnread requires a ConvoKitUiClient adapter with markConversationUnread (core SDK 0.7)");
1992
+ }
1993
+ this.assertActive();
1994
+ this.applyPrivateState(conversationId, await this.mutate(client.markConversationUnread(conversationId)));
1995
+ };
1996
+ /** Remove the viewer's marker (conditionally on `options.ifVersion`); resolves to the response's `cleared` ("this
1997
+ * request removed the marker", not "the room is read") and patches the summary on true and false alike (D10).
1998
+ * Rejects when the adapter lacks `clearConversationUnread` or the store is not active; failures are reported like
1999
+ * `markUnread`.
2000
+ */
2001
+ clearUnread = async (conversationId, options) => {
2002
+ const client = this.options.client;
2003
+ if (typeof client.clearConversationUnread !== "function") {
2004
+ throw new TypeError("clearUnread requires a ConvoKitUiClient adapter with clearConversationUnread (core SDK 0.7)");
2005
+ }
2006
+ this.assertActive();
2007
+ const result = await this.mutate(client.clearConversationUnread(conversationId, options));
2008
+ this.applyPrivateState(conversationId, result);
2009
+ return result.cleared;
2010
+ };
2011
+ /** A disposed or session-evicted store never sends a private-state mutation: on a shared client it could go out
2012
+ * under a replacement login. Rejected without touching `error` (there is no live snapshot to report into).
2013
+ */
2014
+ assertActive() {
2015
+ if (!this.alive()) throw new Error("ConversationListStore is not active");
2016
+ }
2017
+ async mutate(request) {
2018
+ try {
2019
+ return await request;
2020
+ } catch (cause) {
2021
+ if (this.alive()) this.patch({ error: cause });
2022
+ throw cause;
2023
+ }
2024
+ }
2025
+ /** Apply a mark/clear response to the row's CURRENT summary (a refresh may have swapped it) as one unit, only while
2026
+ * the store is alive and the response is not older than the stored version: a delayed response never resurrects a
2027
+ * marker a newer action removed (equal versions are an idempotent no-op). `isUnread` is recomputed from the stored
2028
+ * counts and the response marker. Other devices learn of the change through `inbox_activity`.
2029
+ */
2030
+ applyPrivateState(conversationId, state) {
2031
+ if (!this.alive()) return;
2032
+ const current = this.entries.find((entry) => entry.conversation.id === conversationId);
2033
+ if (!current || state.privateStateVersion < current.privateStateVersion) return;
2034
+ const { unreadMarkedAt, privateStateVersion } = state;
2035
+ const patched = {
2036
+ ...current,
2037
+ unreadMarkedAt,
2038
+ privateStateVersion,
2039
+ isUnread: current.unreadCount > 0 || current.unreadCountCapped || unreadMarkedAt !== null
2040
+ };
2041
+ this.entries = this.entries.map((entry) => entry === current ? patched : entry);
2042
+ this.patch({ summaries: new Map(this.entries.map((entry) => [entry.conversation.id, summaryOf(entry)])) });
2043
+ }
1736
2044
  };
1737
2045
 
1738
2046
  // src/composables/use-conversation-list.ts
@@ -1767,6 +2075,8 @@ function useConversationList(options) {
1767
2075
  });
1768
2076
  return {
1769
2077
  conversations: field("conversations"),
2078
+ summaries: field("summaries"),
2079
+ currentUserId: field("currentUserId"),
1770
2080
  filter: field("filter"),
1771
2081
  isInitialLoading: field("isInitialLoading"),
1772
2082
  isLoadingMore: field("isLoadingMore"),
@@ -1778,6 +2088,8 @@ function useConversationList(options) {
1778
2088
  loadMore: () => store.loadMore(),
1779
2089
  setFilter: (filter) => store.setFilter(filter),
1780
2090
  setQuery: (query) => store.setQuery(query),
2091
+ markUnread: (conversationId) => store.markUnread(conversationId),
2092
+ clearUnread: (conversationId, options2) => store.clearUnread(conversationId, options2),
1781
2093
  dispose
1782
2094
  };
1783
2095
  }
@@ -1792,6 +2104,8 @@ var appearanceProps3 = {
1792
2104
  var listViewProps = {
1793
2105
  ...appearanceProps3,
1794
2106
  conversations: { type: Array, required: true },
2107
+ summaries: { type: Object, default: void 0 },
2108
+ currentUserId: { type: String, default: void 0 },
1795
2109
  selectedConversationId: { type: String, default: void 0 },
1796
2110
  onConversationSelect: { type: Function, default: void 0 },
1797
2111
  onRefresh: { type: Function, default: void 0 },
@@ -1841,6 +2155,28 @@ var ConversationListView = (0, import_vue10.defineComponent)({
1841
2155
  const refresh = () => {
1842
2156
  return props.onRefresh?.();
1843
2157
  };
2158
+ const inlineRetry = () => {
2159
+ if (props.hasMore && props.onLoadMore) return () => {
2160
+ lastRequestedLength = null;
2161
+ void requestMore();
2162
+ };
2163
+ if (props.onRefresh) return () => {
2164
+ void refresh();
2165
+ };
2166
+ return void 0;
2167
+ };
2168
+ const unreadBadge = (summary) => {
2169
+ if (summary.unreadCount > 0 || summary.unreadCountCapped) {
2170
+ const capped = summary.unreadCountCapped || summary.unreadCount > 99;
2171
+ return [(0, import_vue10.h)("span", {
2172
+ class: "ckui-unread-badge",
2173
+ role: "img",
2174
+ "aria-label": `${summary.unreadCountCapped ? "99+" : summary.unreadCount} unread`
2175
+ }, [(0, import_vue10.h)("span", { "aria-hidden": "true" }, capped ? "99+" : String(summary.unreadCount))])];
2176
+ }
2177
+ if (summary.isUnread) return [(0, import_vue10.h)("span", { class: "ckui-unread-badge ckui-unread-badge--dot", role: "img", "aria-label": "Unread" })];
2178
+ return [];
2179
+ };
1844
2180
  const renderContent = () => {
1845
2181
  const currentAppearance = appearance();
1846
2182
  if (props.isInitialLoading && props.conversations.length === 0) {
@@ -1872,10 +2208,21 @@ var ConversationListView = (0, import_vue10.defineComponent)({
1872
2208
  const children = props.conversations.flatMap((conversation, index) => {
1873
2209
  const selected = props.selectedConversationId === conversation.id;
1874
2210
  const select = () => selectConversation(conversation);
1875
- const slotProps = { conversation, index, selected, select };
2211
+ const summary = props.summaries?.get(conversation.id);
2212
+ const slotProps = {
2213
+ conversation,
2214
+ index,
2215
+ selected,
2216
+ select,
2217
+ ...summary ? { summary } : {},
2218
+ ...props.currentUserId === void 0 ? {} : { currentUserId: props.currentUserId }
2219
+ };
2220
+ const preview = inboxPreview(conversation, summary, props.currentUserId);
2221
+ const unread = summary !== void 0 && (summary.isUnread || summary.unreadCount > 0 || summary.unreadCountCapped);
1876
2222
  const item = slots["conversation-item"]?.(slotProps) ?? (0, import_vue10.h)("button", {
1877
2223
  type: "button",
1878
2224
  "data-selected": selected || void 0,
2225
+ "data-unread": unread || void 0,
1879
2226
  "aria-current": selected ? "true" : void 0,
1880
2227
  onClick: select,
1881
2228
  class: partClass("listItem", currentAppearance, "ckui-conversation-item"),
@@ -1889,8 +2236,14 @@ var ConversationListView = (0, import_vue10.defineComponent)({
1889
2236
  }),
1890
2237
  (0, import_vue10.h)("span", { class: "ckui-conversation-item__body" }, [
1891
2238
  (0, import_vue10.h)("strong", conversation.displayTitle),
1892
- (0, import_vue10.h)("span", conversation.participants.map((participant) => participant.name).join(", ") || conversation.description || "No participants")
2239
+ (0, import_vue10.h)("span", preview || conversation.participants.map((participant) => participant.name).join(", ") || conversation.description || "No participants")
1893
2240
  ]),
2241
+ // Spread rather than emit `null`: a null child renders a `<!---->` comment, and rows without a
2242
+ // summary must keep 0.5's exact markup.
2243
+ ...summary ? [(0, import_vue10.h)("span", { class: "ckui-conversation-item__meta" }, [
2244
+ (0, import_vue10.h)("time", { class: "ckui-conversation-item__time", datetime: summary.activityAt.toISOString() }, formatMessageTime(summary.activityAt)),
2245
+ ...unreadBadge(summary)
2246
+ ])] : [],
1894
2247
  (0, import_vue10.h)(import_vue9.ChevronRight, { size: 18, "aria-hidden": "true" })
1895
2248
  ]);
1896
2249
  const nodes = [(0, import_vue10.h)("div", { key: conversation.id, role: "listitem" }, [item])];
@@ -1900,13 +2253,15 @@ var ConversationListView = (0, import_vue10.defineComponent)({
1900
2253
  return nodes;
1901
2254
  });
1902
2255
  if (props.error) {
1903
- children.push(slots.error?.({ error: props.error, retry: requestMore }) ?? (0, import_vue10.h)("div", {
2256
+ const retry = inlineRetry();
2257
+ children.push(slots.error?.({ error: props.error, ...retry ? { retry } : {} }) ?? (0, import_vue10.h)("div", {
1904
2258
  class: partClass("error", currentAppearance, "ckui-inline-state ckui-state--error"),
1905
2259
  style: partStyle("error", currentAppearance),
1906
2260
  role: "alert"
1907
- }, [(0, import_vue10.h)("span", errorMessage(props.error)), (0, import_vue10.h)("button", { type: "button", class: "ckui-link-button", onClick: () => {
1908
- void requestMore();
1909
- } }, "Retry")]));
2261
+ }, [
2262
+ (0, import_vue10.h)("span", errorMessage(props.error)),
2263
+ ...retry ? [(0, import_vue10.h)("button", { type: "button", class: "ckui-link-button", onClick: retry }, "Retry")] : []
2264
+ ]));
1910
2265
  } else if (props.isLoadingMore) {
1911
2266
  children.push(slots["load-more"]?.() ?? (0, import_vue10.h)("div", {
1912
2267
  class: partClass("loading", currentAppearance, "ckui-inline-state"),
@@ -1967,6 +2322,7 @@ var ConversationList = (0, import_vue10.defineComponent)({
1967
2322
  initialFilter: { type: Object, default: void 0 },
1968
2323
  pageSize: { type: Number, default: 30 },
1969
2324
  autoLoad: { type: Boolean, default: true },
2325
+ activityRefreshWindowMs: { type: Number, default: void 0 },
1970
2326
  onControllerChange: { type: Function, default: void 0 }
1971
2327
  },
1972
2328
  emits: ["conversation-select", "controller-change"],
@@ -1976,7 +2332,8 @@ var ConversationList = (0, import_vue10.defineComponent)({
1976
2332
  ...props.pageLoader ? { pageLoader: props.pageLoader } : {},
1977
2333
  ...props.initialFilter ? { initialFilter: props.initialFilter } : {},
1978
2334
  pageSize: props.pageSize,
1979
- autoLoad: props.autoLoad
2335
+ autoLoad: props.autoLoad,
2336
+ ...props.activityRefreshWindowMs === void 0 ? {} : { activityRefreshWindowMs: props.activityRefreshWindowMs }
1980
2337
  });
1981
2338
  expose({ controller });
1982
2339
  (0, import_vue10.watchEffect)(() => {
@@ -1989,8 +2346,11 @@ var ConversationList = (0, import_vue10.defineComponent)({
1989
2346
  initialFilter: _initialFilter,
1990
2347
  pageSize: _pageSize,
1991
2348
  autoLoad: _autoLoad,
2349
+ activityRefreshWindowMs: _activityRefreshWindowMs,
1992
2350
  onControllerChange: _onControllerChange,
1993
2351
  conversations: _conversations,
2352
+ summaries: _summaries,
2353
+ currentUserId: _currentUserId,
1994
2354
  onRefresh: _onRefresh,
1995
2355
  onLoadMore: _onLoadMore,
1996
2356
  isInitialLoading: _isInitialLoading,
@@ -2003,6 +2363,8 @@ var ConversationList = (0, import_vue10.defineComponent)({
2003
2363
  ...attrs,
2004
2364
  ...forwarded,
2005
2365
  conversations: controller.conversations.value,
2366
+ summaries: controller.summaries.value,
2367
+ currentUserId: controller.currentUserId.value,
2006
2368
  onRefresh: controller.refresh,
2007
2369
  onLoadMore: controller.loadMore,
2008
2370
  isInitialLoading: controller.isInitialLoading.value,
@@ -2030,6 +2392,7 @@ var defaultConvoKitTheme = {
2030
2392
  incomingBubble: "#f4f4f5",
2031
2393
  outgoingBubble: "#18181b",
2032
2394
  outgoingText: "#fafafa",
2395
+ badge: "#18181b",
2033
2396
  radius: "10px",
2034
2397
  avatarSize: "40px",
2035
2398
  fontFamily: 'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'
@@ -2061,6 +2424,7 @@ var ConvoKitThemeProvider = (0, import_vue11.defineComponent)({
2061
2424
  "--ckui-incoming": theme.incomingBubble,
2062
2425
  "--ckui-outgoing": theme.outgoingBubble,
2063
2426
  "--ckui-outgoing-text": theme.outgoingText,
2427
+ "--ckui-badge": theme.badge,
2064
2428
  "--ckui-radius": theme.radius,
2065
2429
  "--ckui-avatar-size": theme.avatarSize,
2066
2430
  "--ckui-font": theme.fontFamily
@@ -2093,6 +2457,7 @@ function useConvoKitTheme() {
2093
2457
  isConvoKitPendingMessage,
2094
2458
  matchesConversation,
2095
2459
  mergeConversations,
2460
+ mergeInboxEntries,
2096
2461
  mergeMessages,
2097
2462
  readerIdsFor,
2098
2463
  useConversation,