@convokitapp/vue-ui 0.6.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/CHANGELOG.md CHANGED
@@ -1,5 +1,63 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.7.0
4
+
5
+ - Private mark unread. `useConversationList` / `ConversationListController`
6
+ gain `markUnread(conversationId)` and
7
+ `clearUnread(conversationId, { ifVersion? })`; the latter resolves to the
8
+ response's `cleared` ("this request removed the marker"). On any 200 (a mark,
9
+ or a clear whether `cleared` is true or false) the row's current summary
10
+ takes the response's `unreadMarkedAt` and `privateStateVersion` as one unit,
11
+ only while the response is not older than the stored version, and
12
+ `isUnread` is recomputed as
13
+ `unreadCount > 0 || unreadCountCapped || unreadMarkedAt !== null`; a lower
14
+ version is ignored so a delayed response never resurrects a marker a newer
15
+ action removed. Both methods reject when the adapter lacks the new members
16
+ or the store is disposed or session-ended (nothing is sent); request
17
+ failures are reported through `error` without evicting rows and reject.
18
+ Other devices learn of the change through `inbox_activity`, which the
19
+ backend now also sends for the caller's own marker changes.
20
+ - Rows. The unread predicate (bold title, `data-unread`) is
21
+ `summary.isUnread || summary.unreadCount > 0 || summary.unreadCountCapped`,
22
+ so consumer-built summaries that omit `isUnread` keep their badges. A count
23
+ (or a capped count) keeps the numeric badge unchanged; `isUnread` without
24
+ one renders a numberless dot, `.ckui-unread-badge.ckui-unread-badge--dot`
25
+ (`role="img"`, accessible name `Unread`, never `0 unread`), in the `badge`
26
+ colour. It is spread into the row's meta span, so rows without a badge no
27
+ longer carry an empty comment vnode. `#conversation-item` slots receive the
28
+ same `summary` with its three new fields; no signature changes.
29
+ - Rooms. The store captures `conversation.membership.privateStateVersion` (and
30
+ whether `membership.unreadMarkedAt` was set) once per open, the first time
31
+ its `conversation` goes from null to a DTO: on `loadInitial`, or on the
32
+ reconcile that follows a transient first-load failure; a refresh that
33
+ replaces an existing DTO never recaptures, and the capture is dropped
34
+ whenever `conversation` returns to null (reload, dispose, authoritative
35
+ 401/403/404). Every targeted acknowledgement of that open sends
36
+ `{ throughMessageId, privateStateVersion }`; a DTO without `membership`
37
+ (0.6 backend) sends the 0.6 body. An automatic acknowledgement for a row
38
+ received before the DTO (during the load, or while a transient first-load
39
+ failure stands) waits for the capture, like it waits while hidden, and is
40
+ issued with the version once the open captures. A room opened with a
41
+ marker that renders no non-pending, acknowledgeable row calls
42
+ `clearConversationUnread(conversationId, { ifVersion: captured })` once per
43
+ open under the load-acknowledgement triggers and visibility gating (also on
44
+ an explicit `markRead()`, never once a row is rendered); `cleared: false`
45
+ is not an error. The store still never sends an acknowledgement without a
46
+ target, fabricates no read state and holds no marker state.
47
+ - Adapter: optional `markConversationUnread?(conversationId)` and
48
+ `clearConversationUnread?(conversationId, { ifVersion? })`; the default
49
+ adapter implements both. `markConversationRead` options may now carry
50
+ `privateStateVersion`: custom adapters must forward the options object
51
+ unchanged, or acknowledgements advance the position but never clear the
52
+ marker. Adapters without `clearConversationUnread` leave the marker in
53
+ empty rooms.
54
+ - Requires core SDK 0.7.x (`markConversationUnread`, `clearConversationUnread`,
55
+ `Conversation.membership`, the required `InboxSummary` members `isUnread`,
56
+ `unreadMarkedAt` and `privateStateVersion`) and the coordinated backend.
57
+ Consumer-built `InboxSummary` literals gain those three members. Mixed
58
+ fleet: against a 0.6 backend the new adapter members fail with a 404
59
+ (`HTTP_ERROR`), acknowledgements carry no version and lists show no dots.
60
+
3
61
  ## 0.6.0
4
62
 
5
63
  - Inbox previews and unread counts. SDK-backed lists page `listInbox` by
package/PARITY.md CHANGED
@@ -20,6 +20,11 @@ while using Vue-native composition patterns.
20
20
  | Row replacements receive `summary` / `currentUserId` | `rowBuilder` | Render props | `#conversation-item` slot |
21
21
  | `inbox_activity` throttle (`activityRefreshWindowMs`) | Yes | Yes | Yes |
22
22
  | Adapter `listInbox` / `onInboxActivity` | Required | Optional | Optional |
23
+ | List `markUnread` / `clearUnread` (0.7.0) | Controller | `useConversationList` | `useConversationList` |
24
+ | Numberless unread dot (`isUnread`, count 0, name `Unread`) | `ConvoKitUnreadDot` | `.ckui-badge--dot` | `.ckui-unread-badge--dot` |
25
+ | Acknowledgements carry the version captured at open | Yes | Yes | Yes |
26
+ | Empty marked room clears through `clearConversationUnread` | Yes | Yes | Yes |
27
+ | Adapter `markConversationUnread` / `clearConversationUnread` | Required | Optional | Optional |
23
28
  | Inbox 404 fallback to the offset path | Yes | Yes | Yes |
24
29
  | Search, archived, participant, predicate, sort filters | Yes | Yes | Yes |
25
30
  | Realtime messages, typing, reads | Yes | Yes | Yes |
@@ -68,3 +73,21 @@ unread badge (`99+` above 99 or when capped; accessible name
68
73
  A 404 from the inbox route falls back to the offset path for the store's life
69
74
  without evicting rows. The parity script checks API presence only; the store,
70
75
  rendered-view and actual-SDK protocol tests verify behavior.
76
+
77
+ ## Mark-unread parity (0.7.0)
78
+
79
+ Every UI keeps the marker private to the caller, exposes list-level
80
+ `markUnread` / `clearUnread` (the latter returning the response's `cleared`),
81
+ applies a mutation response to the current summary only when its
82
+ `privateStateVersion` is not older than the stored one, and recomputes
83
+ `isUnread` as `unreadCount > 0 || unreadCountCapped || unreadMarkedAt != null`.
84
+ Rows keep the numeric badge for a count and render a numberless dot named
85
+ `Unread` for a marker without one. Room stores capture the opened
86
+ `membership.privateStateVersion` once per open and send it with every
87
+ targeted acknowledgement; a room opened with a marker that renders nothing
88
+ clears it once through `clearConversationUnread(id, { ifVersion })`, never
89
+ through an untargeted acknowledgement. The adapter additions are optional in
90
+ the TypeScript UIs and required in the Dart and Kotlin ones (a breaking
91
+ change for custom implementers there, like `listInbox` in 0.6.0). The parity
92
+ script checks API presence only; the store, rendered-view and actual-SDK
93
+ protocol tests verify behavior.
package/README.md CHANGED
@@ -11,6 +11,68 @@ React package: system typography, separated rows, subtle borders, compact
11
11
  actions, and restrained radii. It remains framework-CSS independent and does
12
12
  not require Tailwind in the host application.
13
13
 
14
+ ## 0.7.0 private mark unread
15
+
16
+ This release requires JavaScript SDK 0.7.x (`markConversationUnread`,
17
+ `clearConversationUnread`, `Conversation.membership`, the `InboxSummary`
18
+ members `isUnread`, `unreadMarkedAt` and `privateStateVersion`) and the
19
+ coordinated backend (`POST`/`DELETE /api/v1/conversations/:id/unread`, the
20
+ `privateStateVersion` acknowledgement field, the `membership` sibling on
21
+ `GET /api/v1/conversations/:id`).
22
+
23
+ - The marker is private: other members, webhooks and `read` events never see
24
+ it. `useConversationList` and the `ConversationList` controller gain
25
+ `markUnread(conversationId)` and `clearUnread(conversationId, { ifVersion? })`.
26
+ Wire them through `#conversation-item` (a row action or menu) or
27
+ `@controller-change`; the default row adds no affordance. `clearUnread`
28
+ resolves to the response's `cleared` ("this request removed the marker",
29
+ not "the room is read"). Both reject from a disposed or session-ended
30
+ controller without sending anything.
31
+ - Summaries carry `isUnread`
32
+ (`unreadCount > 0 || unreadCountCapped || unreadMarkedAt !== null`),
33
+ `unreadMarkedAt` and `privateStateVersion`. After a mark or a clear the
34
+ row's summary takes the response only while it is not older than what the
35
+ row already holds; a delayed response never resurrects a marker a newer
36
+ action removed. Other devices refresh through `inbox_activity`, which the
37
+ backend also sends for your own marker changes; `activityRefreshWindowMs`
38
+ applies as before.
39
+ - Dot rule: a count (or a capped count) keeps the numeric badge (`5`, `99+`,
40
+ accessible name `<count> unread`); `isUnread` without one renders a
41
+ numberless dot with the accessible name `Unread`, never `0 unread`
42
+ (`.ckui-unread-badge.ckui-unread-badge--dot`, `badge` colour). The title is
43
+ bold and `data-unread` is set in both cases; rows without a summary are
44
+ unchanged.
45
+ - Capture at open: the room store captures
46
+ `conversation.membership.privateStateVersion` once per open (the first time
47
+ its `conversation` goes from null to a DTO: on load, or on the reconcile
48
+ after a transient first-load failure) and sends it with every targeted
49
+ acknowledgement of that open. A refresh never recaptures, so a mark made
50
+ elsewhere while the room is open survives its acknowledgements until the
51
+ room is reopened; a DTO without `membership` (0.6 backend) sends no version.
52
+ An automatic acknowledgement for a row that arrives before the DTO (during
53
+ the load, or while a transient first-load failure stands) waits for the
54
+ capture, like it waits while hidden, so it too carries the version.
55
+ - Empty rooms: when the opened membership carries a marker and nothing
56
+ non-pending is rendered, the store calls
57
+ `clearConversationUnread(conversationId, { ifVersion })` once per open under
58
+ the same triggers and visibility gating as the load acknowledgement (and on
59
+ an explicit `markRead()`); once a row is rendered its acknowledgement clears
60
+ the marker instead. The UI still never sends an acknowledgement without a
61
+ target. `cleared: false` is not an error.
62
+ - Adapter additions: `ConvoKitUiClient.markConversationUnread?(conversationId)`
63
+ and `clearConversationUnread?(conversationId, { ifVersion? })` are optional;
64
+ the default adapter implements both. Custom adapters without them make
65
+ `markUnread` / `clearUnread` reject and leave the marker in empty rooms.
66
+ (The Flutter and Android UIs make the same members, and the
67
+ `privateStateVersion` acknowledgement parameter, required on their
68
+ `ConvoKitUiClient`: a breaking change for custom implementers there, like
69
+ `listInbox` in 0.6.0.)
70
+ `markConversationRead` options now carry `privateStateVersion`; forward the
71
+ options object unchanged, or acknowledgements advance the position but never
72
+ clear the marker.
73
+ - Mixed fleet: 0.6 lists ignore `isUnread`; against a 0.6 backend the new
74
+ adapter members fail with a 404 (`HTTP_ERROR`) and no dots render.
75
+
14
76
  ## 0.6.0 inbox previews and unread counts
15
77
 
16
78
  This release requires JavaScript SDK 0.6.x (`listInbox`, `onInboxActivity`)
@@ -248,8 +310,10 @@ Available slots include `conversation-item`, `separator`, `header`, `message`,
248
310
  Use `useConversationList` and `useConversation` when you want ConvoKit's
249
311
  pagination, de-duplication, realtime, typing, and read state without the
250
312
  default UI. Both return readonly Vue refs plus actions and a `dispose()` method.
251
- `useConversationList` also exposes `summaries` and `currentUserId` and accepts
252
- `activityRefreshWindowMs`; `ConversationList` forwards the same option.
313
+ `useConversationList` also exposes `summaries` and `currentUserId`, accepts
314
+ `activityRefreshWindowMs` (`ConversationList` forwards the same option), and
315
+ offers `markUnread(conversationId)` / `clearUnread(conversationId, options?)`
316
+ for a host-built row action.
253
317
  `useConversation` also exposes `readPositionByUserId`, `readerIdsFor(message)`,
254
318
  `markRead()` and `setVisible(visible)`; call `setVisible(false)` while your own
255
319
  surface hides the room so automatic acknowledgements wait until it is shown.
@@ -261,7 +325,7 @@ use `isConvoKitPendingMessage(message)` to present the same state.
261
325
  ## Appearance
262
326
 
263
327
  Wrap any subtree with `ConvoKitThemeProvider` (tokens include `badge` for the
264
- unread counter), set `density="compact"`, or use
328
+ unread counter and dot), set `density="compact"`, or use
265
329
  the per-part `classNames` and `styles` maps. `unstyled` removes package classes
266
330
  from the configurable parts for a fully host-owned presentation.
267
331
 
package/dist/index.cjs CHANGED
@@ -73,6 +73,8 @@ function createConvoKitUiClient(client) {
73
73
  getMessage: (id) => client.getMessage(id),
74
74
  sendMessage: (input) => client.sendMessage(input),
75
75
  markConversationRead: (conversationId, options) => client.markConversationRead(conversationId, options),
76
+ markConversationUnread: (conversationId) => client.markConversationUnread(conversationId),
77
+ clearConversationUnread: (conversationId, options) => client.clearConversationUnread(conversationId, options),
76
78
  sendTyping: (input) => client.sendTyping(input),
77
79
  onMessage: (conversationId, handler, onError) => client.realtime.onMessage(conversationId, {
78
80
  onEvent: handler,
@@ -257,6 +259,10 @@ function readEntry(participant) {
257
259
  function acknowledgement() {
258
260
  return { inFlight: void 0, followUp: false, suppressed: false, target: void 0, acknowledged: void 0, unacknowledgeable: /* @__PURE__ */ new Set() };
259
261
  }
262
+ function capture(conversation) {
263
+ const membership = conversation?.membership;
264
+ return { version: membership?.privateStateVersion, clearPending: membership?.unreadMarkedAt != null };
265
+ }
260
266
  function isTargetMiss(cause) {
261
267
  if (typeof cause !== "object" || cause === null) return false;
262
268
  const { code, status } = cause;
@@ -317,6 +323,7 @@ var ConversationStore = class {
317
323
  // Keep tombstones until an explicit reload/session change, including across refreshes.
318
324
  deleted = /* @__PURE__ */ new Set();
319
325
  ack = acknowledgement();
326
+ captured = capture();
320
327
  // Visible until the platform reports otherwise; unknown/prerender/no document count as visible.
321
328
  visible = true;
322
329
  sendRevision;
@@ -382,6 +389,7 @@ var ConversationStore = class {
382
389
  this.hydrationPool.queued.clear();
383
390
  this.deleted.clear();
384
391
  this.ack = acknowledgement();
392
+ this.captured = capture();
385
393
  this.sendRevision = void 0;
386
394
  this.activeSend = void 0;
387
395
  this.refreshQueued = false;
@@ -626,9 +634,13 @@ var ConversationStore = class {
626
634
  this.validatePage(page);
627
635
  this.cursor = page.at(-1);
628
636
  this.patch({ conversation, messages: this.overlay(page, revision), hasOlderMessages: page.length === this.pageSize });
637
+ this.captured = capture(conversation);
629
638
  this.mergeReads(conversation.participants.map(readEntry));
630
639
  this.prune(revision);
631
- if (this.options.markReadOnLoad ?? true) await this.acknowledge(true);
640
+ if ((this.options.markReadOnLoad ?? true) || this.ack.suppressed) {
641
+ this.ack.suppressed = false;
642
+ await this.acknowledge(true);
643
+ }
632
644
  } catch (cause) {
633
645
  this.fail(cause, generation, true);
634
646
  } finally {
@@ -685,9 +697,12 @@ var ConversationStore = class {
685
697
  const survivingIds = new Set(reconciled.map((message) => message.id));
686
698
  const known = this.state.messages.filter((message) => !isConvoKitPendingMessage(message)).map((message) => message.id).concat([...this.changes].filter(([, change]) => change.insert && change.revision <= revision).map(([id]) => id));
687
699
  for (const id of known) if (!survivingIds.has(id)) this.forget(id);
700
+ const opening = this.state.conversation === null;
688
701
  this.patch({ conversation, messages: reconciled, hasOlderMessages: hasOlder });
702
+ if (opening) this.captured = capture(conversation);
689
703
  this.mergeReads(conversation.participants.map(readEntry));
690
704
  this.prune(revision);
705
+ if (opening) void this.resumeAcknowledgement();
691
706
  } catch (cause) {
692
707
  this.fail(cause, generation, true);
693
708
  } finally {
@@ -721,15 +736,22 @@ var ConversationStore = class {
721
736
  }
722
737
  }
723
738
  };
724
- /** Acknowledge through the newest rendered row now, regardless of visibility; no request without a target. */
739
+ /** Acknowledge through the newest rendered row now, regardless of visibility; no acknowledgement without a
740
+ * target (a room opened with a marker that renders nothing clears the marker instead, once).
741
+ */
725
742
  markRead = () => this.alive() ? this.acknowledge(false) : Promise.resolve();
726
743
  /** Automatic acknowledgements wait while hidden and are re-issued (once) on becoming visible. */
727
744
  setVisible = (visible) => {
728
745
  this.visible = visible;
729
- if (!visible || !this.ack.suppressed || !this.alive()) return;
730
- this.ack.suppressed = false;
731
- void this.acknowledge(true);
746
+ if (!visible || !this.alive()) return;
747
+ void this.resumeAcknowledgement();
732
748
  };
749
+ /** Re-issue (once) the automatic acknowledgement that waited while hidden or before this open's DTO, if any. */
750
+ resumeAcknowledgement() {
751
+ if (!this.ack.suppressed) return Promise.resolve();
752
+ this.ack.suppressed = false;
753
+ return this.acknowledge(true);
754
+ }
733
755
  /** The newest non-pending rendered row by (createdAt, id), never by list index and never a raw realtime
734
756
  * row; rows the server does not know are skipped, and nothing at or before the accepted target is re-sent.
735
757
  */
@@ -744,7 +766,7 @@ var ConversationStore = class {
744
766
  /** Resolves when the request this call issued or joined settles; a follow-up is issued, not awaited. */
745
767
  acknowledge(automatic) {
746
768
  const ack = this.ack;
747
- if (automatic && !this.visible) {
769
+ if (automatic && (!this.visible || this.state.conversation === null)) {
748
770
  ack.suppressed = true;
749
771
  return Promise.resolve();
750
772
  }
@@ -757,9 +779,10 @@ var ConversationStore = class {
757
779
  issue(ack, generation) {
758
780
  ack.followUp = false;
759
781
  const target = this.ackTarget(ack);
760
- if (!target) return void 0;
761
- ack.target = target.id;
762
- ack.inFlight = this.send(ack, generation, target).finally(() => {
782
+ const request = target ? this.send(ack, generation, target) : this.clearMarker(ack, generation);
783
+ if (!request) return void 0;
784
+ ack.target = target?.id;
785
+ ack.inFlight = request.finally(() => {
763
786
  ack.inFlight = void 0;
764
787
  ack.target = void 0;
765
788
  if (!this.alive(generation) || !ack.followUp) return;
@@ -770,9 +793,31 @@ var ConversationStore = class {
770
793
  });
771
794
  return ack.inFlight;
772
795
  }
796
+ /** A room opened with the caller's unread marker that renders no non-pending, acknowledgeable row cannot clear
797
+ * it through a targeted acknowledgement, so it asks the adapter to clear the marker conditionally on the captured
798
+ * version, once per open, under the acknowledgement triggers and visibility gating. Rows rendered later clear it
799
+ * through their acknowledgements; adapters without the member leave it; `cleared: false` is not an error.
800
+ */
801
+ clearMarker(ack, generation) {
802
+ const captured = this.captured;
803
+ if (!captured.clearPending || captured.version === void 0 || typeof this.client.clearConversationUnread !== "function" || this.state.messages.some((message) => !isConvoKitPendingMessage(message) && !ack.unacknowledgeable.has(message.id))) return void 0;
804
+ captured.clearPending = false;
805
+ return this.clearUnread(captured.version, generation);
806
+ }
807
+ async clearUnread(version2, generation) {
808
+ try {
809
+ await this.client.clearConversationUnread(this.room, { ifVersion: version2 });
810
+ } catch (cause) {
811
+ if (this.alive(generation)) this.fail(cause, generation);
812
+ }
813
+ }
773
814
  async send(ack, generation, target) {
774
815
  try {
775
- await this.client.markConversationRead(this.room, { throughMessageId: target.id });
816
+ const version2 = this.captured.version;
817
+ await this.client.markConversationRead(this.room, {
818
+ throughMessageId: target.id,
819
+ ...version2 === void 0 ? {} : { privateStateVersion: version2 }
820
+ });
776
821
  if (!this.alive(generation)) return;
777
822
  if (!ack.acknowledged || compare(target, ack.acknowledged) > 0) ack.acknowledged = target;
778
823
  } catch (cause) {
@@ -1936,6 +1981,66 @@ var ConversationListStore = class {
1936
1981
  else if (!this.state.conversations.length && this.state.hasMore) await this.loadMore();
1937
1982
  };
1938
1983
  setQuery = (query) => this.setFilter({ ...this.state.filter, query });
1984
+ /** Mark a room unread for the viewer only; the row's summary takes the response (D10). Rejects when the adapter
1985
+ * lacks `markConversationUnread` or the store is not active; a request failure is reported through `error`
1986
+ * without evicting rows and rejects.
1987
+ */
1988
+ markUnread = async (conversationId) => {
1989
+ const client = this.options.client;
1990
+ if (typeof client.markConversationUnread !== "function") {
1991
+ throw new TypeError("markUnread requires a ConvoKitUiClient adapter with markConversationUnread (core SDK 0.7)");
1992
+ }
1993
+ this.assertActive();
1994
+ this.applyPrivateState(conversationId, await this.mutate(client.markConversationUnread(conversationId)));
1995
+ };
1996
+ /** Remove the viewer's marker (conditionally on `options.ifVersion`); resolves to the response's `cleared` ("this
1997
+ * request removed the marker", not "the room is read") and patches the summary on true and false alike (D10).
1998
+ * Rejects when the adapter lacks `clearConversationUnread` or the store is not active; failures are reported like
1999
+ * `markUnread`.
2000
+ */
2001
+ clearUnread = async (conversationId, options) => {
2002
+ const client = this.options.client;
2003
+ if (typeof client.clearConversationUnread !== "function") {
2004
+ throw new TypeError("clearUnread requires a ConvoKitUiClient adapter with clearConversationUnread (core SDK 0.7)");
2005
+ }
2006
+ this.assertActive();
2007
+ const result = await this.mutate(client.clearConversationUnread(conversationId, options));
2008
+ this.applyPrivateState(conversationId, result);
2009
+ return result.cleared;
2010
+ };
2011
+ /** A disposed or session-evicted store never sends a private-state mutation: on a shared client it could go out
2012
+ * under a replacement login. Rejected without touching `error` (there is no live snapshot to report into).
2013
+ */
2014
+ assertActive() {
2015
+ if (!this.alive()) throw new Error("ConversationListStore is not active");
2016
+ }
2017
+ async mutate(request) {
2018
+ try {
2019
+ return await request;
2020
+ } catch (cause) {
2021
+ if (this.alive()) this.patch({ error: cause });
2022
+ throw cause;
2023
+ }
2024
+ }
2025
+ /** Apply a mark/clear response to the row's CURRENT summary (a refresh may have swapped it) as one unit, only while
2026
+ * the store is alive and the response is not older than the stored version: a delayed response never resurrects a
2027
+ * marker a newer action removed (equal versions are an idempotent no-op). `isUnread` is recomputed from the stored
2028
+ * counts and the response marker. Other devices learn of the change through `inbox_activity`.
2029
+ */
2030
+ applyPrivateState(conversationId, state) {
2031
+ if (!this.alive()) return;
2032
+ const current = this.entries.find((entry) => entry.conversation.id === conversationId);
2033
+ if (!current || state.privateStateVersion < current.privateStateVersion) return;
2034
+ const { unreadMarkedAt, privateStateVersion } = state;
2035
+ const patched = {
2036
+ ...current,
2037
+ unreadMarkedAt,
2038
+ privateStateVersion,
2039
+ isUnread: current.unreadCount > 0 || current.unreadCountCapped || unreadMarkedAt !== null
2040
+ };
2041
+ this.entries = this.entries.map((entry) => entry === current ? patched : entry);
2042
+ this.patch({ summaries: new Map(this.entries.map((entry) => [entry.conversation.id, summaryOf(entry)])) });
2043
+ }
1939
2044
  };
1940
2045
 
1941
2046
  // src/composables/use-conversation-list.ts
@@ -1983,6 +2088,8 @@ function useConversationList(options) {
1983
2088
  loadMore: () => store.loadMore(),
1984
2089
  setFilter: (filter) => store.setFilter(filter),
1985
2090
  setQuery: (query) => store.setQuery(query),
2091
+ markUnread: (conversationId) => store.markUnread(conversationId),
2092
+ clearUnread: (conversationId, options2) => store.clearUnread(conversationId, options2),
1986
2093
  dispose
1987
2094
  };
1988
2095
  }
@@ -2059,13 +2166,16 @@ var ConversationListView = (0, import_vue10.defineComponent)({
2059
2166
  return void 0;
2060
2167
  };
2061
2168
  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))]);
2169
+ if (summary.unreadCount > 0 || summary.unreadCountCapped) {
2170
+ const capped = summary.unreadCountCapped || summary.unreadCount > 99;
2171
+ return [(0, import_vue10.h)("span", {
2172
+ class: "ckui-unread-badge",
2173
+ role: "img",
2174
+ "aria-label": `${summary.unreadCountCapped ? "99+" : summary.unreadCount} unread`
2175
+ }, [(0, import_vue10.h)("span", { "aria-hidden": "true" }, capped ? "99+" : String(summary.unreadCount))])];
2176
+ }
2177
+ if (summary.isUnread) return [(0, import_vue10.h)("span", { class: "ckui-unread-badge ckui-unread-badge--dot", role: "img", "aria-label": "Unread" })];
2178
+ return [];
2069
2179
  };
2070
2180
  const renderContent = () => {
2071
2181
  const currentAppearance = appearance();
@@ -2108,7 +2218,7 @@ var ConversationListView = (0, import_vue10.defineComponent)({
2108
2218
  ...props.currentUserId === void 0 ? {} : { currentUserId: props.currentUserId }
2109
2219
  };
2110
2220
  const preview = inboxPreview(conversation, summary, props.currentUserId);
2111
- const unread = summary !== void 0 && (summary.unreadCount > 0 || summary.unreadCountCapped);
2221
+ const unread = summary !== void 0 && (summary.isUnread || summary.unreadCount > 0 || summary.unreadCountCapped);
2112
2222
  const item = slots["conversation-item"]?.(slotProps) ?? (0, import_vue10.h)("button", {
2113
2223
  type: "button",
2114
2224
  "data-selected": selected || void 0,
@@ -2132,7 +2242,7 @@ var ConversationListView = (0, import_vue10.defineComponent)({
2132
2242
  // summary must keep 0.5's exact markup.
2133
2243
  ...summary ? [(0, import_vue10.h)("span", { class: "ckui-conversation-item__meta" }, [
2134
2244
  (0, import_vue10.h)("time", { class: "ckui-conversation-item__time", datetime: summary.activityAt.toISOString() }, formatMessageTime(summary.activityAt)),
2135
- unreadBadge(summary)
2245
+ ...unreadBadge(summary)
2136
2246
  ])] : [],
2137
2247
  (0, import_vue10.h)(import_vue9.ChevronRight, { size: 18, "aria-hidden": "true" })
2138
2248
  ]);