@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.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,14 @@ 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
29
  markConversationRead: (conversationId, options) => client.markConversationRead(conversationId, options),
30
+ markConversationUnread: (conversationId) => client.markConversationUnread(conversationId),
31
+ clearConversationUnread: (conversationId, options) => client.clearConversationUnread(conversationId, options),
25
32
  sendTyping: (input) => client.sendTyping(input),
26
33
  onMessage: (conversationId, handler, onError) => client.realtime.onMessage(conversationId, {
27
34
  onEvent: handler,
@@ -91,6 +98,31 @@ function mergeConversations(current, incoming) {
91
98
  for (const conversation of incoming) byId.set(conversation.id, conversation);
92
99
  return [...byId.values()];
93
100
  }
101
+ function compareInboxOrder(left, right) {
102
+ return right.activityAt.getTime() - left.activityAt.getTime() || (left.conversation.id < right.conversation.id ? 1 : left.conversation.id > right.conversation.id ? -1 : 0);
103
+ }
104
+ function mergeInboxEntries(current, incoming) {
105
+ const byId = new Map(current.map((entry) => [entry.conversation.id, entry]));
106
+ for (const entry of incoming) byId.set(entry.conversation.id, entry);
107
+ return [...byId.values()].sort(compareInboxOrder);
108
+ }
109
+ function inboxPreview(conversation, summary, currentUserId) {
110
+ const message = summary?.latestMessage;
111
+ if (!message) return "";
112
+ const first = message.media[0];
113
+ 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" : "");
114
+ if (!body) return "";
115
+ if (currentUserId !== void 0 && message.senderId === currentUserId) return `You: ${body}`;
116
+ if (conversation.participants.length > 2) {
117
+ const sender = conversation.participants.find((participant) => participant.appUserId === message.senderId || participant.id === message.senderId);
118
+ const name = sender?.name.trim();
119
+ if (name) return `${name}: ${body}`;
120
+ }
121
+ return body;
122
+ }
123
+ function formatMessageTime(date) {
124
+ return new Intl.DateTimeFormat(void 0, { hour: "numeric", minute: "2-digit" }).format(date);
125
+ }
94
126
  function mergeMessages(current, incoming) {
95
127
  const byId = new Map(current.map((message) => [message.id, message]));
96
128
  for (const message of incoming) byId.set(message.id, message);
@@ -187,6 +219,10 @@ function readEntry(participant) {
187
219
  function acknowledgement() {
188
220
  return { inFlight: void 0, followUp: false, suppressed: false, target: void 0, acknowledged: void 0, unacknowledgeable: /* @__PURE__ */ new Set() };
189
221
  }
222
+ function capture(conversation) {
223
+ const membership = conversation?.membership;
224
+ return { version: membership?.privateStateVersion, clearPending: membership?.unreadMarkedAt != null };
225
+ }
190
226
  function isTargetMiss(cause) {
191
227
  if (typeof cause !== "object" || cause === null) return false;
192
228
  const { code, status } = cause;
@@ -247,6 +283,7 @@ var ConversationStore = class {
247
283
  // Keep tombstones until an explicit reload/session change, including across refreshes.
248
284
  deleted = /* @__PURE__ */ new Set();
249
285
  ack = acknowledgement();
286
+ captured = capture();
250
287
  // Visible until the platform reports otherwise; unknown/prerender/no document count as visible.
251
288
  visible = true;
252
289
  sendRevision;
@@ -312,6 +349,7 @@ var ConversationStore = class {
312
349
  this.hydrationPool.queued.clear();
313
350
  this.deleted.clear();
314
351
  this.ack = acknowledgement();
352
+ this.captured = capture();
315
353
  this.sendRevision = void 0;
316
354
  this.activeSend = void 0;
317
355
  this.refreshQueued = false;
@@ -556,9 +594,13 @@ var ConversationStore = class {
556
594
  this.validatePage(page);
557
595
  this.cursor = page.at(-1);
558
596
  this.patch({ conversation, messages: this.overlay(page, revision), hasOlderMessages: page.length === this.pageSize });
597
+ this.captured = capture(conversation);
559
598
  this.mergeReads(conversation.participants.map(readEntry));
560
599
  this.prune(revision);
561
- if (this.options.markReadOnLoad ?? true) await this.acknowledge(true);
600
+ if ((this.options.markReadOnLoad ?? true) || this.ack.suppressed) {
601
+ this.ack.suppressed = false;
602
+ await this.acknowledge(true);
603
+ }
562
604
  } catch (cause) {
563
605
  this.fail(cause, generation, true);
564
606
  } finally {
@@ -615,9 +657,12 @@ var ConversationStore = class {
615
657
  const survivingIds = new Set(reconciled.map((message) => message.id));
616
658
  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));
617
659
  for (const id of known) if (!survivingIds.has(id)) this.forget(id);
660
+ const opening = this.state.conversation === null;
618
661
  this.patch({ conversation, messages: reconciled, hasOlderMessages: hasOlder });
662
+ if (opening) this.captured = capture(conversation);
619
663
  this.mergeReads(conversation.participants.map(readEntry));
620
664
  this.prune(revision);
665
+ if (opening) void this.resumeAcknowledgement();
621
666
  } catch (cause) {
622
667
  this.fail(cause, generation, true);
623
668
  } finally {
@@ -651,15 +696,22 @@ var ConversationStore = class {
651
696
  }
652
697
  }
653
698
  };
654
- /** Acknowledge through the newest rendered row now, regardless of visibility; no request without a target. */
699
+ /** Acknowledge through the newest rendered row now, regardless of visibility; no acknowledgement without a
700
+ * target (a room opened with a marker that renders nothing clears the marker instead, once).
701
+ */
655
702
  markRead = () => this.alive() ? this.acknowledge(false) : Promise.resolve();
656
703
  /** Automatic acknowledgements wait while hidden and are re-issued (once) on becoming visible. */
657
704
  setVisible = (visible) => {
658
705
  this.visible = visible;
659
- if (!visible || !this.ack.suppressed || !this.alive()) return;
660
- this.ack.suppressed = false;
661
- void this.acknowledge(true);
706
+ if (!visible || !this.alive()) return;
707
+ void this.resumeAcknowledgement();
662
708
  };
709
+ /** Re-issue (once) the automatic acknowledgement that waited while hidden or before this open's DTO, if any. */
710
+ resumeAcknowledgement() {
711
+ if (!this.ack.suppressed) return Promise.resolve();
712
+ this.ack.suppressed = false;
713
+ return this.acknowledge(true);
714
+ }
663
715
  /** The newest non-pending rendered row by (createdAt, id), never by list index and never a raw realtime
664
716
  * row; rows the server does not know are skipped, and nothing at or before the accepted target is re-sent.
665
717
  */
@@ -674,7 +726,7 @@ var ConversationStore = class {
674
726
  /** Resolves when the request this call issued or joined settles; a follow-up is issued, not awaited. */
675
727
  acknowledge(automatic) {
676
728
  const ack = this.ack;
677
- if (automatic && !this.visible) {
729
+ if (automatic && (!this.visible || this.state.conversation === null)) {
678
730
  ack.suppressed = true;
679
731
  return Promise.resolve();
680
732
  }
@@ -687,9 +739,10 @@ var ConversationStore = class {
687
739
  issue(ack, generation) {
688
740
  ack.followUp = false;
689
741
  const target = this.ackTarget(ack);
690
- if (!target) return void 0;
691
- ack.target = target.id;
692
- ack.inFlight = this.send(ack, generation, target).finally(() => {
742
+ const request = target ? this.send(ack, generation, target) : this.clearMarker(ack, generation);
743
+ if (!request) return void 0;
744
+ ack.target = target?.id;
745
+ ack.inFlight = request.finally(() => {
693
746
  ack.inFlight = void 0;
694
747
  ack.target = void 0;
695
748
  if (!this.alive(generation) || !ack.followUp) return;
@@ -700,9 +753,31 @@ var ConversationStore = class {
700
753
  });
701
754
  return ack.inFlight;
702
755
  }
756
+ /** A room opened with the caller's unread marker that renders no non-pending, acknowledgeable row cannot clear
757
+ * it through a targeted acknowledgement, so it asks the adapter to clear the marker conditionally on the captured
758
+ * version, once per open, under the acknowledgement triggers and visibility gating. Rows rendered later clear it
759
+ * through their acknowledgements; adapters without the member leave it; `cleared: false` is not an error.
760
+ */
761
+ clearMarker(ack, generation) {
762
+ const captured = this.captured;
763
+ 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;
764
+ captured.clearPending = false;
765
+ return this.clearUnread(captured.version, generation);
766
+ }
767
+ async clearUnread(version2, generation) {
768
+ try {
769
+ await this.client.clearConversationUnread(this.room, { ifVersion: version2 });
770
+ } catch (cause) {
771
+ if (this.alive(generation)) this.fail(cause, generation);
772
+ }
773
+ }
703
774
  async send(ack, generation, target) {
704
775
  try {
705
- await this.client.markConversationRead(this.room, { throughMessageId: target.id });
776
+ const version2 = this.captured.version;
777
+ await this.client.markConversationRead(this.room, {
778
+ throughMessageId: target.id,
779
+ ...version2 === void 0 ? {} : { privateStateVersion: version2 }
780
+ });
706
781
  if (!this.alive(generation)) return;
707
782
  if (!ack.acknowledged || compare(target, ack.acknowledged) > 0) ack.acknowledged = target;
708
783
  } catch (cause) {
@@ -889,9 +964,6 @@ var appearanceProps = {
889
964
  density: { type: String, default: "comfortable" },
890
965
  unstyled: { type: Boolean, default: false }
891
966
  };
892
- function defaultFormatTime(date) {
893
- return new Intl.DateTimeFormat(void 0, { hour: "numeric", minute: "2-digit" }).format(date);
894
- }
895
967
  function defaultMedia(media, open, imageLoading) {
896
968
  const tag = open ? "button" : "div";
897
969
  const interactive = open ? { type: "button", onClick: open } : {};
@@ -942,7 +1014,7 @@ var MessageListView = defineComponent2({
942
1014
  paginationThreshold: { type: Number, default: 240 },
943
1015
  reverse: { type: Boolean, default: true },
944
1016
  stickToBottom: { type: Boolean, default: true },
945
- formatTime: { type: Function, default: defaultFormatTime },
1017
+ formatTime: { type: Function, default: formatMessageTime },
946
1018
  imageLoading: { type: String, default: "lazy" }
947
1019
  },
948
1020
  emits: ["load-older", "attachment-click"],
@@ -1504,30 +1576,64 @@ import {
1504
1576
  import { computed as computed3, getCurrentScope as getCurrentScope2, onScopeDispose as onScopeDispose2, shallowRef as shallowRef2, toValue as toValue2, watch as watch3 } from "vue";
1505
1577
 
1506
1578
  // src/conversation-list-store.ts
1579
+ var defaultActivityRefreshWindowMs = 500;
1507
1580
  function blank2(filter) {
1508
- return { conversations: [], filter, isInitialLoading: false, isLoadingMore: false, hasMore: true, hasLoaded: false, error: null };
1581
+ return {
1582
+ conversations: [],
1583
+ summaries: /* @__PURE__ */ new Map(),
1584
+ currentUserId: "",
1585
+ filter,
1586
+ isInitialLoading: false,
1587
+ isLoadingMore: false,
1588
+ hasMore: true,
1589
+ hasLoaded: false,
1590
+ error: null
1591
+ };
1592
+ }
1593
+ function statusOf(cause) {
1594
+ return typeof cause === "object" && cause !== null && "status" in cause ? cause.status : void 0;
1595
+ }
1596
+ function summaryOf(entry) {
1597
+ const { conversation: _conversation, ...summary } = entry;
1598
+ return summary;
1599
+ }
1600
+ function ids(entries) {
1601
+ return entries.map((entry) => entry.conversation);
1509
1602
  }
1510
1603
  var ConversationListStore = class {
1511
1604
  constructor(options) {
1512
1605
  this.options = options;
1513
1606
  this.owner = options.client.sessionIdentity;
1607
+ this.user = this.owner ? options.client.currentUserId : "";
1514
1608
  this.pageSize = options.pageSize ?? 30;
1515
1609
  if (!Number.isInteger(this.pageSize) || this.pageSize < 1 || this.pageSize > 100) {
1516
1610
  throw new RangeError("pageSize must be an integer between 1 and 100");
1517
1611
  }
1612
+ this.activityRefreshWindowMs = options.activityRefreshWindowMs ?? defaultActivityRefreshWindowMs;
1613
+ if (!Number.isFinite(this.activityRefreshWindowMs) || this.activityRefreshWindowMs < 0) {
1614
+ throw new RangeError("activityRefreshWindowMs must be a non-negative number");
1615
+ }
1518
1616
  this.state = blank2(options.initialFilter ?? {});
1519
1617
  }
1520
1618
  options;
1521
1619
  owner;
1620
+ user;
1522
1621
  pageSize;
1622
+ activityRefreshWindowMs;
1523
1623
  state;
1524
1624
  source = [];
1625
+ entries = [];
1525
1626
  offset = 0;
1627
+ cursor = null;
1628
+ inboxUnavailable = false;
1629
+ inboxWarned = false;
1526
1630
  generation = 0;
1527
1631
  lifecycleGeneration = 0;
1528
1632
  disposed = true;
1529
1633
  lifecycle;
1530
1634
  inbox;
1635
+ activity;
1636
+ activityTimer;
1531
1637
  refreshQueued = false;
1532
1638
  refreshing = false;
1533
1639
  listeners = /* @__PURE__ */ new Set();
@@ -1545,12 +1651,27 @@ var ConversationListStore = class {
1545
1651
  alive(generation = this.generation) {
1546
1652
  return !this.disposed && generation === this.generation && this.owner !== null && this.options.client.sessionIdentity === this.owner;
1547
1653
  }
1654
+ get inboxMode() {
1655
+ return !this.options.pageLoader && typeof this.options.client.listInbox === "function" && !this.inboxUnavailable;
1656
+ }
1657
+ currentUserId() {
1658
+ return this.inboxMode ? this.user : "";
1659
+ }
1548
1660
  start = (autoLoad = true) => {
1549
1661
  if (!this.owner || this.options.client.sessionIdentity !== this.owner) return;
1550
1662
  if (!this.disposed) return;
1551
1663
  this.disposed = false;
1664
+ this.inboxUnavailable = false;
1552
1665
  const lifecycleGeneration = ++this.lifecycleGeneration;
1666
+ const current = () => this.alive() && lifecycleGeneration === this.lifecycleGeneration;
1667
+ const reconcile = (cause) => {
1668
+ if (current()) {
1669
+ this.patch({ error: cause });
1670
+ this.queueRefresh();
1671
+ }
1672
+ };
1553
1673
  try {
1674
+ this.patch({ currentUserId: this.currentUserId() });
1554
1675
  const subscription = this.options.client.onConnectionEvent({
1555
1676
  onEvent: () => {
1556
1677
  },
@@ -1562,15 +1683,20 @@ var ConversationListStore = class {
1562
1683
  else void subscription.unsubscribe().catch(() => void 0);
1563
1684
  if (!this.alive()) return;
1564
1685
  const inbox = this.options.client.onInboxChanged(() => {
1565
- if (this.alive() && lifecycleGeneration === this.lifecycleGeneration) this.queueRefresh();
1566
- }, (cause) => {
1567
- if (this.alive() && lifecycleGeneration === this.lifecycleGeneration) {
1568
- this.patch({ error: cause });
1569
- this.queueRefresh();
1570
- }
1571
- });
1686
+ if (!current()) return;
1687
+ this.clearActivityTimer();
1688
+ this.queueRefresh();
1689
+ }, reconcile);
1572
1690
  if (this.alive()) this.inbox = inbox;
1573
1691
  else void inbox.unsubscribe().catch(() => void 0);
1692
+ if (!this.alive()) return;
1693
+ if (this.inboxMode && typeof this.options.client.onInboxActivity === "function") {
1694
+ const activity = this.options.client.onInboxActivity(() => {
1695
+ if (current()) this.scheduleActivityRefresh(lifecycleGeneration);
1696
+ }, reconcile);
1697
+ if (this.alive()) this.activity = activity;
1698
+ else void activity.unsubscribe().catch(() => void 0);
1699
+ }
1574
1700
  if (autoLoad) void this.loadInitial();
1575
1701
  } catch (cause) {
1576
1702
  if (this.alive()) this.patch({ error: cause });
@@ -1580,27 +1706,109 @@ var ConversationListStore = class {
1580
1706
  this.disposed = true;
1581
1707
  this.generation++;
1582
1708
  this.lifecycleGeneration++;
1709
+ this.clearActivityTimer();
1583
1710
  const subscription = this.lifecycle;
1584
1711
  this.lifecycle = void 0;
1585
1712
  if (subscription) void subscription.unsubscribe().catch(() => void 0);
1586
1713
  if (this.inbox) void this.inbox.unsubscribe().catch(() => void 0);
1587
1714
  this.inbox = void 0;
1715
+ this.stopActivity();
1588
1716
  this.refreshQueued = false;
1589
1717
  this.refreshing = false;
1590
1718
  this.source = [];
1719
+ this.entries = [];
1591
1720
  this.offset = 0;
1721
+ this.cursor = null;
1592
1722
  this.patch(blank2(this.state.filter));
1593
1723
  };
1724
+ stopActivity() {
1725
+ if (this.activity) void this.activity.unsubscribe().catch(() => void 0);
1726
+ this.activity = void 0;
1727
+ }
1728
+ clearActivityTimer() {
1729
+ if (this.activityTimer !== void 0) clearTimeout(this.activityTimer);
1730
+ this.activityTimer = void 0;
1731
+ }
1732
+ /** Max-wait throttle: the first signal opens a window; later signals wait for it; one refresh runs when it closes. */
1733
+ scheduleActivityRefresh(lifecycleGeneration) {
1734
+ if (this.activityRefreshWindowMs === 0) return this.queueRefresh();
1735
+ if (this.activityTimer !== void 0) return;
1736
+ const timer = setTimeout(() => {
1737
+ this.activityTimer = void 0;
1738
+ if (this.alive() && lifecycleGeneration === this.lifecycleGeneration) this.queueRefresh();
1739
+ }, this.activityRefreshWindowMs);
1740
+ timer.unref?.();
1741
+ this.activityTimer = timer;
1742
+ }
1594
1743
  fail(cause, generation) {
1595
1744
  if (!this.alive(generation)) return;
1596
- const status = typeof cause === "object" && cause !== null && "status" in cause ? cause.status : void 0;
1745
+ const status = statusOf(cause);
1597
1746
  if (status === 401 || status === 403 || status === 404) {
1598
1747
  this.source = [];
1748
+ this.entries = [];
1599
1749
  this.offset = 0;
1600
- this.patch({ conversations: [], hasMore: false });
1750
+ this.cursor = null;
1751
+ this.patch({ conversations: [], summaries: /* @__PURE__ */ new Map(), hasMore: false });
1601
1752
  }
1602
1753
  this.patch({ error: cause });
1603
1754
  }
1755
+ /** Run an operation in inbox mode, falling back to the legacy path for the rest of this store's life when the
1756
+ * inbox route is absent (404: rollback, staging). Loaded rows are kept and the same operation continues.
1757
+ */
1758
+ async withFallback(generation, inbox, legacy) {
1759
+ if (!this.inboxMode) return legacy();
1760
+ try {
1761
+ await inbox();
1762
+ } catch (cause) {
1763
+ if (!this.alive(generation) || statusOf(cause) !== 404) throw cause;
1764
+ this.inboxUnavailable = true;
1765
+ this.clearActivityTimer();
1766
+ this.stopActivity();
1767
+ this.entries = [];
1768
+ this.cursor = null;
1769
+ this.offset = this.source.length;
1770
+ if (!this.inboxWarned) {
1771
+ this.inboxWarned = true;
1772
+ console.warn("ConvoKit inbox endpoint unavailable (404); using getConversations without previews or unread counts.");
1773
+ }
1774
+ this.patch({ summaries: /* @__PURE__ */ new Map(), currentUserId: "" });
1775
+ await legacy();
1776
+ }
1777
+ }
1778
+ validateInboxPage(page, limit, requestedCursor) {
1779
+ const seen = /* @__PURE__ */ new Set();
1780
+ for (const entry of page.entries) {
1781
+ const id = entry.conversation.id;
1782
+ if (!id.trim() || seen.has(id)) throw new Error("Invalid conversation page");
1783
+ seen.add(id);
1784
+ }
1785
+ if (page.entries.length > limit) throw new Error("Invalid conversation page");
1786
+ if (page.nextCursor !== null && (page.nextCursor === requestedCursor || page.entries.length === 0)) {
1787
+ throw new Error("Inbox pagination did not advance");
1788
+ }
1789
+ }
1790
+ /** Swap rows, summaries, cursor and hasMore together, filtered by the filter current at commit time. */
1791
+ commitInbox(entries, cursor) {
1792
+ this.entries = entries;
1793
+ this.source = ids(entries);
1794
+ this.cursor = cursor;
1795
+ this.patch({
1796
+ conversations: applyConversationFilter(this.source, this.state.filter),
1797
+ summaries: new Map(entries.map((entry) => [entry.conversation.id, summaryOf(entry)])),
1798
+ hasMore: cursor !== null
1799
+ });
1800
+ }
1801
+ async loadInboxUntilVisible(generation, filter) {
1802
+ const visibleBefore = applyConversationFilter(this.source, filter).length;
1803
+ while (this.alive(generation)) {
1804
+ const requested = this.cursor;
1805
+ const page = await this.options.client.listInbox({ limit: this.pageSize, cursor: requested, archived: filter.archived ?? false });
1806
+ if (!this.alive(generation)) return;
1807
+ this.validateInboxPage(page, this.pageSize, requested);
1808
+ this.commitInbox(mergeInboxEntries(this.entries, page.entries), page.nextCursor);
1809
+ if (page.nextCursor === null || this.state.conversations.length > visibleBefore) return;
1810
+ }
1811
+ }
1604
1812
  async loadUntilVisible(generation, filter) {
1605
1813
  const visibleBefore = applyConversationFilter(this.source, filter).length;
1606
1814
  while (this.alive(generation)) {
@@ -1627,10 +1835,17 @@ var ConversationListStore = class {
1627
1835
  const generation = ++this.generation;
1628
1836
  this.refreshing = false;
1629
1837
  this.source = [];
1838
+ this.entries = [];
1630
1839
  this.offset = 0;
1631
- this.patch({ ...blank2(this.state.filter), isInitialLoading: true });
1840
+ this.cursor = null;
1841
+ this.patch({ ...blank2(this.state.filter), currentUserId: this.currentUserId(), isInitialLoading: true });
1842
+ const filter = this.state.filter;
1632
1843
  try {
1633
- await this.loadUntilVisible(generation, this.state.filter);
1844
+ await this.withFallback(
1845
+ generation,
1846
+ () => this.loadInboxUntilVisible(generation, filter),
1847
+ () => this.loadUntilVisible(generation, filter)
1848
+ );
1634
1849
  } catch (cause) {
1635
1850
  this.fail(cause, generation);
1636
1851
  } finally {
@@ -1652,6 +1867,47 @@ var ConversationListStore = class {
1652
1867
  void this.refresh();
1653
1868
  });
1654
1869
  }
1870
+ /** Re-walk the inbox from the head until the loaded window is covered and something is visible, or the inbox
1871
+ * ends. Rooms that moved are re-positioned by the merge; an exhausted inbox publishes what it found.
1872
+ */
1873
+ async refreshInbox(generation, filter) {
1874
+ const target = Math.max(this.pageSize, this.entries.length);
1875
+ let rows = [], consumed = 0, cursor = null;
1876
+ while (this.alive(generation)) {
1877
+ const remaining = target - consumed;
1878
+ const limit = remaining >= 1 ? Math.min(100, remaining) : this.pageSize;
1879
+ const page = await this.options.client.listInbox({ limit, cursor, archived: filter.archived ?? false });
1880
+ if (!this.alive(generation)) return;
1881
+ this.validateInboxPage(page, limit, cursor);
1882
+ rows = mergeInboxEntries(rows, page.entries);
1883
+ consumed += page.entries.length;
1884
+ cursor = page.nextCursor;
1885
+ if (cursor === null || consumed >= target && applyConversationFilter(ids(rows), this.state.filter).length > 0) break;
1886
+ }
1887
+ if (!this.alive(generation)) return;
1888
+ this.commitInbox(rows, cursor);
1889
+ }
1890
+ async refreshLegacy(generation, filter) {
1891
+ const target = Math.max(this.pageSize, this.offset);
1892
+ const compare2 = (a, b) => a.createdAt.getTime() - b.createdAt.getTime() || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
1893
+ const boundary = this.options.pageLoader ? void 0 : this.source.reduce((oldest, row) => !oldest || compare2(row, oldest) < 0 ? row : oldest, void 0);
1894
+ let rows = [], offset = 0, hasMore = true;
1895
+ while (this.alive(generation)) {
1896
+ 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 });
1897
+ if (!this.alive(generation)) return;
1898
+ if (page.length > this.pageSize || page.some((row) => !row.id.trim())) throw new Error("Invalid conversation page");
1899
+ const merged = mergeConversations(rows, page);
1900
+ if (page.length === this.pageSize && merged.length === rows.length) throw new Error("Conversation pagination did not advance");
1901
+ rows = merged;
1902
+ offset += page.length;
1903
+ hasMore = page.length === this.pageSize;
1904
+ if (!hasMore || offset >= target && applyConversationFilter(rows, this.state.filter).length > 0 && (!boundary || page.some((row) => compare2(row, boundary) <= 0))) break;
1905
+ }
1906
+ if (!this.alive(generation)) return;
1907
+ this.source = rows;
1908
+ this.offset = offset;
1909
+ this.patch({ conversations: applyConversationFilter(rows, this.state.filter), hasMore });
1910
+ }
1655
1911
  /** Replace the loaded window atomically, retaining filters and rows during transient failures. */
1656
1912
  refresh = async () => {
1657
1913
  if (!this.alive()) return;
@@ -1662,28 +1918,14 @@ var ConversationListStore = class {
1662
1918
  if (!this.state.hasLoaded) return this.loadInitial();
1663
1919
  const generation = this.generation;
1664
1920
  const filter = this.state.filter;
1665
- const target = Math.max(this.pageSize, this.offset);
1666
- const compare2 = (a, b) => a.createdAt.getTime() - b.createdAt.getTime() || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
1667
- const boundary = this.options.pageLoader ? void 0 : this.source.reduce((oldest, row) => !oldest || compare2(row, oldest) < 0 ? row : oldest, void 0);
1668
1921
  this.refreshing = true;
1669
1922
  this.patch({ error: null });
1670
1923
  try {
1671
- let rows = [], offset = 0, hasMore = true;
1672
- while (this.alive(generation)) {
1673
- 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 });
1674
- if (!this.alive(generation)) return;
1675
- if (page.length > this.pageSize || page.some((row) => !row.id.trim())) throw new Error("Invalid conversation page");
1676
- const merged = mergeConversations(rows, page);
1677
- if (page.length === this.pageSize && merged.length === rows.length) throw new Error("Conversation pagination did not advance");
1678
- rows = merged;
1679
- offset += page.length;
1680
- hasMore = page.length === this.pageSize;
1681
- if (!hasMore || offset >= target && applyConversationFilter(rows, this.state.filter).length > 0 && (!boundary || page.some((row) => compare2(row, boundary) <= 0))) break;
1682
- }
1683
- if (!this.alive(generation)) return;
1684
- this.source = rows;
1685
- this.offset = offset;
1686
- this.patch({ conversations: applyConversationFilter(rows, this.state.filter), hasMore });
1924
+ await this.withFallback(
1925
+ generation,
1926
+ () => this.refreshInbox(generation, filter),
1927
+ () => this.refreshLegacy(generation, filter)
1928
+ );
1687
1929
  } catch (cause) {
1688
1930
  this.fail(cause, generation);
1689
1931
  } finally {
@@ -1696,9 +1938,14 @@ var ConversationListStore = class {
1696
1938
  loadMore = async () => {
1697
1939
  if (!this.alive() || this.refreshing || this.state.isInitialLoading || this.state.isLoadingMore || !this.state.hasMore) return;
1698
1940
  const generation = this.generation;
1941
+ const filter = this.state.filter;
1699
1942
  this.patch({ isLoadingMore: true, error: null });
1700
1943
  try {
1701
- await this.loadUntilVisible(generation, this.state.filter);
1944
+ await this.withFallback(
1945
+ generation,
1946
+ () => this.loadInboxUntilVisible(generation, filter),
1947
+ () => this.loadUntilVisible(generation, filter)
1948
+ );
1702
1949
  } catch (cause) {
1703
1950
  this.fail(cause, generation);
1704
1951
  } finally {
@@ -1716,6 +1963,66 @@ var ConversationListStore = class {
1716
1963
  else if (!this.state.conversations.length && this.state.hasMore) await this.loadMore();
1717
1964
  };
1718
1965
  setQuery = (query) => this.setFilter({ ...this.state.filter, query });
1966
+ /** Mark a room unread for the viewer only; the row's summary takes the response (D10). Rejects when the adapter
1967
+ * lacks `markConversationUnread` or the store is not active; a request failure is reported through `error`
1968
+ * without evicting rows and rejects.
1969
+ */
1970
+ markUnread = async (conversationId) => {
1971
+ const client = this.options.client;
1972
+ if (typeof client.markConversationUnread !== "function") {
1973
+ throw new TypeError("markUnread requires a ConvoKitUiClient adapter with markConversationUnread (core SDK 0.7)");
1974
+ }
1975
+ this.assertActive();
1976
+ this.applyPrivateState(conversationId, await this.mutate(client.markConversationUnread(conversationId)));
1977
+ };
1978
+ /** Remove the viewer's marker (conditionally on `options.ifVersion`); resolves to the response's `cleared` ("this
1979
+ * request removed the marker", not "the room is read") and patches the summary on true and false alike (D10).
1980
+ * Rejects when the adapter lacks `clearConversationUnread` or the store is not active; failures are reported like
1981
+ * `markUnread`.
1982
+ */
1983
+ clearUnread = async (conversationId, options) => {
1984
+ const client = this.options.client;
1985
+ if (typeof client.clearConversationUnread !== "function") {
1986
+ throw new TypeError("clearUnread requires a ConvoKitUiClient adapter with clearConversationUnread (core SDK 0.7)");
1987
+ }
1988
+ this.assertActive();
1989
+ const result = await this.mutate(client.clearConversationUnread(conversationId, options));
1990
+ this.applyPrivateState(conversationId, result);
1991
+ return result.cleared;
1992
+ };
1993
+ /** A disposed or session-evicted store never sends a private-state mutation: on a shared client it could go out
1994
+ * under a replacement login. Rejected without touching `error` (there is no live snapshot to report into).
1995
+ */
1996
+ assertActive() {
1997
+ if (!this.alive()) throw new Error("ConversationListStore is not active");
1998
+ }
1999
+ async mutate(request) {
2000
+ try {
2001
+ return await request;
2002
+ } catch (cause) {
2003
+ if (this.alive()) this.patch({ error: cause });
2004
+ throw cause;
2005
+ }
2006
+ }
2007
+ /** Apply a mark/clear response to the row's CURRENT summary (a refresh may have swapped it) as one unit, only while
2008
+ * the store is alive and the response is not older than the stored version: a delayed response never resurrects a
2009
+ * marker a newer action removed (equal versions are an idempotent no-op). `isUnread` is recomputed from the stored
2010
+ * counts and the response marker. Other devices learn of the change through `inbox_activity`.
2011
+ */
2012
+ applyPrivateState(conversationId, state) {
2013
+ if (!this.alive()) return;
2014
+ const current = this.entries.find((entry) => entry.conversation.id === conversationId);
2015
+ if (!current || state.privateStateVersion < current.privateStateVersion) return;
2016
+ const { unreadMarkedAt, privateStateVersion } = state;
2017
+ const patched = {
2018
+ ...current,
2019
+ unreadMarkedAt,
2020
+ privateStateVersion,
2021
+ isUnread: current.unreadCount > 0 || current.unreadCountCapped || unreadMarkedAt !== null
2022
+ };
2023
+ this.entries = this.entries.map((entry) => entry === current ? patched : entry);
2024
+ this.patch({ summaries: new Map(this.entries.map((entry) => [entry.conversation.id, summaryOf(entry)])) });
2025
+ }
1719
2026
  };
1720
2027
 
1721
2028
  // src/composables/use-conversation-list.ts
@@ -1750,6 +2057,8 @@ function useConversationList(options) {
1750
2057
  });
1751
2058
  return {
1752
2059
  conversations: field("conversations"),
2060
+ summaries: field("summaries"),
2061
+ currentUserId: field("currentUserId"),
1753
2062
  filter: field("filter"),
1754
2063
  isInitialLoading: field("isInitialLoading"),
1755
2064
  isLoadingMore: field("isLoadingMore"),
@@ -1761,6 +2070,8 @@ function useConversationList(options) {
1761
2070
  loadMore: () => store.loadMore(),
1762
2071
  setFilter: (filter) => store.setFilter(filter),
1763
2072
  setQuery: (query) => store.setQuery(query),
2073
+ markUnread: (conversationId) => store.markUnread(conversationId),
2074
+ clearUnread: (conversationId, options2) => store.clearUnread(conversationId, options2),
1764
2075
  dispose
1765
2076
  };
1766
2077
  }
@@ -1775,6 +2086,8 @@ var appearanceProps3 = {
1775
2086
  var listViewProps = {
1776
2087
  ...appearanceProps3,
1777
2088
  conversations: { type: Array, required: true },
2089
+ summaries: { type: Object, default: void 0 },
2090
+ currentUserId: { type: String, default: void 0 },
1778
2091
  selectedConversationId: { type: String, default: void 0 },
1779
2092
  onConversationSelect: { type: Function, default: void 0 },
1780
2093
  onRefresh: { type: Function, default: void 0 },
@@ -1824,6 +2137,28 @@ var ConversationListView = defineComponent4({
1824
2137
  const refresh = () => {
1825
2138
  return props.onRefresh?.();
1826
2139
  };
2140
+ const inlineRetry = () => {
2141
+ if (props.hasMore && props.onLoadMore) return () => {
2142
+ lastRequestedLength = null;
2143
+ void requestMore();
2144
+ };
2145
+ if (props.onRefresh) return () => {
2146
+ void refresh();
2147
+ };
2148
+ return void 0;
2149
+ };
2150
+ const unreadBadge = (summary) => {
2151
+ if (summary.unreadCount > 0 || summary.unreadCountCapped) {
2152
+ const capped = summary.unreadCountCapped || summary.unreadCount > 99;
2153
+ return [h4("span", {
2154
+ class: "ckui-unread-badge",
2155
+ role: "img",
2156
+ "aria-label": `${summary.unreadCountCapped ? "99+" : summary.unreadCount} unread`
2157
+ }, [h4("span", { "aria-hidden": "true" }, capped ? "99+" : String(summary.unreadCount))])];
2158
+ }
2159
+ if (summary.isUnread) return [h4("span", { class: "ckui-unread-badge ckui-unread-badge--dot", role: "img", "aria-label": "Unread" })];
2160
+ return [];
2161
+ };
1827
2162
  const renderContent = () => {
1828
2163
  const currentAppearance = appearance();
1829
2164
  if (props.isInitialLoading && props.conversations.length === 0) {
@@ -1855,10 +2190,21 @@ var ConversationListView = defineComponent4({
1855
2190
  const children = props.conversations.flatMap((conversation, index) => {
1856
2191
  const selected = props.selectedConversationId === conversation.id;
1857
2192
  const select = () => selectConversation(conversation);
1858
- const slotProps = { conversation, index, selected, select };
2193
+ const summary = props.summaries?.get(conversation.id);
2194
+ const slotProps = {
2195
+ conversation,
2196
+ index,
2197
+ selected,
2198
+ select,
2199
+ ...summary ? { summary } : {},
2200
+ ...props.currentUserId === void 0 ? {} : { currentUserId: props.currentUserId }
2201
+ };
2202
+ const preview = inboxPreview(conversation, summary, props.currentUserId);
2203
+ const unread = summary !== void 0 && (summary.isUnread || summary.unreadCount > 0 || summary.unreadCountCapped);
1859
2204
  const item = slots["conversation-item"]?.(slotProps) ?? h4("button", {
1860
2205
  type: "button",
1861
2206
  "data-selected": selected || void 0,
2207
+ "data-unread": unread || void 0,
1862
2208
  "aria-current": selected ? "true" : void 0,
1863
2209
  onClick: select,
1864
2210
  class: partClass("listItem", currentAppearance, "ckui-conversation-item"),
@@ -1872,8 +2218,14 @@ var ConversationListView = defineComponent4({
1872
2218
  }),
1873
2219
  h4("span", { class: "ckui-conversation-item__body" }, [
1874
2220
  h4("strong", conversation.displayTitle),
1875
- h4("span", conversation.participants.map((participant) => participant.name).join(", ") || conversation.description || "No participants")
2221
+ h4("span", preview || conversation.participants.map((participant) => participant.name).join(", ") || conversation.description || "No participants")
1876
2222
  ]),
2223
+ // Spread rather than emit `null`: a null child renders a `<!---->` comment, and rows without a
2224
+ // summary must keep 0.5's exact markup.
2225
+ ...summary ? [h4("span", { class: "ckui-conversation-item__meta" }, [
2226
+ h4("time", { class: "ckui-conversation-item__time", datetime: summary.activityAt.toISOString() }, formatMessageTime(summary.activityAt)),
2227
+ ...unreadBadge(summary)
2228
+ ])] : [],
1877
2229
  h4(ChevronRight, { size: 18, "aria-hidden": "true" })
1878
2230
  ]);
1879
2231
  const nodes = [h4("div", { key: conversation.id, role: "listitem" }, [item])];
@@ -1883,13 +2235,15 @@ var ConversationListView = defineComponent4({
1883
2235
  return nodes;
1884
2236
  });
1885
2237
  if (props.error) {
1886
- children.push(slots.error?.({ error: props.error, retry: requestMore }) ?? h4("div", {
2238
+ const retry = inlineRetry();
2239
+ children.push(slots.error?.({ error: props.error, ...retry ? { retry } : {} }) ?? h4("div", {
1887
2240
  class: partClass("error", currentAppearance, "ckui-inline-state ckui-state--error"),
1888
2241
  style: partStyle("error", currentAppearance),
1889
2242
  role: "alert"
1890
- }, [h4("span", errorMessage(props.error)), h4("button", { type: "button", class: "ckui-link-button", onClick: () => {
1891
- void requestMore();
1892
- } }, "Retry")]));
2243
+ }, [
2244
+ h4("span", errorMessage(props.error)),
2245
+ ...retry ? [h4("button", { type: "button", class: "ckui-link-button", onClick: retry }, "Retry")] : []
2246
+ ]));
1893
2247
  } else if (props.isLoadingMore) {
1894
2248
  children.push(slots["load-more"]?.() ?? h4("div", {
1895
2249
  class: partClass("loading", currentAppearance, "ckui-inline-state"),
@@ -1950,6 +2304,7 @@ var ConversationList = defineComponent4({
1950
2304
  initialFilter: { type: Object, default: void 0 },
1951
2305
  pageSize: { type: Number, default: 30 },
1952
2306
  autoLoad: { type: Boolean, default: true },
2307
+ activityRefreshWindowMs: { type: Number, default: void 0 },
1953
2308
  onControllerChange: { type: Function, default: void 0 }
1954
2309
  },
1955
2310
  emits: ["conversation-select", "controller-change"],
@@ -1959,7 +2314,8 @@ var ConversationList = defineComponent4({
1959
2314
  ...props.pageLoader ? { pageLoader: props.pageLoader } : {},
1960
2315
  ...props.initialFilter ? { initialFilter: props.initialFilter } : {},
1961
2316
  pageSize: props.pageSize,
1962
- autoLoad: props.autoLoad
2317
+ autoLoad: props.autoLoad,
2318
+ ...props.activityRefreshWindowMs === void 0 ? {} : { activityRefreshWindowMs: props.activityRefreshWindowMs }
1963
2319
  });
1964
2320
  expose({ controller });
1965
2321
  watchEffect2(() => {
@@ -1972,8 +2328,11 @@ var ConversationList = defineComponent4({
1972
2328
  initialFilter: _initialFilter,
1973
2329
  pageSize: _pageSize,
1974
2330
  autoLoad: _autoLoad,
2331
+ activityRefreshWindowMs: _activityRefreshWindowMs,
1975
2332
  onControllerChange: _onControllerChange,
1976
2333
  conversations: _conversations,
2334
+ summaries: _summaries,
2335
+ currentUserId: _currentUserId,
1977
2336
  onRefresh: _onRefresh,
1978
2337
  onLoadMore: _onLoadMore,
1979
2338
  isInitialLoading: _isInitialLoading,
@@ -1986,6 +2345,8 @@ var ConversationList = defineComponent4({
1986
2345
  ...attrs,
1987
2346
  ...forwarded,
1988
2347
  conversations: controller.conversations.value,
2348
+ summaries: controller.summaries.value,
2349
+ currentUserId: controller.currentUserId.value,
1989
2350
  onRefresh: controller.refresh,
1990
2351
  onLoadMore: controller.loadMore,
1991
2352
  isInitialLoading: controller.isInitialLoading.value,
@@ -2019,6 +2380,7 @@ var defaultConvoKitTheme = {
2019
2380
  incomingBubble: "#f4f4f5",
2020
2381
  outgoingBubble: "#18181b",
2021
2382
  outgoingText: "#fafafa",
2383
+ badge: "#18181b",
2022
2384
  radius: "10px",
2023
2385
  avatarSize: "40px",
2024
2386
  fontFamily: 'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'
@@ -2050,6 +2412,7 @@ var ConvoKitThemeProvider = defineComponent5({
2050
2412
  "--ckui-incoming": theme.incomingBubble,
2051
2413
  "--ckui-outgoing": theme.outgoingBubble,
2052
2414
  "--ckui-outgoing-text": theme.outgoingText,
2415
+ "--ckui-badge": theme.badge,
2053
2416
  "--ckui-radius": theme.radius,
2054
2417
  "--ckui-avatar-size": theme.avatarSize,
2055
2418
  "--ckui-font": theme.fontFamily
@@ -2081,6 +2444,7 @@ export {
2081
2444
  isConvoKitPendingMessage,
2082
2445
  matchesConversation,
2083
2446
  mergeConversations,
2447
+ mergeInboxEntries,
2084
2448
  mergeMessages,
2085
2449
  readerIdsFor,
2086
2450
  useConversation,