@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.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,6 +67,7 @@ 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),
@@ -136,6 +142,31 @@ function mergeConversations(current, incoming) {
136
142
  for (const conversation of incoming) byId.set(conversation.id, conversation);
137
143
  return [...byId.values()];
138
144
  }
145
+ function compareInboxOrder(left, right) {
146
+ return right.activityAt.getTime() - left.activityAt.getTime() || (left.conversation.id < right.conversation.id ? 1 : left.conversation.id > right.conversation.id ? -1 : 0);
147
+ }
148
+ function mergeInboxEntries(current, incoming) {
149
+ const byId = new Map(current.map((entry) => [entry.conversation.id, entry]));
150
+ for (const entry of incoming) byId.set(entry.conversation.id, entry);
151
+ return [...byId.values()].sort(compareInboxOrder);
152
+ }
153
+ function inboxPreview(conversation, summary, currentUserId) {
154
+ const message = summary?.latestMessage;
155
+ if (!message) return "";
156
+ const first = message.media[0];
157
+ const body = message.text?.trim() || (!first ? "" : first.type === "image" ? "Photo" : first.type === "file" ? first.name?.trim() || "File" : first.type === "location" ? "Location" : first.type === "contact" ? "Contact" : "");
158
+ if (!body) return "";
159
+ if (currentUserId !== void 0 && message.senderId === currentUserId) return `You: ${body}`;
160
+ if (conversation.participants.length > 2) {
161
+ const sender = conversation.participants.find((participant) => participant.appUserId === message.senderId || participant.id === message.senderId);
162
+ const name = sender?.name.trim();
163
+ if (name) return `${name}: ${body}`;
164
+ }
165
+ return body;
166
+ }
167
+ function formatMessageTime(date) {
168
+ return new Intl.DateTimeFormat(void 0, { hour: "numeric", minute: "2-digit" }).format(date);
169
+ }
139
170
  function mergeMessages(current, incoming) {
140
171
  const byId = new Map(current.map((message) => [message.id, message]));
141
172
  for (const message of incoming) byId.set(message.id, message);
@@ -911,9 +942,6 @@ var appearanceProps = {
911
942
  density: { type: String, default: "comfortable" },
912
943
  unstyled: { type: Boolean, default: false }
913
944
  };
914
- function defaultFormatTime(date) {
915
- return new Intl.DateTimeFormat(void 0, { hour: "numeric", minute: "2-digit" }).format(date);
916
- }
917
945
  function defaultMedia(media, open, imageLoading) {
918
946
  const tag = open ? "button" : "div";
919
947
  const interactive = open ? { type: "button", onClick: open } : {};
@@ -964,7 +992,7 @@ var MessageListView = (0, import_vue5.defineComponent)({
964
992
  paginationThreshold: { type: Number, default: 240 },
965
993
  reverse: { type: Boolean, default: true },
966
994
  stickToBottom: { type: Boolean, default: true },
967
- formatTime: { type: Function, default: defaultFormatTime },
995
+ formatTime: { type: Function, default: formatMessageTime },
968
996
  imageLoading: { type: String, default: "lazy" }
969
997
  },
970
998
  emits: ["load-older", "attachment-click"],
@@ -1521,30 +1549,64 @@ var import_vue10 = require("vue");
1521
1549
  var import_vue8 = require("vue");
1522
1550
 
1523
1551
  // src/conversation-list-store.ts
1552
+ var defaultActivityRefreshWindowMs = 500;
1524
1553
  function blank2(filter) {
1525
- return { conversations: [], filter, isInitialLoading: false, isLoadingMore: false, hasMore: true, hasLoaded: false, error: null };
1554
+ return {
1555
+ conversations: [],
1556
+ summaries: /* @__PURE__ */ new Map(),
1557
+ currentUserId: "",
1558
+ filter,
1559
+ isInitialLoading: false,
1560
+ isLoadingMore: false,
1561
+ hasMore: true,
1562
+ hasLoaded: false,
1563
+ error: null
1564
+ };
1565
+ }
1566
+ function statusOf(cause) {
1567
+ return typeof cause === "object" && cause !== null && "status" in cause ? cause.status : void 0;
1568
+ }
1569
+ function summaryOf(entry) {
1570
+ const { conversation: _conversation, ...summary } = entry;
1571
+ return summary;
1572
+ }
1573
+ function ids(entries) {
1574
+ return entries.map((entry) => entry.conversation);
1526
1575
  }
1527
1576
  var ConversationListStore = class {
1528
1577
  constructor(options) {
1529
1578
  this.options = options;
1530
1579
  this.owner = options.client.sessionIdentity;
1580
+ this.user = this.owner ? options.client.currentUserId : "";
1531
1581
  this.pageSize = options.pageSize ?? 30;
1532
1582
  if (!Number.isInteger(this.pageSize) || this.pageSize < 1 || this.pageSize > 100) {
1533
1583
  throw new RangeError("pageSize must be an integer between 1 and 100");
1534
1584
  }
1585
+ this.activityRefreshWindowMs = options.activityRefreshWindowMs ?? defaultActivityRefreshWindowMs;
1586
+ if (!Number.isFinite(this.activityRefreshWindowMs) || this.activityRefreshWindowMs < 0) {
1587
+ throw new RangeError("activityRefreshWindowMs must be a non-negative number");
1588
+ }
1535
1589
  this.state = blank2(options.initialFilter ?? {});
1536
1590
  }
1537
1591
  options;
1538
1592
  owner;
1593
+ user;
1539
1594
  pageSize;
1595
+ activityRefreshWindowMs;
1540
1596
  state;
1541
1597
  source = [];
1598
+ entries = [];
1542
1599
  offset = 0;
1600
+ cursor = null;
1601
+ inboxUnavailable = false;
1602
+ inboxWarned = false;
1543
1603
  generation = 0;
1544
1604
  lifecycleGeneration = 0;
1545
1605
  disposed = true;
1546
1606
  lifecycle;
1547
1607
  inbox;
1608
+ activity;
1609
+ activityTimer;
1548
1610
  refreshQueued = false;
1549
1611
  refreshing = false;
1550
1612
  listeners = /* @__PURE__ */ new Set();
@@ -1562,12 +1624,27 @@ var ConversationListStore = class {
1562
1624
  alive(generation = this.generation) {
1563
1625
  return !this.disposed && generation === this.generation && this.owner !== null && this.options.client.sessionIdentity === this.owner;
1564
1626
  }
1627
+ get inboxMode() {
1628
+ return !this.options.pageLoader && typeof this.options.client.listInbox === "function" && !this.inboxUnavailable;
1629
+ }
1630
+ currentUserId() {
1631
+ return this.inboxMode ? this.user : "";
1632
+ }
1565
1633
  start = (autoLoad = true) => {
1566
1634
  if (!this.owner || this.options.client.sessionIdentity !== this.owner) return;
1567
1635
  if (!this.disposed) return;
1568
1636
  this.disposed = false;
1637
+ this.inboxUnavailable = false;
1569
1638
  const lifecycleGeneration = ++this.lifecycleGeneration;
1639
+ const current = () => this.alive() && lifecycleGeneration === this.lifecycleGeneration;
1640
+ const reconcile = (cause) => {
1641
+ if (current()) {
1642
+ this.patch({ error: cause });
1643
+ this.queueRefresh();
1644
+ }
1645
+ };
1570
1646
  try {
1647
+ this.patch({ currentUserId: this.currentUserId() });
1571
1648
  const subscription = this.options.client.onConnectionEvent({
1572
1649
  onEvent: () => {
1573
1650
  },
@@ -1579,15 +1656,20 @@ var ConversationListStore = class {
1579
1656
  else void subscription.unsubscribe().catch(() => void 0);
1580
1657
  if (!this.alive()) return;
1581
1658
  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
- });
1659
+ if (!current()) return;
1660
+ this.clearActivityTimer();
1661
+ this.queueRefresh();
1662
+ }, reconcile);
1589
1663
  if (this.alive()) this.inbox = inbox;
1590
1664
  else void inbox.unsubscribe().catch(() => void 0);
1665
+ if (!this.alive()) return;
1666
+ if (this.inboxMode && typeof this.options.client.onInboxActivity === "function") {
1667
+ const activity = this.options.client.onInboxActivity(() => {
1668
+ if (current()) this.scheduleActivityRefresh(lifecycleGeneration);
1669
+ }, reconcile);
1670
+ if (this.alive()) this.activity = activity;
1671
+ else void activity.unsubscribe().catch(() => void 0);
1672
+ }
1591
1673
  if (autoLoad) void this.loadInitial();
1592
1674
  } catch (cause) {
1593
1675
  if (this.alive()) this.patch({ error: cause });
@@ -1597,27 +1679,109 @@ var ConversationListStore = class {
1597
1679
  this.disposed = true;
1598
1680
  this.generation++;
1599
1681
  this.lifecycleGeneration++;
1682
+ this.clearActivityTimer();
1600
1683
  const subscription = this.lifecycle;
1601
1684
  this.lifecycle = void 0;
1602
1685
  if (subscription) void subscription.unsubscribe().catch(() => void 0);
1603
1686
  if (this.inbox) void this.inbox.unsubscribe().catch(() => void 0);
1604
1687
  this.inbox = void 0;
1688
+ this.stopActivity();
1605
1689
  this.refreshQueued = false;
1606
1690
  this.refreshing = false;
1607
1691
  this.source = [];
1692
+ this.entries = [];
1608
1693
  this.offset = 0;
1694
+ this.cursor = null;
1609
1695
  this.patch(blank2(this.state.filter));
1610
1696
  };
1697
+ stopActivity() {
1698
+ if (this.activity) void this.activity.unsubscribe().catch(() => void 0);
1699
+ this.activity = void 0;
1700
+ }
1701
+ clearActivityTimer() {
1702
+ if (this.activityTimer !== void 0) clearTimeout(this.activityTimer);
1703
+ this.activityTimer = void 0;
1704
+ }
1705
+ /** Max-wait throttle: the first signal opens a window; later signals wait for it; one refresh runs when it closes. */
1706
+ scheduleActivityRefresh(lifecycleGeneration) {
1707
+ if (this.activityRefreshWindowMs === 0) return this.queueRefresh();
1708
+ if (this.activityTimer !== void 0) return;
1709
+ const timer = setTimeout(() => {
1710
+ this.activityTimer = void 0;
1711
+ if (this.alive() && lifecycleGeneration === this.lifecycleGeneration) this.queueRefresh();
1712
+ }, this.activityRefreshWindowMs);
1713
+ timer.unref?.();
1714
+ this.activityTimer = timer;
1715
+ }
1611
1716
  fail(cause, generation) {
1612
1717
  if (!this.alive(generation)) return;
1613
- const status = typeof cause === "object" && cause !== null && "status" in cause ? cause.status : void 0;
1718
+ const status = statusOf(cause);
1614
1719
  if (status === 401 || status === 403 || status === 404) {
1615
1720
  this.source = [];
1721
+ this.entries = [];
1616
1722
  this.offset = 0;
1617
- this.patch({ conversations: [], hasMore: false });
1723
+ this.cursor = null;
1724
+ this.patch({ conversations: [], summaries: /* @__PURE__ */ new Map(), hasMore: false });
1618
1725
  }
1619
1726
  this.patch({ error: cause });
1620
1727
  }
1728
+ /** Run an operation in inbox mode, falling back to the legacy path for the rest of this store's life when the
1729
+ * inbox route is absent (404: rollback, staging). Loaded rows are kept and the same operation continues.
1730
+ */
1731
+ async withFallback(generation, inbox, legacy) {
1732
+ if (!this.inboxMode) return legacy();
1733
+ try {
1734
+ await inbox();
1735
+ } catch (cause) {
1736
+ if (!this.alive(generation) || statusOf(cause) !== 404) throw cause;
1737
+ this.inboxUnavailable = true;
1738
+ this.clearActivityTimer();
1739
+ this.stopActivity();
1740
+ this.entries = [];
1741
+ this.cursor = null;
1742
+ this.offset = this.source.length;
1743
+ if (!this.inboxWarned) {
1744
+ this.inboxWarned = true;
1745
+ console.warn("ConvoKit inbox endpoint unavailable (404); using getConversations without previews or unread counts.");
1746
+ }
1747
+ this.patch({ summaries: /* @__PURE__ */ new Map(), currentUserId: "" });
1748
+ await legacy();
1749
+ }
1750
+ }
1751
+ validateInboxPage(page, limit, requestedCursor) {
1752
+ const seen = /* @__PURE__ */ new Set();
1753
+ for (const entry of page.entries) {
1754
+ const id = entry.conversation.id;
1755
+ if (!id.trim() || seen.has(id)) throw new Error("Invalid conversation page");
1756
+ seen.add(id);
1757
+ }
1758
+ if (page.entries.length > limit) throw new Error("Invalid conversation page");
1759
+ if (page.nextCursor !== null && (page.nextCursor === requestedCursor || page.entries.length === 0)) {
1760
+ throw new Error("Inbox pagination did not advance");
1761
+ }
1762
+ }
1763
+ /** Swap rows, summaries, cursor and hasMore together, filtered by the filter current at commit time. */
1764
+ commitInbox(entries, cursor) {
1765
+ this.entries = entries;
1766
+ this.source = ids(entries);
1767
+ this.cursor = cursor;
1768
+ this.patch({
1769
+ conversations: applyConversationFilter(this.source, this.state.filter),
1770
+ summaries: new Map(entries.map((entry) => [entry.conversation.id, summaryOf(entry)])),
1771
+ hasMore: cursor !== null
1772
+ });
1773
+ }
1774
+ async loadInboxUntilVisible(generation, filter) {
1775
+ const visibleBefore = applyConversationFilter(this.source, filter).length;
1776
+ while (this.alive(generation)) {
1777
+ const requested = this.cursor;
1778
+ const page = await this.options.client.listInbox({ limit: this.pageSize, cursor: requested, archived: filter.archived ?? false });
1779
+ if (!this.alive(generation)) return;
1780
+ this.validateInboxPage(page, this.pageSize, requested);
1781
+ this.commitInbox(mergeInboxEntries(this.entries, page.entries), page.nextCursor);
1782
+ if (page.nextCursor === null || this.state.conversations.length > visibleBefore) return;
1783
+ }
1784
+ }
1621
1785
  async loadUntilVisible(generation, filter) {
1622
1786
  const visibleBefore = applyConversationFilter(this.source, filter).length;
1623
1787
  while (this.alive(generation)) {
@@ -1644,10 +1808,17 @@ var ConversationListStore = class {
1644
1808
  const generation = ++this.generation;
1645
1809
  this.refreshing = false;
1646
1810
  this.source = [];
1811
+ this.entries = [];
1647
1812
  this.offset = 0;
1648
- this.patch({ ...blank2(this.state.filter), isInitialLoading: true });
1813
+ this.cursor = null;
1814
+ this.patch({ ...blank2(this.state.filter), currentUserId: this.currentUserId(), isInitialLoading: true });
1815
+ const filter = this.state.filter;
1649
1816
  try {
1650
- await this.loadUntilVisible(generation, this.state.filter);
1817
+ await this.withFallback(
1818
+ generation,
1819
+ () => this.loadInboxUntilVisible(generation, filter),
1820
+ () => this.loadUntilVisible(generation, filter)
1821
+ );
1651
1822
  } catch (cause) {
1652
1823
  this.fail(cause, generation);
1653
1824
  } finally {
@@ -1669,6 +1840,47 @@ var ConversationListStore = class {
1669
1840
  void this.refresh();
1670
1841
  });
1671
1842
  }
1843
+ /** Re-walk the inbox from the head until the loaded window is covered and something is visible, or the inbox
1844
+ * ends. Rooms that moved are re-positioned by the merge; an exhausted inbox publishes what it found.
1845
+ */
1846
+ async refreshInbox(generation, filter) {
1847
+ const target = Math.max(this.pageSize, this.entries.length);
1848
+ let rows = [], consumed = 0, cursor = null;
1849
+ while (this.alive(generation)) {
1850
+ const remaining = target - consumed;
1851
+ const limit = remaining >= 1 ? Math.min(100, remaining) : this.pageSize;
1852
+ const page = await this.options.client.listInbox({ limit, cursor, archived: filter.archived ?? false });
1853
+ if (!this.alive(generation)) return;
1854
+ this.validateInboxPage(page, limit, cursor);
1855
+ rows = mergeInboxEntries(rows, page.entries);
1856
+ consumed += page.entries.length;
1857
+ cursor = page.nextCursor;
1858
+ if (cursor === null || consumed >= target && applyConversationFilter(ids(rows), this.state.filter).length > 0) break;
1859
+ }
1860
+ if (!this.alive(generation)) return;
1861
+ this.commitInbox(rows, cursor);
1862
+ }
1863
+ async refreshLegacy(generation, filter) {
1864
+ const target = Math.max(this.pageSize, this.offset);
1865
+ const compare2 = (a, b) => a.createdAt.getTime() - b.createdAt.getTime() || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
1866
+ const boundary = this.options.pageLoader ? void 0 : this.source.reduce((oldest, row) => !oldest || compare2(row, oldest) < 0 ? row : oldest, void 0);
1867
+ let rows = [], offset = 0, hasMore = true;
1868
+ while (this.alive(generation)) {
1869
+ const page = this.options.pageLoader ? await this.options.pageLoader({ limit: this.pageSize, offset, filter }) : await this.options.client.getConversations({ limit: this.pageSize, offset, archived: filter.archived ?? false });
1870
+ if (!this.alive(generation)) return;
1871
+ if (page.length > this.pageSize || page.some((row) => !row.id.trim())) throw new Error("Invalid conversation page");
1872
+ const merged = mergeConversations(rows, page);
1873
+ if (page.length === this.pageSize && merged.length === rows.length) throw new Error("Conversation pagination did not advance");
1874
+ rows = merged;
1875
+ offset += page.length;
1876
+ hasMore = page.length === this.pageSize;
1877
+ if (!hasMore || offset >= target && applyConversationFilter(rows, this.state.filter).length > 0 && (!boundary || page.some((row) => compare2(row, boundary) <= 0))) break;
1878
+ }
1879
+ if (!this.alive(generation)) return;
1880
+ this.source = rows;
1881
+ this.offset = offset;
1882
+ this.patch({ conversations: applyConversationFilter(rows, this.state.filter), hasMore });
1883
+ }
1672
1884
  /** Replace the loaded window atomically, retaining filters and rows during transient failures. */
1673
1885
  refresh = async () => {
1674
1886
  if (!this.alive()) return;
@@ -1679,28 +1891,14 @@ var ConversationListStore = class {
1679
1891
  if (!this.state.hasLoaded) return this.loadInitial();
1680
1892
  const generation = this.generation;
1681
1893
  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
1894
  this.refreshing = true;
1686
1895
  this.patch({ error: null });
1687
1896
  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 });
1897
+ await this.withFallback(
1898
+ generation,
1899
+ () => this.refreshInbox(generation, filter),
1900
+ () => this.refreshLegacy(generation, filter)
1901
+ );
1704
1902
  } catch (cause) {
1705
1903
  this.fail(cause, generation);
1706
1904
  } finally {
@@ -1713,9 +1911,14 @@ var ConversationListStore = class {
1713
1911
  loadMore = async () => {
1714
1912
  if (!this.alive() || this.refreshing || this.state.isInitialLoading || this.state.isLoadingMore || !this.state.hasMore) return;
1715
1913
  const generation = this.generation;
1914
+ const filter = this.state.filter;
1716
1915
  this.patch({ isLoadingMore: true, error: null });
1717
1916
  try {
1718
- await this.loadUntilVisible(generation, this.state.filter);
1917
+ await this.withFallback(
1918
+ generation,
1919
+ () => this.loadInboxUntilVisible(generation, filter),
1920
+ () => this.loadUntilVisible(generation, filter)
1921
+ );
1719
1922
  } catch (cause) {
1720
1923
  this.fail(cause, generation);
1721
1924
  } finally {
@@ -1767,6 +1970,8 @@ function useConversationList(options) {
1767
1970
  });
1768
1971
  return {
1769
1972
  conversations: field("conversations"),
1973
+ summaries: field("summaries"),
1974
+ currentUserId: field("currentUserId"),
1770
1975
  filter: field("filter"),
1771
1976
  isInitialLoading: field("isInitialLoading"),
1772
1977
  isLoadingMore: field("isLoadingMore"),
@@ -1792,6 +1997,8 @@ var appearanceProps3 = {
1792
1997
  var listViewProps = {
1793
1998
  ...appearanceProps3,
1794
1999
  conversations: { type: Array, required: true },
2000
+ summaries: { type: Object, default: void 0 },
2001
+ currentUserId: { type: String, default: void 0 },
1795
2002
  selectedConversationId: { type: String, default: void 0 },
1796
2003
  onConversationSelect: { type: Function, default: void 0 },
1797
2004
  onRefresh: { type: Function, default: void 0 },
@@ -1841,6 +2048,25 @@ var ConversationListView = (0, import_vue10.defineComponent)({
1841
2048
  const refresh = () => {
1842
2049
  return props.onRefresh?.();
1843
2050
  };
2051
+ const inlineRetry = () => {
2052
+ if (props.hasMore && props.onLoadMore) return () => {
2053
+ lastRequestedLength = null;
2054
+ void requestMore();
2055
+ };
2056
+ if (props.onRefresh) return () => {
2057
+ void refresh();
2058
+ };
2059
+ return void 0;
2060
+ };
2061
+ const unreadBadge = (summary) => {
2062
+ if (summary.unreadCount <= 0 && !summary.unreadCountCapped) return null;
2063
+ const capped = summary.unreadCountCapped || summary.unreadCount > 99;
2064
+ return (0, import_vue10.h)("span", {
2065
+ class: "ckui-unread-badge",
2066
+ role: "img",
2067
+ "aria-label": `${summary.unreadCountCapped ? "99+" : summary.unreadCount} unread`
2068
+ }, [(0, import_vue10.h)("span", { "aria-hidden": "true" }, capped ? "99+" : String(summary.unreadCount))]);
2069
+ };
1844
2070
  const renderContent = () => {
1845
2071
  const currentAppearance = appearance();
1846
2072
  if (props.isInitialLoading && props.conversations.length === 0) {
@@ -1872,10 +2098,21 @@ var ConversationListView = (0, import_vue10.defineComponent)({
1872
2098
  const children = props.conversations.flatMap((conversation, index) => {
1873
2099
  const selected = props.selectedConversationId === conversation.id;
1874
2100
  const select = () => selectConversation(conversation);
1875
- const slotProps = { conversation, index, selected, select };
2101
+ const summary = props.summaries?.get(conversation.id);
2102
+ const slotProps = {
2103
+ conversation,
2104
+ index,
2105
+ selected,
2106
+ select,
2107
+ ...summary ? { summary } : {},
2108
+ ...props.currentUserId === void 0 ? {} : { currentUserId: props.currentUserId }
2109
+ };
2110
+ const preview = inboxPreview(conversation, summary, props.currentUserId);
2111
+ const unread = summary !== void 0 && (summary.unreadCount > 0 || summary.unreadCountCapped);
1876
2112
  const item = slots["conversation-item"]?.(slotProps) ?? (0, import_vue10.h)("button", {
1877
2113
  type: "button",
1878
2114
  "data-selected": selected || void 0,
2115
+ "data-unread": unread || void 0,
1879
2116
  "aria-current": selected ? "true" : void 0,
1880
2117
  onClick: select,
1881
2118
  class: partClass("listItem", currentAppearance, "ckui-conversation-item"),
@@ -1889,8 +2126,14 @@ var ConversationListView = (0, import_vue10.defineComponent)({
1889
2126
  }),
1890
2127
  (0, import_vue10.h)("span", { class: "ckui-conversation-item__body" }, [
1891
2128
  (0, import_vue10.h)("strong", conversation.displayTitle),
1892
- (0, import_vue10.h)("span", conversation.participants.map((participant) => participant.name).join(", ") || conversation.description || "No participants")
2129
+ (0, import_vue10.h)("span", preview || conversation.participants.map((participant) => participant.name).join(", ") || conversation.description || "No participants")
1893
2130
  ]),
2131
+ // Spread rather than emit `null`: a null child renders a `<!---->` comment, and rows without a
2132
+ // summary must keep 0.5's exact markup.
2133
+ ...summary ? [(0, import_vue10.h)("span", { class: "ckui-conversation-item__meta" }, [
2134
+ (0, import_vue10.h)("time", { class: "ckui-conversation-item__time", datetime: summary.activityAt.toISOString() }, formatMessageTime(summary.activityAt)),
2135
+ unreadBadge(summary)
2136
+ ])] : [],
1894
2137
  (0, import_vue10.h)(import_vue9.ChevronRight, { size: 18, "aria-hidden": "true" })
1895
2138
  ]);
1896
2139
  const nodes = [(0, import_vue10.h)("div", { key: conversation.id, role: "listitem" }, [item])];
@@ -1900,13 +2143,15 @@ var ConversationListView = (0, import_vue10.defineComponent)({
1900
2143
  return nodes;
1901
2144
  });
1902
2145
  if (props.error) {
1903
- children.push(slots.error?.({ error: props.error, retry: requestMore }) ?? (0, import_vue10.h)("div", {
2146
+ const retry = inlineRetry();
2147
+ children.push(slots.error?.({ error: props.error, ...retry ? { retry } : {} }) ?? (0, import_vue10.h)("div", {
1904
2148
  class: partClass("error", currentAppearance, "ckui-inline-state ckui-state--error"),
1905
2149
  style: partStyle("error", currentAppearance),
1906
2150
  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")]));
2151
+ }, [
2152
+ (0, import_vue10.h)("span", errorMessage(props.error)),
2153
+ ...retry ? [(0, import_vue10.h)("button", { type: "button", class: "ckui-link-button", onClick: retry }, "Retry")] : []
2154
+ ]));
1910
2155
  } else if (props.isLoadingMore) {
1911
2156
  children.push(slots["load-more"]?.() ?? (0, import_vue10.h)("div", {
1912
2157
  class: partClass("loading", currentAppearance, "ckui-inline-state"),
@@ -1967,6 +2212,7 @@ var ConversationList = (0, import_vue10.defineComponent)({
1967
2212
  initialFilter: { type: Object, default: void 0 },
1968
2213
  pageSize: { type: Number, default: 30 },
1969
2214
  autoLoad: { type: Boolean, default: true },
2215
+ activityRefreshWindowMs: { type: Number, default: void 0 },
1970
2216
  onControllerChange: { type: Function, default: void 0 }
1971
2217
  },
1972
2218
  emits: ["conversation-select", "controller-change"],
@@ -1976,7 +2222,8 @@ var ConversationList = (0, import_vue10.defineComponent)({
1976
2222
  ...props.pageLoader ? { pageLoader: props.pageLoader } : {},
1977
2223
  ...props.initialFilter ? { initialFilter: props.initialFilter } : {},
1978
2224
  pageSize: props.pageSize,
1979
- autoLoad: props.autoLoad
2225
+ autoLoad: props.autoLoad,
2226
+ ...props.activityRefreshWindowMs === void 0 ? {} : { activityRefreshWindowMs: props.activityRefreshWindowMs }
1980
2227
  });
1981
2228
  expose({ controller });
1982
2229
  (0, import_vue10.watchEffect)(() => {
@@ -1989,8 +2236,11 @@ var ConversationList = (0, import_vue10.defineComponent)({
1989
2236
  initialFilter: _initialFilter,
1990
2237
  pageSize: _pageSize,
1991
2238
  autoLoad: _autoLoad,
2239
+ activityRefreshWindowMs: _activityRefreshWindowMs,
1992
2240
  onControllerChange: _onControllerChange,
1993
2241
  conversations: _conversations,
2242
+ summaries: _summaries,
2243
+ currentUserId: _currentUserId,
1994
2244
  onRefresh: _onRefresh,
1995
2245
  onLoadMore: _onLoadMore,
1996
2246
  isInitialLoading: _isInitialLoading,
@@ -2003,6 +2253,8 @@ var ConversationList = (0, import_vue10.defineComponent)({
2003
2253
  ...attrs,
2004
2254
  ...forwarded,
2005
2255
  conversations: controller.conversations.value,
2256
+ summaries: controller.summaries.value,
2257
+ currentUserId: controller.currentUserId.value,
2006
2258
  onRefresh: controller.refresh,
2007
2259
  onLoadMore: controller.loadMore,
2008
2260
  isInitialLoading: controller.isInitialLoading.value,
@@ -2030,6 +2282,7 @@ var defaultConvoKitTheme = {
2030
2282
  incomingBubble: "#f4f4f5",
2031
2283
  outgoingBubble: "#18181b",
2032
2284
  outgoingText: "#fafafa",
2285
+ badge: "#18181b",
2033
2286
  radius: "10px",
2034
2287
  avatarSize: "40px",
2035
2288
  fontFamily: 'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'
@@ -2061,6 +2314,7 @@ var ConvoKitThemeProvider = (0, import_vue11.defineComponent)({
2061
2314
  "--ckui-incoming": theme.incomingBubble,
2062
2315
  "--ckui-outgoing": theme.outgoingBubble,
2063
2316
  "--ckui-outgoing-text": theme.outgoingText,
2317
+ "--ckui-badge": theme.badge,
2064
2318
  "--ckui-radius": theme.radius,
2065
2319
  "--ckui-avatar-size": theme.avatarSize,
2066
2320
  "--ckui-font": theme.fontFamily
@@ -2093,6 +2347,7 @@ function useConvoKitTheme() {
2093
2347
  isConvoKitPendingMessage,
2094
2348
  matchesConversation,
2095
2349
  mergeConversations,
2350
+ mergeInboxEntries,
2096
2351
  mergeMessages,
2097
2352
  readerIdsFor,
2098
2353
  useConversation,