@convokitapp/vue-ui 0.6.0 → 0.8.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
@@ -26,7 +26,11 @@ function createConvoKitUiClient(client) {
26
26
  getMessages: (options) => client.getMessages(options),
27
27
  getMessage: (id) => client.getMessage(id),
28
28
  sendMessage: (input) => client.sendMessage(input),
29
+ editMessage: (messageId, input) => client.editMessage(messageId, input),
30
+ deleteMessage: (messageId) => client.deleteMessage(messageId),
29
31
  markConversationRead: (conversationId, options) => client.markConversationRead(conversationId, options),
32
+ markConversationUnread: (conversationId) => client.markConversationUnread(conversationId),
33
+ clearConversationUnread: (conversationId, options) => client.clearConversationUnread(conversationId, options),
30
34
  sendTyping: (input) => client.sendTyping(input),
31
35
  onMessage: (conversationId, handler, onError) => client.realtime.onMessage(conversationId, {
32
36
  onEvent: handler,
@@ -184,12 +188,13 @@ var ConvoKitAvatar = defineComponent({
184
188
  });
185
189
 
186
190
  // src/components/conversation.ts
187
- import { ArrowLeft, LoaderCircle as LoaderCircle2, Paperclip, RefreshCw, Send } from "@lucide/vue";
191
+ import { ArrowLeft, Check as Check2, LoaderCircle as LoaderCircle2, Paperclip, Pencil as Pencil2, RefreshCw, Send } from "@lucide/vue";
188
192
  import {
189
193
  defineComponent as defineComponent3,
190
194
  h as h3,
191
195
  onBeforeUnmount,
192
196
  ref as ref2,
197
+ watch as watch3,
193
198
  watchEffect
194
199
  } from "vue";
195
200
 
@@ -204,9 +209,31 @@ function version(message) {
204
209
  function hasContent(message) {
205
210
  return !!message.text?.trim() || message.media.length > 0;
206
211
  }
212
+ function revisionOrder(left, right) {
213
+ const a = left.revision, b = right.revision;
214
+ if (typeof a !== "number" || typeof b !== "number" || a <= 0 && b <= 0) return void 0;
215
+ return a === b ? void 0 : a - b;
216
+ }
217
+ function older(candidate, reference) {
218
+ const byRevision = revisionOrder(candidate, reference);
219
+ return byRevision === void 0 ? version(candidate) < version(reference) : byRevision < 0;
220
+ }
207
221
  function newest(current, incoming, incomingComplete = true) {
222
+ const byRevision = revisionOrder(current, incoming);
223
+ if (byRevision !== void 0) return byRevision > 0 ? current : incoming;
208
224
  return version(current) > version(incoming) || !incomingComplete && version(current) === version(incoming) ? current : incoming;
209
225
  }
226
+ function isRevisionConflict(cause) {
227
+ if (typeof cause !== "object" || cause === null) return false;
228
+ const { code, status } = cause;
229
+ return code === "REVISION_CONFLICT" || code === void 0 && status === 409;
230
+ }
231
+ function isMessageMissing(cause) {
232
+ return typeof cause === "object" && cause !== null && cause.code === "MESSAGE_NOT_FOUND";
233
+ }
234
+ function localConflict() {
235
+ return Object.assign(new Error("Message was changed since it was loaded"), { code: "REVISION_CONFLICT" });
236
+ }
210
237
  var compare = compareMessageOrder;
211
238
  function positionCursor(position) {
212
239
  return { createdAt: position.createdAt, id: position.messageId };
@@ -217,12 +244,16 @@ function readEntry(participant) {
217
244
  function acknowledgement() {
218
245
  return { inFlight: void 0, followUp: false, suppressed: false, target: void 0, acknowledged: void 0, unacknowledgeable: /* @__PURE__ */ new Set() };
219
246
  }
247
+ function capture(conversation) {
248
+ const membership = conversation?.membership;
249
+ return { version: membership?.privateStateVersion, clearPending: membership?.unreadMarkedAt != null };
250
+ }
220
251
  function isTargetMiss(cause) {
221
252
  if (typeof cause !== "object" || cause === null) return false;
222
253
  const { code, status } = cause;
223
254
  return code === "MESSAGE_NOT_FOUND" || code === void 0 && status === 404;
224
255
  }
225
- function blank(currentUserId = "") {
256
+ function blank(currentUserId = "", support = { edit: false, delete: false }) {
226
257
  return {
227
258
  conversation: null,
228
259
  messages: [],
@@ -236,7 +267,10 @@ function blank(currentUserId = "") {
236
267
  hasOlderMessages: true,
237
268
  hasLoaded: false,
238
269
  error: null,
239
- currentUserId
270
+ currentUserId,
271
+ editingMessage: null,
272
+ canEditMessages: support.edit,
273
+ canDeleteMessages: support.delete
240
274
  };
241
275
  }
242
276
  var ConversationStore = class {
@@ -255,7 +289,8 @@ var ConversationStore = class {
255
289
  }
256
290
  this.owner = this.client.sessionIdentity;
257
291
  this.user = this.owner ? this.client.currentUserId : "";
258
- this.state = blank(this.user);
292
+ this.support = { edit: typeof this.client.editMessage === "function", delete: typeof this.client.deleteMessage === "function" };
293
+ this.state = blank(this.user, this.support);
259
294
  }
260
295
  options;
261
296
  client;
@@ -277,10 +312,18 @@ var ConversationStore = class {
277
312
  // Keep tombstones until an explicit reload/session change, including across refreshes.
278
313
  deleted = /* @__PURE__ */ new Set();
279
314
  ack = acknowledgement();
315
+ captured = capture();
280
316
  // Visible until the platform reports otherwise; unknown/prerender/no document count as visible.
281
317
  visible = true;
282
318
  sendRevision;
283
319
  activeSend;
320
+ /** Adapter support for author edits/deletes, decided once like `listInbox`. */
321
+ support;
322
+ /** The id whose `saveEdit` request is in flight: its outcome (success or 409) decides the edit, so newer rows for
323
+ * it arriving meanwhile (its own UPDATE image, typically) are not reported as a local conflict while it lasts;
324
+ * `settleEditing` re-evaluates them once the request has settled any other way.
325
+ */
326
+ activeEdit;
284
327
  refreshQueued = false;
285
328
  typingTimers = /* @__PURE__ */ new Map();
286
329
  ownTypingTimer;
@@ -299,9 +342,33 @@ var ConversationStore = class {
299
342
  for (const message of patch.messages) this.confirmSend(message);
300
343
  if (this.activeSend.confirmed) patch.messages = patch.messages.filter((message) => message.id !== this.activeSend.pending.id);
301
344
  }
345
+ if (patch.messages) patch = this.trackEditing(patch);
302
346
  this.state = { ...this.state, ...patch };
303
347
  for (const listener of this.listeners) listener();
304
348
  }
349
+ /** Edit mode follows the edited row wherever a message list reaches the state: the row leaving the list (deletion,
350
+ * reconcile tombstone, eviction) ends it, and a row for it with a higher revision than the snapshot (UPDATE image,
351
+ * hydration, reconcile, refresh) is the local conflict: the snapshot is replaced and `error` carries the conflict
352
+ * code, without a request. A save in flight owns its own outcome (`activeEdit`) and re-checks when it settles
353
+ * (`settleEditing`).
354
+ */
355
+ trackEditing(patch) {
356
+ const editing = patch.editingMessage === void 0 ? this.state.editingMessage : patch.editingMessage;
357
+ if (!editing || !patch.messages) return patch;
358
+ const live = patch.messages.find((message) => message.id === editing.id);
359
+ if (!live) return { ...patch, editingMessage: null };
360
+ if (this.activeEdit === editing.id || !(live.revision > editing.revision)) return patch;
361
+ return { ...patch, editingMessage: live, error: localConflict() };
362
+ }
363
+ /** After a save for `id` has settled without deciding the edit (a failure, or a 409 whose reload failed), a newer
364
+ * row for it that arrived during the request is the local conflict after all: the snapshot is replaced and
365
+ * `error` carries the conflict code, so the next save carries the fresh revision without another round trip.
366
+ */
367
+ settleEditing(id) {
368
+ const editing = this.state.editingMessage;
369
+ const live = editing?.id === id ? this.state.messages.find((message) => message.id === id) : void 0;
370
+ if (editing && live && live.revision > editing.revision) this.patch({ editingMessage: live, error: localConflict() });
371
+ }
305
372
  alive(generation = this.generation) {
306
373
  return !this.disposed && generation === this.generation && this.owner !== null && this.client.sessionIdentity === this.owner;
307
374
  }
@@ -342,8 +409,10 @@ var ConversationStore = class {
342
409
  this.hydrationPool.queued.clear();
343
410
  this.deleted.clear();
344
411
  this.ack = acknowledgement();
412
+ this.captured = capture();
345
413
  this.sendRevision = void 0;
346
414
  this.activeSend = void 0;
415
+ this.activeEdit = void 0;
347
416
  this.refreshQueued = false;
348
417
  }
349
418
  dispose = () => {
@@ -352,14 +421,14 @@ var ConversationStore = class {
352
421
  }
353
422
  this.disposed = true;
354
423
  this.clear();
355
- this.patch(blank());
424
+ this.patch(blank("", this.support));
356
425
  };
357
426
  fail(cause, generation, history = false) {
358
427
  if (!this.alive(generation)) return;
359
428
  const status = typeof cause === "object" && cause !== null && "status" in cause ? cause.status : void 0;
360
429
  if (history && (status === 401 || status === 403 || status === 404)) {
361
430
  this.clear();
362
- this.patch({ ...blank(this.user), error: cause, hasLoaded: true, hasOlderMessages: false });
431
+ this.patch({ ...blank(this.user, this.support), error: cause, hasLoaded: true, hasOlderMessages: false });
363
432
  } else this.patch({ error: cause });
364
433
  }
365
434
  attach(generation, data = true) {
@@ -431,7 +500,7 @@ var ConversationStore = class {
431
500
  if (!this.alive(generation) || type !== "insert" && type !== "update" || !this.validMessage(message) || this.deleted.has(message.id)) return;
432
501
  const existing = this.state.messages.find((item) => item.id === message.id);
433
502
  const known = existing ?? this.changes.get(message.id)?.message;
434
- if (known && version(message) < version(known)) return;
503
+ if (known && older(message, known)) return;
435
504
  const insert = type === "insert" || this.changes.get(message.id)?.insert === true;
436
505
  if (!existing && !insert && type === "update" && !this.state.isInitialLoading && !this.state.isLoadingOlder && !this.state.isReconciling) return;
437
506
  const revision = ++this.revision;
@@ -444,7 +513,7 @@ var ConversationStore = class {
444
513
  }
445
514
  record(message, insert, revision, complete) {
446
515
  const existing = this.state.messages.find((item) => item.id === message.id);
447
- if (existing && version(existing) > version(message)) return;
516
+ if (existing && older(message, existing)) return;
448
517
  if (this.confirmSend(message) && !complete && !message.media.length) {
449
518
  message = { ...message, media: this.activeSend.pending.media };
450
519
  this.confirmSend(message);
@@ -495,7 +564,7 @@ var ConversationStore = class {
495
564
  if (!this.currentHydration(job)) return;
496
565
  const full = await this.client.getMessage(id);
497
566
  if (!this.currentHydration(job)) return;
498
- if (!this.validMessage(full) || full.id !== id || full.senderId !== job.message.senderId || version(full) < version(job.message)) {
567
+ if (!this.validMessage(full) || full.id !== id || full.senderId !== job.message.senderId || older(full, job.message)) {
499
568
  throw new Error("Complete message response does not match the observed resource/revision");
500
569
  }
501
570
  this.record(full, job.insert, job.revision, true);
@@ -575,7 +644,7 @@ var ConversationStore = class {
575
644
  if (!this.alive()) return;
576
645
  this.clear();
577
646
  const generation = this.generation;
578
- this.patch({ ...blank(this.user), isInitialLoading: true });
647
+ this.patch({ ...blank(this.user, this.support), isInitialLoading: true });
579
648
  const revision = this.revision;
580
649
  try {
581
650
  this.attach(generation);
@@ -586,9 +655,13 @@ var ConversationStore = class {
586
655
  this.validatePage(page);
587
656
  this.cursor = page.at(-1);
588
657
  this.patch({ conversation, messages: this.overlay(page, revision), hasOlderMessages: page.length === this.pageSize });
658
+ this.captured = capture(conversation);
589
659
  this.mergeReads(conversation.participants.map(readEntry));
590
660
  this.prune(revision);
591
- if (this.options.markReadOnLoad ?? true) await this.acknowledge(true);
661
+ if ((this.options.markReadOnLoad ?? true) || this.ack.suppressed) {
662
+ this.ack.suppressed = false;
663
+ await this.acknowledge(true);
664
+ }
592
665
  } catch (cause) {
593
666
  this.fail(cause, generation, true);
594
667
  } finally {
@@ -645,9 +718,12 @@ var ConversationStore = class {
645
718
  const survivingIds = new Set(reconciled.map((message) => message.id));
646
719
  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));
647
720
  for (const id of known) if (!survivingIds.has(id)) this.forget(id);
721
+ const opening = this.state.conversation === null;
648
722
  this.patch({ conversation, messages: reconciled, hasOlderMessages: hasOlder });
723
+ if (opening) this.captured = capture(conversation);
649
724
  this.mergeReads(conversation.participants.map(readEntry));
650
725
  this.prune(revision);
726
+ if (opening) void this.resumeAcknowledgement();
651
727
  } catch (cause) {
652
728
  this.fail(cause, generation, true);
653
729
  } finally {
@@ -681,15 +757,22 @@ var ConversationStore = class {
681
757
  }
682
758
  }
683
759
  };
684
- /** Acknowledge through the newest rendered row now, regardless of visibility; no request without a target. */
760
+ /** Acknowledge through the newest rendered row now, regardless of visibility; no acknowledgement without a
761
+ * target (a room opened with a marker that renders nothing clears the marker instead, once).
762
+ */
685
763
  markRead = () => this.alive() ? this.acknowledge(false) : Promise.resolve();
686
764
  /** Automatic acknowledgements wait while hidden and are re-issued (once) on becoming visible. */
687
765
  setVisible = (visible) => {
688
766
  this.visible = visible;
689
- if (!visible || !this.ack.suppressed || !this.alive()) return;
690
- this.ack.suppressed = false;
691
- void this.acknowledge(true);
767
+ if (!visible || !this.alive()) return;
768
+ void this.resumeAcknowledgement();
692
769
  };
770
+ /** Re-issue (once) the automatic acknowledgement that waited while hidden or before this open's DTO, if any. */
771
+ resumeAcknowledgement() {
772
+ if (!this.ack.suppressed) return Promise.resolve();
773
+ this.ack.suppressed = false;
774
+ return this.acknowledge(true);
775
+ }
693
776
  /** The newest non-pending rendered row by (createdAt, id), never by list index and never a raw realtime
694
777
  * row; rows the server does not know are skipped, and nothing at or before the accepted target is re-sent.
695
778
  */
@@ -704,7 +787,7 @@ var ConversationStore = class {
704
787
  /** Resolves when the request this call issued or joined settles; a follow-up is issued, not awaited. */
705
788
  acknowledge(automatic) {
706
789
  const ack = this.ack;
707
- if (automatic && !this.visible) {
790
+ if (automatic && (!this.visible || this.state.conversation === null)) {
708
791
  ack.suppressed = true;
709
792
  return Promise.resolve();
710
793
  }
@@ -717,9 +800,10 @@ var ConversationStore = class {
717
800
  issue(ack, generation) {
718
801
  ack.followUp = false;
719
802
  const target = this.ackTarget(ack);
720
- if (!target) return void 0;
721
- ack.target = target.id;
722
- ack.inFlight = this.send(ack, generation, target).finally(() => {
803
+ const request = target ? this.send(ack, generation, target) : this.clearMarker(ack, generation);
804
+ if (!request) return void 0;
805
+ ack.target = target?.id;
806
+ ack.inFlight = request.finally(() => {
723
807
  ack.inFlight = void 0;
724
808
  ack.target = void 0;
725
809
  if (!this.alive(generation) || !ack.followUp) return;
@@ -730,9 +814,31 @@ var ConversationStore = class {
730
814
  });
731
815
  return ack.inFlight;
732
816
  }
817
+ /** A room opened with the caller's unread marker that renders no non-pending, acknowledgeable row cannot clear
818
+ * it through a targeted acknowledgement, so it asks the adapter to clear the marker conditionally on the captured
819
+ * version, once per open, under the acknowledgement triggers and visibility gating. Rows rendered later clear it
820
+ * through their acknowledgements; adapters without the member leave it; `cleared: false` is not an error.
821
+ */
822
+ clearMarker(ack, generation) {
823
+ const captured = this.captured;
824
+ 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;
825
+ captured.clearPending = false;
826
+ return this.clearUnread(captured.version, generation);
827
+ }
828
+ async clearUnread(version2, generation) {
829
+ try {
830
+ await this.client.clearConversationUnread(this.room, { ifVersion: version2 });
831
+ } catch (cause) {
832
+ if (this.alive(generation)) this.fail(cause, generation);
833
+ }
834
+ }
733
835
  async send(ack, generation, target) {
734
836
  try {
735
- await this.client.markConversationRead(this.room, { throughMessageId: target.id });
837
+ const version2 = this.captured.version;
838
+ await this.client.markConversationRead(this.room, {
839
+ throughMessageId: target.id,
840
+ ...version2 === void 0 ? {} : { privateStateVersion: version2 }
841
+ });
736
842
  if (!this.alive(generation)) return;
737
843
  if (!ack.acknowledged || compare(target, ack.acknowledged) > 0) ack.acknowledged = target;
738
844
  } catch (cause) {
@@ -781,7 +887,8 @@ var ConversationStore = class {
781
887
  text: normalized || null,
782
888
  media: media ?? [],
783
889
  createdAt: /* @__PURE__ */ new Date(),
784
- updatedAt: null
890
+ updatedAt: null,
891
+ revision: 0
785
892
  };
786
893
  const send = { pending };
787
894
  this.activeSend = send;
@@ -825,6 +932,132 @@ var ConversationStore = class {
825
932
  }
826
933
  }
827
934
  };
935
+ /** The caller's role in the open room when known (0.7 `membership`, else the caller's participant row). */
936
+ ownRole() {
937
+ const conversation = this.state.conversation;
938
+ return conversation?.membership?.role ?? conversation?.participants.find((participant) => participant.appUserId === this.user || participant.id === this.user)?.role;
939
+ }
940
+ /** A rendered, confirmed row of the caller's own that is not known to be gone. */
941
+ ownRow(messageId) {
942
+ const row = this.state.messages.find((message) => message.id === messageId);
943
+ return row && row.senderId === this.user && !isConvoKitPendingMessage(row) && !this.deleted.has(messageId) ? row : void 0;
944
+ }
945
+ /** Enter edit mode on one of the caller's own confirmed messages (0.8.0): the row as it stands now becomes the
946
+ * snapshot whose `revision` every save sends. A no-op unless the adapter implements `editMessage`, the row is
947
+ * rendered, own, confirmed, not tombstoned and the caller's role (when known) is not `READ`. Sends nothing.
948
+ */
949
+ startEditing = (messageId) => {
950
+ if (!this.alive() || !this.support.edit || this.ownRole() === "READ") return;
951
+ const row = this.ownRow(messageId);
952
+ if (row) this.patch({ editingMessage: row });
953
+ };
954
+ /** Leave edit mode without a request; the draft is the view's to restore. */
955
+ cancelEditing = () => {
956
+ if (this.state.editingMessage) this.patch({ editingMessage: null });
957
+ };
958
+ /** Save the edit in progress with the snapshot's revision (never the live row's), trimming the text and sending
959
+ * `null` for an empty caption. Resolves true when the server accepted the edit (the response is merged through the
960
+ * tombstone and precedence guards and edit mode ends); false when nothing was saved: a stale revision (409
961
+ * `REVISION_CONFLICT`) reloads the row once through `getMessage`, replaces the snapshot with it (the next save
962
+ * carries the fresh revision) and reports the conflict through `error`, keeping edit mode; a coded 404
963
+ * (`MESSAGE_NOT_FOUND`, on the save or on that reload) removes the row and ends edit mode; any other failure
964
+ * (403, 500, network, an uncoded 404 from a 0.7 backend) is reported through `error` without evicting anything and
965
+ * keeps edit mode; if a newer row for the message arrived during such a request, that row is then the local
966
+ * conflict (`settleEditing`). A text-only message cannot be saved empty (no request). Rejects when the adapter
967
+ * lacks `editMessage`.
968
+ */
969
+ saveEdit = async (text) => {
970
+ const client = this.client;
971
+ if (typeof client.editMessage !== "function") {
972
+ throw new TypeError("This ConvoKitUiClient adapter does not implement editMessage (0.8)");
973
+ }
974
+ const snapshot = this.state.editingMessage;
975
+ if (!this.alive() || !snapshot || this.activeEdit !== void 0) return false;
976
+ const trimmed = text.trim();
977
+ const normalized = trimmed === "" ? null : trimmed;
978
+ if (normalized === null && snapshot.media.length === 0) return false;
979
+ const generation = this.generation;
980
+ const id = snapshot.id;
981
+ this.activeEdit = id;
982
+ this.patch({ error: null });
983
+ try {
984
+ const message = await client.editMessage(id, { text: normalized, revision: snapshot.revision });
985
+ if (!this.alive(generation)) return false;
986
+ if (!this.validMessage(message) || message.id !== id || message.senderId !== this.user) {
987
+ throw new Error("Edit response belongs to a different message or sender");
988
+ }
989
+ if (this.state.editingMessage?.id === id) this.patch({ editingMessage: null });
990
+ this.applyRow(message);
991
+ return true;
992
+ } catch (cause) {
993
+ if (!this.alive(generation)) return false;
994
+ if (isRevisionConflict(cause)) await this.reloadConflict(id, cause, generation);
995
+ else if (isMessageMissing(cause)) {
996
+ this.removeMessage(id);
997
+ this.patch({ error: cause });
998
+ } else this.fail(cause, generation);
999
+ return false;
1000
+ } finally {
1001
+ if (this.alive(generation)) {
1002
+ this.activeEdit = void 0;
1003
+ this.settleEditing(id);
1004
+ }
1005
+ }
1006
+ };
1007
+ /** Merge a complete REST row for a known id through the live-row guards: a tombstoned id is dropped, and an older
1008
+ * revision (or timestamp) never overwrites the newer row already recorded. Recorded as a non-insert change, so a
1009
+ * reconcile keeps it only while the row is still in the fetched range.
1010
+ */
1011
+ applyRow(message) {
1012
+ if (this.deleted.has(message.id)) return;
1013
+ this.record(message, this.changes.get(message.id)?.insert === true, ++this.revision, true);
1014
+ }
1015
+ /** The 409 path: one `getMessage` shows the conflicting content. Its row is merged through the guards and becomes
1016
+ * the new snapshot; a `MESSAGE_NOT_FOUND` answer removes the row and ends edit mode; another failure keeps the
1017
+ * snapshot. `error` carries the conflict (or the reload failure).
1018
+ */
1019
+ async reloadConflict(id, conflict, generation) {
1020
+ try {
1021
+ const current = await this.client.getMessage(id);
1022
+ if (!this.alive(generation)) return;
1023
+ if (!this.validMessage(current) || current.id !== id) throw new Error("Complete message response does not match the edited message");
1024
+ this.applyRow(current);
1025
+ const row = this.state.messages.find((message) => message.id === id);
1026
+ const editing = this.state.editingMessage?.id === id && row ? { editingMessage: row } : {};
1027
+ this.patch({ ...editing, error: conflict });
1028
+ } catch (cause) {
1029
+ if (!this.alive(generation)) return;
1030
+ if (isTargetMiss(cause)) this.removeMessage(id);
1031
+ this.patch({ error: cause });
1032
+ }
1033
+ }
1034
+ /** Delete one of the caller's own confirmed messages (0.8.0). The row stays until the server answers: on success,
1035
+ * or when the server no longer knows it (`MESSAGE_NOT_FOUND`), it is tombstoned and removed (late responses, row
1036
+ * images and hydrations for it are dropped, the acknowledgement target is re-resolved and edit mode on it ends)
1037
+ * and the call resolves true; any other failure keeps the row, reports through `error` and resolves false. Rejects
1038
+ * when the adapter lacks `deleteMessage`.
1039
+ */
1040
+ deleteMessage = async (messageId) => {
1041
+ const client = this.client;
1042
+ if (typeof client.deleteMessage !== "function") {
1043
+ throw new TypeError("This ConvoKitUiClient adapter does not implement deleteMessage (0.8)");
1044
+ }
1045
+ if (!this.alive() || !this.ownRow(messageId)) return false;
1046
+ const generation = this.generation;
1047
+ this.patch({ error: null });
1048
+ try {
1049
+ await client.deleteMessage(messageId);
1050
+ } catch (cause) {
1051
+ if (!this.alive(generation)) return false;
1052
+ if (!isMessageMissing(cause)) {
1053
+ this.fail(cause, generation);
1054
+ return false;
1055
+ }
1056
+ }
1057
+ if (!this.alive(generation)) return false;
1058
+ this.removeMessage(messageId);
1059
+ return true;
1060
+ };
828
1061
  readerIdsFor = (message) => readerIdsFor(message, this.state.readAtByUserId, this.state.readPositionByUserId);
829
1062
  };
830
1063
 
@@ -878,11 +1111,18 @@ function useConversation(options) {
878
1111
  hasLoaded: field("hasLoaded"),
879
1112
  error: field("error"),
880
1113
  currentUserId: field("currentUserId"),
1114
+ editingMessage: field("editingMessage"),
1115
+ canEditMessages: field("canEditMessages"),
1116
+ canDeleteMessages: field("canDeleteMessages"),
881
1117
  readerIdsFor: (message) => store.readerIdsFor(message),
882
1118
  loadInitial: () => store.loadInitial(),
883
1119
  refresh: () => store.refresh(),
884
1120
  loadOlderMessages: () => store.loadOlderMessages(),
885
1121
  sendMessage: (input) => store.sendMessage(input),
1122
+ startEditing: (messageId) => store.startEditing(messageId),
1123
+ cancelEditing: () => store.cancelEditing(),
1124
+ saveEdit: (text) => store.saveEdit(text),
1125
+ deleteMessage: (messageId) => store.deleteMessage(messageId),
886
1126
  markRead: () => store.markRead(),
887
1127
  updateTyping: (isTyping) => store.updateTyping(isTyping),
888
1128
  setVisible: (value) => {
@@ -894,6 +1134,7 @@ function useConversation(options) {
894
1134
  }
895
1135
 
896
1136
  // src/components/message-list.ts
1137
+ import { isEditedMessage } from "@convokitapp/sdk";
897
1138
  import {
898
1139
  Check,
899
1140
  CheckCheck,
@@ -903,7 +1144,9 @@ import {
903
1144
  ImageOff,
904
1145
  LoaderCircle,
905
1146
  MapPin,
906
- MessageCircle
1147
+ MessageCircle,
1148
+ Pencil,
1149
+ Trash2
907
1150
  } from "@lucide/vue";
908
1151
  import {
909
1152
  computed as computed2,
@@ -965,6 +1208,10 @@ var MessageListView = defineComponent2({
965
1208
  isLoadingOlder: { type: Boolean, default: false },
966
1209
  error: { type: null, required: false },
967
1210
  onAttachmentClick: { type: Function, default: void 0 },
1211
+ onEditMessage: { type: Function, default: void 0 },
1212
+ onDeleteMessage: { type: Function, default: void 0 },
1213
+ canEditMessage: { type: Function, default: void 0 },
1214
+ confirmDelete: { type: Function, default: void 0 },
968
1215
  scrollElement: { type: Object, default: void 0 },
969
1216
  paginationThreshold: { type: Number, default: 240 },
970
1217
  reverse: { type: Boolean, default: true },
@@ -972,9 +1219,10 @@ var MessageListView = defineComponent2({
972
1219
  formatTime: { type: Function, default: formatMessageTime },
973
1220
  imageLoading: { type: String, default: "lazy" }
974
1221
  },
975
- emits: ["load-older", "attachment-click"],
1222
+ emits: ["load-older", "attachment-click", "edit-message", "delete-message"],
976
1223
  setup(props, { attrs, emit, slots }) {
977
1224
  const internalElement = ref(null);
1225
+ const confirming = ref(null);
978
1226
  let requestInFlight = false;
979
1227
  let lastRequestedLength = null;
980
1228
  let previousMessageCount = 0;
@@ -1014,16 +1262,77 @@ var MessageListView = defineComponent2({
1014
1262
  }
1015
1263
  }
1016
1264
  }, { flush: "post", immediate: true });
1265
+ const viewerRole = computed2(() => props.conversation.membership?.role ?? props.conversation.participants.find((participant) => participant.appUserId === props.currentUserId || participant.id === props.currentUserId)?.role);
1266
+ const remove = async (message) => {
1267
+ if (props.confirmDelete && !await props.confirmDelete(message)) return false;
1268
+ return await props.onDeleteMessage?.(message) !== false;
1269
+ };
1017
1270
  const renderMessage = (message, index) => {
1018
1271
  const isCurrentUser = message.senderId === props.currentUserId;
1019
1272
  const sender = participants.value.get(message.senderId);
1020
1273
  const isPending = isConvoKitPendingMessage(message);
1021
1274
  const readerIds = isPending ? /* @__PURE__ */ new Set() : props.readersResolver ? props.readersResolver(message) : readerIdsFor(message, props.readAtByUserId, props.readPositionByUserId);
1022
- const slotProps = { message, chronologicalIndex: index, isCurrentUser, sender, readerIds };
1275
+ const isEdited = !isPending && isEditedMessage(message);
1276
+ const eligible = !isPending && (props.canEditMessage ? props.canEditMessage(message) : isCurrentUser && viewerRole.value !== "READ");
1277
+ const canEdit = eligible && !!props.onEditMessage;
1278
+ const canDelete = eligible && !!props.onDeleteMessage;
1279
+ const edit = () => {
1280
+ props.onEditMessage?.(message);
1281
+ };
1282
+ const slotProps = {
1283
+ message,
1284
+ chronologicalIndex: index,
1285
+ isCurrentUser,
1286
+ sender,
1287
+ readerIds,
1288
+ isEdited,
1289
+ canEdit,
1290
+ canDelete,
1291
+ ...canEdit ? { edit } : {},
1292
+ ...canDelete ? { remove: () => remove(message) } : {}
1293
+ };
1023
1294
  const custom = slots.message?.(slotProps);
1024
1295
  if (custom) return h2("div", { key: message.id, role: "listitem" }, custom);
1025
1296
  const currentAppearance = appearance();
1026
1297
  const messagePart = isCurrentUser ? "outgoingMessage" : "incomingMessage";
1298
+ const iconButton = (label, onClick, icon) => h2("button", {
1299
+ type: "button",
1300
+ "aria-label": label,
1301
+ onClick,
1302
+ class: partClass("button", currentAppearance, "ckui-icon-button"),
1303
+ style: partStyle("button", currentAppearance)
1304
+ }, [icon]);
1305
+ const actions = canEdit || canDelete ? h2("div", { class: "ckui-message-actions" }, [
1306
+ ...canEdit ? [iconButton("Edit message", edit, h2(Pencil, { size: 16, "aria-hidden": "true" }))] : [],
1307
+ ...canDelete ? [iconButton("Delete message", () => {
1308
+ if (props.confirmDelete) void remove(message);
1309
+ else confirming.value = message.id;
1310
+ }, h2(Trash2, { size: 16, "aria-hidden": "true" }))] : []
1311
+ ]) : null;
1312
+ const confirm = canDelete && confirming.value === message.id ? h2("div", {
1313
+ class: "ckui-message-confirm",
1314
+ role: "group",
1315
+ "aria-label": "Delete this message?"
1316
+ }, [
1317
+ h2("span", "Delete this message?"),
1318
+ h2("button", {
1319
+ type: "button",
1320
+ class: "ckui-link-button",
1321
+ "aria-label": "Confirm delete",
1322
+ onClick: () => {
1323
+ confirming.value = null;
1324
+ void props.onDeleteMessage?.(message);
1325
+ }
1326
+ }, "Delete"),
1327
+ h2("button", {
1328
+ type: "button",
1329
+ class: "ckui-link-button",
1330
+ "aria-label": "Cancel delete",
1331
+ onClick: () => {
1332
+ confirming.value = null;
1333
+ }
1334
+ }, "Cancel")
1335
+ ]) : null;
1027
1336
  const mediaNodes = message.media.map((media, mediaIndex) => {
1028
1337
  const open = props.onAttachmentClick ? () => {
1029
1338
  props.onAttachmentClick?.(media, message);
@@ -1047,15 +1356,19 @@ var MessageListView = defineComponent2({
1047
1356
  style: [props.styles?.message, props.styles?.[messagePart]],
1048
1357
  "data-message-id": message.id
1049
1358
  }, [
1359
+ // Spread, not null: an absent action/label/prompt must not leave a comment node (0.7 markup stays byte-identical).
1360
+ ...actions ? [actions] : [],
1050
1361
  h2("div", { class: "ckui-message-bubble" }, [
1051
1362
  !isCurrentUser ? h2("strong", { class: "ckui-message-sender" }, sender?.name || message.senderId) : null,
1052
1363
  message.text ? h2("div", { class: "ckui-message-text" }, message.text) : null,
1053
1364
  ...mediaNodes,
1054
1365
  h2("span", { class: "ckui-message-time" }, [
1055
1366
  isPending ? "Sending\u2026" : props.formatTime(message.createdAt),
1367
+ ...isEdited ? [h2("span", { class: "ckui-message-edited", "aria-label": "Edited" }, "Edited")] : [],
1056
1368
  isCurrentUser && !isPending ? readerIds.size > 0 ? h2(CheckCheck, { size: 14, "aria-label": "Read" }) : h2(Check, { size: 14, "aria-label": "Sent" }) : null
1057
1369
  ])
1058
1370
  ]),
1371
+ ...confirm ? [confirm] : [],
1059
1372
  isCurrentUser && !isPending ? slots["read-receipt"]?.(receiptSlotProps) ?? h2("div", {
1060
1373
  class: partClass("receipt", currentAppearance, "ckui-read-receipt"),
1061
1374
  style: partStyle("receipt", currentAppearance)
@@ -1144,6 +1457,13 @@ var viewProps = {
1144
1457
  onTypingChange: { type: Function, default: void 0 },
1145
1458
  onAddAttachment: { type: Function, default: void 0 },
1146
1459
  onAttachmentClick: { type: Function, default: void 0 },
1460
+ editingMessage: { type: Object, default: null },
1461
+ onEditMessage: { type: Function, default: void 0 },
1462
+ onSaveEdit: { type: Function, default: void 0 },
1463
+ onCancelEdit: { type: Function, default: void 0 },
1464
+ onDeleteMessage: { type: Function, default: void 0 },
1465
+ canEditMessage: { type: Function, default: void 0 },
1466
+ confirmDelete: { type: Function, default: void 0 },
1147
1467
  isInitialLoading: { type: Boolean, default: false },
1148
1468
  isLoadingOlder: { type: Boolean, default: false },
1149
1469
  isSending: { type: Boolean, default: false },
@@ -1163,6 +1483,9 @@ var viewProps = {
1163
1483
  defaultDraft: { type: String, default: "" },
1164
1484
  onDraftChange: { type: Function, default: void 0 }
1165
1485
  };
1486
+ function editingSummary(message) {
1487
+ return message.text?.trim() || (message.media.length === 1 ? "1 attachment" : `${message.media.length} attachments`);
1488
+ }
1166
1489
  function typingLabel(userIds, displayNameForUser) {
1167
1490
  const names = [...userIds].map(displayNameForUser);
1168
1491
  if (names.length === 0) return "";
@@ -1182,7 +1505,11 @@ var ConversationView = defineComponent3({
1182
1505
  "load-older",
1183
1506
  "add-attachment",
1184
1507
  "attachment-click",
1185
- "update:modelValue"
1508
+ "update:modelValue",
1509
+ "edit-message",
1510
+ "save-edit",
1511
+ "cancel-edit",
1512
+ "delete-message"
1186
1513
  ],
1187
1514
  setup(props, { attrs, emit, slots }) {
1188
1515
  const internalDraft = ref2(props.defaultDraft);
@@ -1195,15 +1522,57 @@ var ConversationView = defineComponent3({
1195
1522
  });
1196
1523
  const draft = () => props.modelValue ?? internalDraft.value;
1197
1524
  let latestDraft = draft();
1198
- const setDraft = (value) => {
1525
+ let stash;
1526
+ let saving = false;
1527
+ const setDraft = (value, typing = true) => {
1199
1528
  latestDraft = value;
1200
1529
  if (props.modelValue === void 0) internalDraft.value = value;
1201
1530
  props.onDraftChange?.(value);
1202
1531
  emit("update:modelValue", value);
1203
- const isTyping = value.trim().length > 0;
1204
- void props.onTypingChange?.(isTyping);
1532
+ if (typing) void props.onTypingChange?.(value.trim().length > 0);
1533
+ };
1534
+ const enterEdit = (message) => {
1535
+ if (stash === void 0) stash = draft();
1536
+ setDraft(message.text ?? "", false);
1537
+ };
1538
+ const leaveEdit = (message, restore) => {
1539
+ if (stash === void 0) return;
1540
+ const saved = stash;
1541
+ stash = void 0;
1542
+ const current = draft();
1543
+ if (restore === "always" || current.trim() === "" || current === (message.text ?? "")) setDraft(saved);
1205
1544
  };
1545
+ watch3(() => props.editingMessage, (next, previous) => {
1546
+ if (next && (!previous || previous.id !== next.id)) enterEdit(next);
1547
+ else if (!next && previous && !saving) leaveEdit(previous, "unchanged");
1548
+ }, { immediate: true });
1549
+ const cancelEdit = () => {
1550
+ const editing = props.editingMessage;
1551
+ if (!editing) return;
1552
+ leaveEdit(editing, "always");
1553
+ props.onCancelEdit?.();
1554
+ };
1555
+ const canSave = (editing) => draft().trim().length > 0 || editing.media.length > 0;
1206
1556
  const submit = async () => {
1557
+ const editing = props.editingMessage;
1558
+ if (editing) {
1559
+ const text2 = draft().trim();
1560
+ if (!canSave(editing) || props.isSending || submitting.value) return;
1561
+ submitting.value = true;
1562
+ saving = true;
1563
+ try {
1564
+ const saved = await props.onSaveEdit?.(editing, text2);
1565
+ if (saved === false) {
1566
+ if (props.editingMessage === null) leaveEdit(editing, "unchanged");
1567
+ return;
1568
+ }
1569
+ leaveEdit(editing, "always");
1570
+ } finally {
1571
+ saving = false;
1572
+ submitting.value = false;
1573
+ }
1574
+ return;
1575
+ }
1207
1576
  const originalDraft = draft();
1208
1577
  const text = originalDraft.trim();
1209
1578
  if (!text || props.isSending || submitting.value) return;
@@ -1272,6 +1641,7 @@ var ConversationView = defineComponent3({
1272
1641
  }, typingLabel(props.typingUserIds, nameForUser));
1273
1642
  };
1274
1643
  const renderComposer = () => {
1644
+ const editing = props.editingMessage;
1275
1645
  const slotProps = {
1276
1646
  value: draft(),
1277
1647
  setValue: setDraft,
@@ -1279,16 +1649,27 @@ var ConversationView = defineComponent3({
1279
1649
  send: () => {
1280
1650
  void submit();
1281
1651
  },
1282
- ...props.onAddAttachment ? { addAttachment } : {}
1652
+ ...props.onAddAttachment ? { addAttachment } : {},
1653
+ ...editing ? { editing, cancelEdit } : {}
1283
1654
  };
1655
+ const busy = props.isSending || submitting.value;
1284
1656
  return slots.composer?.(slotProps) ?? h3("form", {
1285
- class: partClass("composer", appearance(), "ckui-composer"),
1657
+ class: cx(partClass("composer", appearance(), "ckui-composer"), editing && !props.unstyled && "ckui-composer--editing"),
1286
1658
  style: partStyle("composer", appearance()),
1287
1659
  onSubmit: (event) => {
1288
1660
  event.preventDefault();
1289
1661
  void submit();
1290
1662
  }
1291
1663
  }, [
1664
+ // Spread, not null: outside edit mode the composer markup stays byte-identical to 0.7.
1665
+ ...editing ? [h3("div", { class: "ckui-composer__editing", role: "status" }, [
1666
+ h3(Pencil2, { size: 14, "aria-hidden": "true" }),
1667
+ h3("span", { class: "ckui-composer__editing-body" }, [
1668
+ h3("strong", "Editing message"),
1669
+ h3("span", editingSummary(editing))
1670
+ ]),
1671
+ h3("button", { type: "button", class: "ckui-link-button", "aria-label": "Cancel editing", onClick: cancelEdit }, "Cancel")
1672
+ ])] : [],
1292
1673
  props.onAddAttachment ? h3("button", {
1293
1674
  type: "button",
1294
1675
  "aria-label": "Add attachment",
@@ -1317,15 +1698,19 @@ var ConversationView = defineComponent3({
1317
1698
  event.preventDefault();
1318
1699
  void submit();
1319
1700
  }
1701
+ if (event.key === "Escape" && props.editingMessage) {
1702
+ event.preventDefault();
1703
+ cancelEdit();
1704
+ }
1320
1705
  }
1321
1706
  }),
1322
1707
  h3("button", {
1323
1708
  type: "submit",
1324
- "aria-label": "Send message",
1325
- disabled: !draft().trim() || props.isSending || submitting.value,
1709
+ "aria-label": editing ? "Save message" : "Send message",
1710
+ disabled: (editing ? !canSave(editing) : !draft().trim()) || busy,
1326
1711
  class: partClass("button", appearance(), "ckui-send-button"),
1327
1712
  style: partStyle("button", appearance())
1328
- }, [props.isSending || submitting.value ? h3(LoaderCircle2, { class: "ckui-spin", size: 18, "aria-hidden": "true" }) : h3(Send, { size: 18, "aria-hidden": "true" })])
1713
+ }, [busy ? h3(LoaderCircle2, { class: "ckui-spin", size: 18, "aria-hidden": "true" }) : h3(editing ? Check2 : Send, { size: 18, "aria-hidden": "true" })])
1329
1714
  ]);
1330
1715
  };
1331
1716
  return () => {
@@ -1374,6 +1759,12 @@ var ConversationView = defineComponent3({
1374
1759
  ...props.onAttachmentClick ? { onAttachmentClick: (media, message) => {
1375
1760
  props.onAttachmentClick?.(media, message);
1376
1761
  } } : {},
1762
+ ...props.onEditMessage ? { onEditMessage: (message) => {
1763
+ props.onEditMessage?.(message);
1764
+ } } : {},
1765
+ ...props.onDeleteMessage ? { onDeleteMessage: (message) => props.onDeleteMessage?.(message) } : {},
1766
+ ...props.canEditMessage ? { canEditMessage: props.canEditMessage } : {},
1767
+ ...props.confirmDelete ? { confirmDelete: props.confirmDelete } : {},
1377
1768
  reverse: props.reverseMessages,
1378
1769
  stickToBottom: props.stickToBottom,
1379
1770
  paginationThreshold: props.paginationThreshold,
@@ -1404,6 +1795,11 @@ var Conversation = defineComponent3({
1404
1795
  messages: { type: Array, default: () => [] },
1405
1796
  currentUserId: { type: String, default: "" },
1406
1797
  onSendMessage: { type: Function, default: void 0 },
1798
+ editingMessage: { type: Object, default: void 0 },
1799
+ onEditMessage: { type: Function, default: void 0 },
1800
+ onSaveEdit: { type: Function, default: void 0 },
1801
+ onCancelEdit: { type: Function, default: void 0 },
1802
+ onDeleteMessage: { type: Function, default: void 0 },
1407
1803
  client: { type: Object, required: true },
1408
1804
  conversationId: { type: String, required: true },
1409
1805
  messagePageSize: { type: Number, default: 30 },
@@ -1413,7 +1809,21 @@ var Conversation = defineComponent3({
1413
1809
  autoLoad: { type: Boolean, default: true },
1414
1810
  onControllerChange: { type: Function, default: void 0 }
1415
1811
  },
1416
- emits: ["controller-change", "send-message", "typing-change", "back", "refresh", "load-older", "add-attachment", "attachment-click", "update:modelValue"],
1812
+ emits: [
1813
+ "controller-change",
1814
+ "send-message",
1815
+ "typing-change",
1816
+ "back",
1817
+ "refresh",
1818
+ "load-older",
1819
+ "add-attachment",
1820
+ "attachment-click",
1821
+ "update:modelValue",
1822
+ "edit-message",
1823
+ "save-edit",
1824
+ "cancel-edit",
1825
+ "delete-message"
1826
+ ],
1417
1827
  setup(props, { attrs, emit, expose, slots }) {
1418
1828
  const controller = useConversation({
1419
1829
  client: () => props.client,
@@ -1477,6 +1887,11 @@ var Conversation = defineComponent3({
1477
1887
  isSending: _isSending,
1478
1888
  hasOlderMessages: _hasOlderMessages,
1479
1889
  error: _error,
1890
+ editingMessage: _editingMessage,
1891
+ onEditMessage: _onEditMessage,
1892
+ onSaveEdit: _onSaveEdit,
1893
+ onCancelEdit: _onCancelEdit,
1894
+ onDeleteMessage: _onDeleteMessage,
1480
1895
  ...forwarded
1481
1896
  } = props;
1482
1897
  return h3(ConversationView, {
@@ -1500,6 +1915,28 @@ var Conversation = defineComponent3({
1500
1915
  isSending: controller.isSending.value,
1501
1916
  hasOlderMessages: controller.hasOlderMessages.value,
1502
1917
  ...controller.error.value == null ? {} : { error: controller.error.value },
1918
+ // Edit mode is the store's; the actions render only while the adapter supports them (0.8.0).
1919
+ editingMessage: controller.editingMessage.value,
1920
+ ...controller.canEditMessages.value ? {
1921
+ onEditMessage: (message) => {
1922
+ emit("edit-message", message);
1923
+ controller.startEditing(message.id);
1924
+ },
1925
+ onSaveEdit: async (message, text) => {
1926
+ emit("save-edit", message, text);
1927
+ return controller.saveEdit(text);
1928
+ },
1929
+ onCancelEdit: () => {
1930
+ emit("cancel-edit");
1931
+ controller.cancelEditing();
1932
+ }
1933
+ } : {},
1934
+ ...controller.canDeleteMessages.value ? {
1935
+ onDeleteMessage: async (message) => {
1936
+ emit("delete-message", message);
1937
+ return controller.deleteMessage(message.id);
1938
+ }
1939
+ } : {},
1503
1940
  "onUpdate:modelValue": (value) => emit("update:modelValue", value),
1504
1941
  ...props.onBack ? { onBack: () => {
1505
1942
  props.onBack?.();
@@ -1528,7 +1965,7 @@ import {
1528
1965
  } from "vue";
1529
1966
 
1530
1967
  // src/composables/use-conversation-list.ts
1531
- import { computed as computed3, getCurrentScope as getCurrentScope2, onScopeDispose as onScopeDispose2, shallowRef as shallowRef2, toValue as toValue2, watch as watch3 } from "vue";
1968
+ import { computed as computed3, getCurrentScope as getCurrentScope2, onScopeDispose as onScopeDispose2, shallowRef as shallowRef2, toValue as toValue2, watch as watch4 } from "vue";
1532
1969
 
1533
1970
  // src/conversation-list-store.ts
1534
1971
  var defaultActivityRefreshWindowMs = 500;
@@ -1918,6 +2355,66 @@ var ConversationListStore = class {
1918
2355
  else if (!this.state.conversations.length && this.state.hasMore) await this.loadMore();
1919
2356
  };
1920
2357
  setQuery = (query) => this.setFilter({ ...this.state.filter, query });
2358
+ /** Mark a room unread for the viewer only; the row's summary takes the response (D10). Rejects when the adapter
2359
+ * lacks `markConversationUnread` or the store is not active; a request failure is reported through `error`
2360
+ * without evicting rows and rejects.
2361
+ */
2362
+ markUnread = async (conversationId) => {
2363
+ const client = this.options.client;
2364
+ if (typeof client.markConversationUnread !== "function") {
2365
+ throw new TypeError("markUnread requires a ConvoKitUiClient adapter with markConversationUnread (core SDK 0.7)");
2366
+ }
2367
+ this.assertActive();
2368
+ this.applyPrivateState(conversationId, await this.mutate(client.markConversationUnread(conversationId)));
2369
+ };
2370
+ /** Remove the viewer's marker (conditionally on `options.ifVersion`); resolves to the response's `cleared` ("this
2371
+ * request removed the marker", not "the room is read") and patches the summary on true and false alike (D10).
2372
+ * Rejects when the adapter lacks `clearConversationUnread` or the store is not active; failures are reported like
2373
+ * `markUnread`.
2374
+ */
2375
+ clearUnread = async (conversationId, options) => {
2376
+ const client = this.options.client;
2377
+ if (typeof client.clearConversationUnread !== "function") {
2378
+ throw new TypeError("clearUnread requires a ConvoKitUiClient adapter with clearConversationUnread (core SDK 0.7)");
2379
+ }
2380
+ this.assertActive();
2381
+ const result = await this.mutate(client.clearConversationUnread(conversationId, options));
2382
+ this.applyPrivateState(conversationId, result);
2383
+ return result.cleared;
2384
+ };
2385
+ /** A disposed or session-evicted store never sends a private-state mutation: on a shared client it could go out
2386
+ * under a replacement login. Rejected without touching `error` (there is no live snapshot to report into).
2387
+ */
2388
+ assertActive() {
2389
+ if (!this.alive()) throw new Error("ConversationListStore is not active");
2390
+ }
2391
+ async mutate(request) {
2392
+ try {
2393
+ return await request;
2394
+ } catch (cause) {
2395
+ if (this.alive()) this.patch({ error: cause });
2396
+ throw cause;
2397
+ }
2398
+ }
2399
+ /** Apply a mark/clear response to the row's CURRENT summary (a refresh may have swapped it) as one unit, only while
2400
+ * the store is alive and the response is not older than the stored version: a delayed response never resurrects a
2401
+ * marker a newer action removed (equal versions are an idempotent no-op). `isUnread` is recomputed from the stored
2402
+ * counts and the response marker. Other devices learn of the change through `inbox_activity`.
2403
+ */
2404
+ applyPrivateState(conversationId, state) {
2405
+ if (!this.alive()) return;
2406
+ const current = this.entries.find((entry) => entry.conversation.id === conversationId);
2407
+ if (!current || state.privateStateVersion < current.privateStateVersion) return;
2408
+ const { unreadMarkedAt, privateStateVersion } = state;
2409
+ const patched = {
2410
+ ...current,
2411
+ unreadMarkedAt,
2412
+ privateStateVersion,
2413
+ isUnread: current.unreadCount > 0 || current.unreadCountCapped || unreadMarkedAt !== null
2414
+ };
2415
+ this.entries = this.entries.map((entry) => entry === current ? patched : entry);
2416
+ this.patch({ summaries: new Map(this.entries.map((entry) => [entry.conversation.id, summaryOf(entry)])) });
2417
+ }
1921
2418
  };
1922
2419
 
1923
2420
  // src/composables/use-conversation-list.ts
@@ -1926,7 +2423,7 @@ function useConversationList(options) {
1926
2423
  let store = createStore();
1927
2424
  const snapshot = shallowRef2(store.getSnapshot());
1928
2425
  let unsubscribe;
1929
- const stop = watch3(
2426
+ const stop = watch4(
1930
2427
  () => [toValue2(options.client), toValue2(options.client).sessionIdentity],
1931
2428
  () => {
1932
2429
  store.dispose();
@@ -1965,6 +2462,8 @@ function useConversationList(options) {
1965
2462
  loadMore: () => store.loadMore(),
1966
2463
  setFilter: (filter) => store.setFilter(filter),
1967
2464
  setQuery: (query) => store.setQuery(query),
2465
+ markUnread: (conversationId) => store.markUnread(conversationId),
2466
+ clearUnread: (conversationId, options2) => store.clearUnread(conversationId, options2),
1968
2467
  dispose
1969
2468
  };
1970
2469
  }
@@ -2041,13 +2540,16 @@ var ConversationListView = defineComponent4({
2041
2540
  return void 0;
2042
2541
  };
2043
2542
  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))]);
2543
+ if (summary.unreadCount > 0 || summary.unreadCountCapped) {
2544
+ const capped = summary.unreadCountCapped || summary.unreadCount > 99;
2545
+ return [h4("span", {
2546
+ class: "ckui-unread-badge",
2547
+ role: "img",
2548
+ "aria-label": `${summary.unreadCountCapped ? "99+" : summary.unreadCount} unread`
2549
+ }, [h4("span", { "aria-hidden": "true" }, capped ? "99+" : String(summary.unreadCount))])];
2550
+ }
2551
+ if (summary.isUnread) return [h4("span", { class: "ckui-unread-badge ckui-unread-badge--dot", role: "img", "aria-label": "Unread" })];
2552
+ return [];
2051
2553
  };
2052
2554
  const renderContent = () => {
2053
2555
  const currentAppearance = appearance();
@@ -2090,7 +2592,7 @@ var ConversationListView = defineComponent4({
2090
2592
  ...props.currentUserId === void 0 ? {} : { currentUserId: props.currentUserId }
2091
2593
  };
2092
2594
  const preview = inboxPreview(conversation, summary, props.currentUserId);
2093
- const unread = summary !== void 0 && (summary.unreadCount > 0 || summary.unreadCountCapped);
2595
+ const unread = summary !== void 0 && (summary.isUnread || summary.unreadCount > 0 || summary.unreadCountCapped);
2094
2596
  const item = slots["conversation-item"]?.(slotProps) ?? h4("button", {
2095
2597
  type: "button",
2096
2598
  "data-selected": selected || void 0,
@@ -2114,7 +2616,7 @@ var ConversationListView = defineComponent4({
2114
2616
  // summary must keep 0.5's exact markup.
2115
2617
  ...summary ? [h4("span", { class: "ckui-conversation-item__meta" }, [
2116
2618
  h4("time", { class: "ckui-conversation-item__time", datetime: summary.activityAt.toISOString() }, formatMessageTime(summary.activityAt)),
2117
- unreadBadge(summary)
2619
+ ...unreadBadge(summary)
2118
2620
  ])] : [],
2119
2621
  h4(ChevronRight, { size: 18, "aria-hidden": "true" })
2120
2622
  ]);