@convokitapp/vue-ui 0.5.0 → 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,6 +21,7 @@ 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),
@@ -91,6 +96,31 @@ function mergeConversations(current, incoming) {
91
96
  for (const conversation of incoming) byId.set(conversation.id, conversation);
92
97
  return [...byId.values()];
93
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
+ }
94
124
  function mergeMessages(current, incoming) {
95
125
  const byId = new Map(current.map((message) => [message.id, message]));
96
126
  for (const message of incoming) byId.set(message.id, message);
@@ -889,9 +919,6 @@ var appearanceProps = {
889
919
  density: { type: String, default: "comfortable" },
890
920
  unstyled: { type: Boolean, default: false }
891
921
  };
892
- function defaultFormatTime(date) {
893
- return new Intl.DateTimeFormat(void 0, { hour: "numeric", minute: "2-digit" }).format(date);
894
- }
895
922
  function defaultMedia(media, open, imageLoading) {
896
923
  const tag = open ? "button" : "div";
897
924
  const interactive = open ? { type: "button", onClick: open } : {};
@@ -942,7 +969,7 @@ var MessageListView = defineComponent2({
942
969
  paginationThreshold: { type: Number, default: 240 },
943
970
  reverse: { type: Boolean, default: true },
944
971
  stickToBottom: { type: Boolean, default: true },
945
- formatTime: { type: Function, default: defaultFormatTime },
972
+ formatTime: { type: Function, default: formatMessageTime },
946
973
  imageLoading: { type: String, default: "lazy" }
947
974
  },
948
975
  emits: ["load-older", "attachment-click"],
@@ -1504,30 +1531,64 @@ import {
1504
1531
  import { computed as computed3, getCurrentScope as getCurrentScope2, onScopeDispose as onScopeDispose2, shallowRef as shallowRef2, toValue as toValue2, watch as watch3 } from "vue";
1505
1532
 
1506
1533
  // src/conversation-list-store.ts
1534
+ var defaultActivityRefreshWindowMs = 500;
1507
1535
  function blank2(filter) {
1508
- 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);
1509
1557
  }
1510
1558
  var ConversationListStore = class {
1511
1559
  constructor(options) {
1512
1560
  this.options = options;
1513
1561
  this.owner = options.client.sessionIdentity;
1562
+ this.user = this.owner ? options.client.currentUserId : "";
1514
1563
  this.pageSize = options.pageSize ?? 30;
1515
1564
  if (!Number.isInteger(this.pageSize) || this.pageSize < 1 || this.pageSize > 100) {
1516
1565
  throw new RangeError("pageSize must be an integer between 1 and 100");
1517
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
+ }
1518
1571
  this.state = blank2(options.initialFilter ?? {});
1519
1572
  }
1520
1573
  options;
1521
1574
  owner;
1575
+ user;
1522
1576
  pageSize;
1577
+ activityRefreshWindowMs;
1523
1578
  state;
1524
1579
  source = [];
1580
+ entries = [];
1525
1581
  offset = 0;
1582
+ cursor = null;
1583
+ inboxUnavailable = false;
1584
+ inboxWarned = false;
1526
1585
  generation = 0;
1527
1586
  lifecycleGeneration = 0;
1528
1587
  disposed = true;
1529
1588
  lifecycle;
1530
1589
  inbox;
1590
+ activity;
1591
+ activityTimer;
1531
1592
  refreshQueued = false;
1532
1593
  refreshing = false;
1533
1594
  listeners = /* @__PURE__ */ new Set();
@@ -1545,12 +1606,27 @@ var ConversationListStore = class {
1545
1606
  alive(generation = this.generation) {
1546
1607
  return !this.disposed && generation === this.generation && this.owner !== null && this.options.client.sessionIdentity === this.owner;
1547
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
+ }
1548
1615
  start = (autoLoad = true) => {
1549
1616
  if (!this.owner || this.options.client.sessionIdentity !== this.owner) return;
1550
1617
  if (!this.disposed) return;
1551
1618
  this.disposed = false;
1619
+ this.inboxUnavailable = false;
1552
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
+ };
1553
1628
  try {
1629
+ this.patch({ currentUserId: this.currentUserId() });
1554
1630
  const subscription = this.options.client.onConnectionEvent({
1555
1631
  onEvent: () => {
1556
1632
  },
@@ -1562,15 +1638,20 @@ var ConversationListStore = class {
1562
1638
  else void subscription.unsubscribe().catch(() => void 0);
1563
1639
  if (!this.alive()) return;
1564
1640
  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
- });
1641
+ if (!current()) return;
1642
+ this.clearActivityTimer();
1643
+ this.queueRefresh();
1644
+ }, reconcile);
1572
1645
  if (this.alive()) this.inbox = inbox;
1573
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
+ }
1574
1655
  if (autoLoad) void this.loadInitial();
1575
1656
  } catch (cause) {
1576
1657
  if (this.alive()) this.patch({ error: cause });
@@ -1580,27 +1661,109 @@ var ConversationListStore = class {
1580
1661
  this.disposed = true;
1581
1662
  this.generation++;
1582
1663
  this.lifecycleGeneration++;
1664
+ this.clearActivityTimer();
1583
1665
  const subscription = this.lifecycle;
1584
1666
  this.lifecycle = void 0;
1585
1667
  if (subscription) void subscription.unsubscribe().catch(() => void 0);
1586
1668
  if (this.inbox) void this.inbox.unsubscribe().catch(() => void 0);
1587
1669
  this.inbox = void 0;
1670
+ this.stopActivity();
1588
1671
  this.refreshQueued = false;
1589
1672
  this.refreshing = false;
1590
1673
  this.source = [];
1674
+ this.entries = [];
1591
1675
  this.offset = 0;
1676
+ this.cursor = null;
1592
1677
  this.patch(blank2(this.state.filter));
1593
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
+ }
1594
1698
  fail(cause, generation) {
1595
1699
  if (!this.alive(generation)) return;
1596
- const status = typeof cause === "object" && cause !== null && "status" in cause ? cause.status : void 0;
1700
+ const status = statusOf(cause);
1597
1701
  if (status === 401 || status === 403 || status === 404) {
1598
1702
  this.source = [];
1703
+ this.entries = [];
1599
1704
  this.offset = 0;
1600
- this.patch({ conversations: [], hasMore: false });
1705
+ this.cursor = null;
1706
+ this.patch({ conversations: [], summaries: /* @__PURE__ */ new Map(), hasMore: false });
1601
1707
  }
1602
1708
  this.patch({ error: cause });
1603
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
+ }
1604
1767
  async loadUntilVisible(generation, filter) {
1605
1768
  const visibleBefore = applyConversationFilter(this.source, filter).length;
1606
1769
  while (this.alive(generation)) {
@@ -1627,10 +1790,17 @@ var ConversationListStore = class {
1627
1790
  const generation = ++this.generation;
1628
1791
  this.refreshing = false;
1629
1792
  this.source = [];
1793
+ this.entries = [];
1630
1794
  this.offset = 0;
1631
- 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;
1632
1798
  try {
1633
- await this.loadUntilVisible(generation, this.state.filter);
1799
+ await this.withFallback(
1800
+ generation,
1801
+ () => this.loadInboxUntilVisible(generation, filter),
1802
+ () => this.loadUntilVisible(generation, filter)
1803
+ );
1634
1804
  } catch (cause) {
1635
1805
  this.fail(cause, generation);
1636
1806
  } finally {
@@ -1652,6 +1822,47 @@ var ConversationListStore = class {
1652
1822
  void this.refresh();
1653
1823
  });
1654
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
+ }
1655
1866
  /** Replace the loaded window atomically, retaining filters and rows during transient failures. */
1656
1867
  refresh = async () => {
1657
1868
  if (!this.alive()) return;
@@ -1662,28 +1873,14 @@ var ConversationListStore = class {
1662
1873
  if (!this.state.hasLoaded) return this.loadInitial();
1663
1874
  const generation = this.generation;
1664
1875
  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
1876
  this.refreshing = true;
1669
1877
  this.patch({ error: null });
1670
1878
  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 });
1879
+ await this.withFallback(
1880
+ generation,
1881
+ () => this.refreshInbox(generation, filter),
1882
+ () => this.refreshLegacy(generation, filter)
1883
+ );
1687
1884
  } catch (cause) {
1688
1885
  this.fail(cause, generation);
1689
1886
  } finally {
@@ -1696,9 +1893,14 @@ var ConversationListStore = class {
1696
1893
  loadMore = async () => {
1697
1894
  if (!this.alive() || this.refreshing || this.state.isInitialLoading || this.state.isLoadingMore || !this.state.hasMore) return;
1698
1895
  const generation = this.generation;
1896
+ const filter = this.state.filter;
1699
1897
  this.patch({ isLoadingMore: true, error: null });
1700
1898
  try {
1701
- await this.loadUntilVisible(generation, this.state.filter);
1899
+ await this.withFallback(
1900
+ generation,
1901
+ () => this.loadInboxUntilVisible(generation, filter),
1902
+ () => this.loadUntilVisible(generation, filter)
1903
+ );
1702
1904
  } catch (cause) {
1703
1905
  this.fail(cause, generation);
1704
1906
  } finally {
@@ -1750,6 +1952,8 @@ function useConversationList(options) {
1750
1952
  });
1751
1953
  return {
1752
1954
  conversations: field("conversations"),
1955
+ summaries: field("summaries"),
1956
+ currentUserId: field("currentUserId"),
1753
1957
  filter: field("filter"),
1754
1958
  isInitialLoading: field("isInitialLoading"),
1755
1959
  isLoadingMore: field("isLoadingMore"),
@@ -1775,6 +1979,8 @@ var appearanceProps3 = {
1775
1979
  var listViewProps = {
1776
1980
  ...appearanceProps3,
1777
1981
  conversations: { type: Array, required: true },
1982
+ summaries: { type: Object, default: void 0 },
1983
+ currentUserId: { type: String, default: void 0 },
1778
1984
  selectedConversationId: { type: String, default: void 0 },
1779
1985
  onConversationSelect: { type: Function, default: void 0 },
1780
1986
  onRefresh: { type: Function, default: void 0 },
@@ -1824,6 +2030,25 @@ var ConversationListView = defineComponent4({
1824
2030
  const refresh = () => {
1825
2031
  return props.onRefresh?.();
1826
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
+ };
1827
2052
  const renderContent = () => {
1828
2053
  const currentAppearance = appearance();
1829
2054
  if (props.isInitialLoading && props.conversations.length === 0) {
@@ -1855,10 +2080,21 @@ var ConversationListView = defineComponent4({
1855
2080
  const children = props.conversations.flatMap((conversation, index) => {
1856
2081
  const selected = props.selectedConversationId === conversation.id;
1857
2082
  const select = () => selectConversation(conversation);
1858
- 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);
1859
2094
  const item = slots["conversation-item"]?.(slotProps) ?? h4("button", {
1860
2095
  type: "button",
1861
2096
  "data-selected": selected || void 0,
2097
+ "data-unread": unread || void 0,
1862
2098
  "aria-current": selected ? "true" : void 0,
1863
2099
  onClick: select,
1864
2100
  class: partClass("listItem", currentAppearance, "ckui-conversation-item"),
@@ -1872,8 +2108,14 @@ var ConversationListView = defineComponent4({
1872
2108
  }),
1873
2109
  h4("span", { class: "ckui-conversation-item__body" }, [
1874
2110
  h4("strong", conversation.displayTitle),
1875
- 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")
1876
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
+ ])] : [],
1877
2119
  h4(ChevronRight, { size: 18, "aria-hidden": "true" })
1878
2120
  ]);
1879
2121
  const nodes = [h4("div", { key: conversation.id, role: "listitem" }, [item])];
@@ -1883,13 +2125,15 @@ var ConversationListView = defineComponent4({
1883
2125
  return nodes;
1884
2126
  });
1885
2127
  if (props.error) {
1886
- 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", {
1887
2130
  class: partClass("error", currentAppearance, "ckui-inline-state ckui-state--error"),
1888
2131
  style: partStyle("error", currentAppearance),
1889
2132
  role: "alert"
1890
- }, [h4("span", errorMessage(props.error)), h4("button", { type: "button", class: "ckui-link-button", onClick: () => {
1891
- void requestMore();
1892
- } }, "Retry")]));
2133
+ }, [
2134
+ h4("span", errorMessage(props.error)),
2135
+ ...retry ? [h4("button", { type: "button", class: "ckui-link-button", onClick: retry }, "Retry")] : []
2136
+ ]));
1893
2137
  } else if (props.isLoadingMore) {
1894
2138
  children.push(slots["load-more"]?.() ?? h4("div", {
1895
2139
  class: partClass("loading", currentAppearance, "ckui-inline-state"),
@@ -1950,6 +2194,7 @@ var ConversationList = defineComponent4({
1950
2194
  initialFilter: { type: Object, default: void 0 },
1951
2195
  pageSize: { type: Number, default: 30 },
1952
2196
  autoLoad: { type: Boolean, default: true },
2197
+ activityRefreshWindowMs: { type: Number, default: void 0 },
1953
2198
  onControllerChange: { type: Function, default: void 0 }
1954
2199
  },
1955
2200
  emits: ["conversation-select", "controller-change"],
@@ -1959,7 +2204,8 @@ var ConversationList = defineComponent4({
1959
2204
  ...props.pageLoader ? { pageLoader: props.pageLoader } : {},
1960
2205
  ...props.initialFilter ? { initialFilter: props.initialFilter } : {},
1961
2206
  pageSize: props.pageSize,
1962
- autoLoad: props.autoLoad
2207
+ autoLoad: props.autoLoad,
2208
+ ...props.activityRefreshWindowMs === void 0 ? {} : { activityRefreshWindowMs: props.activityRefreshWindowMs }
1963
2209
  });
1964
2210
  expose({ controller });
1965
2211
  watchEffect2(() => {
@@ -1972,8 +2218,11 @@ var ConversationList = defineComponent4({
1972
2218
  initialFilter: _initialFilter,
1973
2219
  pageSize: _pageSize,
1974
2220
  autoLoad: _autoLoad,
2221
+ activityRefreshWindowMs: _activityRefreshWindowMs,
1975
2222
  onControllerChange: _onControllerChange,
1976
2223
  conversations: _conversations,
2224
+ summaries: _summaries,
2225
+ currentUserId: _currentUserId,
1977
2226
  onRefresh: _onRefresh,
1978
2227
  onLoadMore: _onLoadMore,
1979
2228
  isInitialLoading: _isInitialLoading,
@@ -1986,6 +2235,8 @@ var ConversationList = defineComponent4({
1986
2235
  ...attrs,
1987
2236
  ...forwarded,
1988
2237
  conversations: controller.conversations.value,
2238
+ summaries: controller.summaries.value,
2239
+ currentUserId: controller.currentUserId.value,
1989
2240
  onRefresh: controller.refresh,
1990
2241
  onLoadMore: controller.loadMore,
1991
2242
  isInitialLoading: controller.isInitialLoading.value,
@@ -2019,6 +2270,7 @@ var defaultConvoKitTheme = {
2019
2270
  incomingBubble: "#f4f4f5",
2020
2271
  outgoingBubble: "#18181b",
2021
2272
  outgoingText: "#fafafa",
2273
+ badge: "#18181b",
2022
2274
  radius: "10px",
2023
2275
  avatarSize: "40px",
2024
2276
  fontFamily: 'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'
@@ -2050,6 +2302,7 @@ var ConvoKitThemeProvider = defineComponent5({
2050
2302
  "--ckui-incoming": theme.incomingBubble,
2051
2303
  "--ckui-outgoing": theme.outgoingBubble,
2052
2304
  "--ckui-outgoing-text": theme.outgoingText,
2305
+ "--ckui-badge": theme.badge,
2053
2306
  "--ckui-radius": theme.radius,
2054
2307
  "--ckui-avatar-size": theme.avatarSize,
2055
2308
  "--ckui-font": theme.fontFamily
@@ -2081,6 +2334,7 @@ export {
2081
2334
  isConvoKitPendingMessage,
2082
2335
  matchesConversation,
2083
2336
  mergeConversations,
2337
+ mergeInboxEntries,
2084
2338
  mergeMessages,
2085
2339
  readerIdsFor,
2086
2340
  useConversation,