@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.cjs CHANGED
@@ -72,7 +72,11 @@ function createConvoKitUiClient(client) {
72
72
  getMessages: (options) => client.getMessages(options),
73
73
  getMessage: (id) => client.getMessage(id),
74
74
  sendMessage: (input) => client.sendMessage(input),
75
+ editMessage: (messageId, input) => client.editMessage(messageId, input),
76
+ deleteMessage: (messageId) => client.deleteMessage(messageId),
75
77
  markConversationRead: (conversationId, options) => client.markConversationRead(conversationId, options),
78
+ markConversationUnread: (conversationId) => client.markConversationUnread(conversationId),
79
+ clearConversationUnread: (conversationId, options) => client.clearConversationUnread(conversationId, options),
76
80
  sendTyping: (input) => client.sendTyping(input),
77
81
  onMessage: (conversationId, handler, onError) => client.realtime.onMessage(conversationId, {
78
82
  onEvent: handler,
@@ -244,9 +248,31 @@ function version(message) {
244
248
  function hasContent(message) {
245
249
  return !!message.text?.trim() || message.media.length > 0;
246
250
  }
251
+ function revisionOrder(left, right) {
252
+ const a = left.revision, b = right.revision;
253
+ if (typeof a !== "number" || typeof b !== "number" || a <= 0 && b <= 0) return void 0;
254
+ return a === b ? void 0 : a - b;
255
+ }
256
+ function older(candidate, reference) {
257
+ const byRevision = revisionOrder(candidate, reference);
258
+ return byRevision === void 0 ? version(candidate) < version(reference) : byRevision < 0;
259
+ }
247
260
  function newest(current, incoming, incomingComplete = true) {
261
+ const byRevision = revisionOrder(current, incoming);
262
+ if (byRevision !== void 0) return byRevision > 0 ? current : incoming;
248
263
  return version(current) > version(incoming) || !incomingComplete && version(current) === version(incoming) ? current : incoming;
249
264
  }
265
+ function isRevisionConflict(cause) {
266
+ if (typeof cause !== "object" || cause === null) return false;
267
+ const { code, status } = cause;
268
+ return code === "REVISION_CONFLICT" || code === void 0 && status === 409;
269
+ }
270
+ function isMessageMissing(cause) {
271
+ return typeof cause === "object" && cause !== null && cause.code === "MESSAGE_NOT_FOUND";
272
+ }
273
+ function localConflict() {
274
+ return Object.assign(new Error("Message was changed since it was loaded"), { code: "REVISION_CONFLICT" });
275
+ }
250
276
  var compare = compareMessageOrder;
251
277
  function positionCursor(position) {
252
278
  return { createdAt: position.createdAt, id: position.messageId };
@@ -257,12 +283,16 @@ function readEntry(participant) {
257
283
  function acknowledgement() {
258
284
  return { inFlight: void 0, followUp: false, suppressed: false, target: void 0, acknowledged: void 0, unacknowledgeable: /* @__PURE__ */ new Set() };
259
285
  }
286
+ function capture(conversation) {
287
+ const membership = conversation?.membership;
288
+ return { version: membership?.privateStateVersion, clearPending: membership?.unreadMarkedAt != null };
289
+ }
260
290
  function isTargetMiss(cause) {
261
291
  if (typeof cause !== "object" || cause === null) return false;
262
292
  const { code, status } = cause;
263
293
  return code === "MESSAGE_NOT_FOUND" || code === void 0 && status === 404;
264
294
  }
265
- function blank(currentUserId = "") {
295
+ function blank(currentUserId = "", support = { edit: false, delete: false }) {
266
296
  return {
267
297
  conversation: null,
268
298
  messages: [],
@@ -276,7 +306,10 @@ function blank(currentUserId = "") {
276
306
  hasOlderMessages: true,
277
307
  hasLoaded: false,
278
308
  error: null,
279
- currentUserId
309
+ currentUserId,
310
+ editingMessage: null,
311
+ canEditMessages: support.edit,
312
+ canDeleteMessages: support.delete
280
313
  };
281
314
  }
282
315
  var ConversationStore = class {
@@ -295,7 +328,8 @@ var ConversationStore = class {
295
328
  }
296
329
  this.owner = this.client.sessionIdentity;
297
330
  this.user = this.owner ? this.client.currentUserId : "";
298
- this.state = blank(this.user);
331
+ this.support = { edit: typeof this.client.editMessage === "function", delete: typeof this.client.deleteMessage === "function" };
332
+ this.state = blank(this.user, this.support);
299
333
  }
300
334
  options;
301
335
  client;
@@ -317,10 +351,18 @@ var ConversationStore = class {
317
351
  // Keep tombstones until an explicit reload/session change, including across refreshes.
318
352
  deleted = /* @__PURE__ */ new Set();
319
353
  ack = acknowledgement();
354
+ captured = capture();
320
355
  // Visible until the platform reports otherwise; unknown/prerender/no document count as visible.
321
356
  visible = true;
322
357
  sendRevision;
323
358
  activeSend;
359
+ /** Adapter support for author edits/deletes, decided once like `listInbox`. */
360
+ support;
361
+ /** The id whose `saveEdit` request is in flight: its outcome (success or 409) decides the edit, so newer rows for
362
+ * it arriving meanwhile (its own UPDATE image, typically) are not reported as a local conflict while it lasts;
363
+ * `settleEditing` re-evaluates them once the request has settled any other way.
364
+ */
365
+ activeEdit;
324
366
  refreshQueued = false;
325
367
  typingTimers = /* @__PURE__ */ new Map();
326
368
  ownTypingTimer;
@@ -339,9 +381,33 @@ var ConversationStore = class {
339
381
  for (const message of patch.messages) this.confirmSend(message);
340
382
  if (this.activeSend.confirmed) patch.messages = patch.messages.filter((message) => message.id !== this.activeSend.pending.id);
341
383
  }
384
+ if (patch.messages) patch = this.trackEditing(patch);
342
385
  this.state = { ...this.state, ...patch };
343
386
  for (const listener of this.listeners) listener();
344
387
  }
388
+ /** Edit mode follows the edited row wherever a message list reaches the state: the row leaving the list (deletion,
389
+ * reconcile tombstone, eviction) ends it, and a row for it with a higher revision than the snapshot (UPDATE image,
390
+ * hydration, reconcile, refresh) is the local conflict: the snapshot is replaced and `error` carries the conflict
391
+ * code, without a request. A save in flight owns its own outcome (`activeEdit`) and re-checks when it settles
392
+ * (`settleEditing`).
393
+ */
394
+ trackEditing(patch) {
395
+ const editing = patch.editingMessage === void 0 ? this.state.editingMessage : patch.editingMessage;
396
+ if (!editing || !patch.messages) return patch;
397
+ const live = patch.messages.find((message) => message.id === editing.id);
398
+ if (!live) return { ...patch, editingMessage: null };
399
+ if (this.activeEdit === editing.id || !(live.revision > editing.revision)) return patch;
400
+ return { ...patch, editingMessage: live, error: localConflict() };
401
+ }
402
+ /** After a save for `id` has settled without deciding the edit (a failure, or a 409 whose reload failed), a newer
403
+ * row for it that arrived during the request is the local conflict after all: the snapshot is replaced and
404
+ * `error` carries the conflict code, so the next save carries the fresh revision without another round trip.
405
+ */
406
+ settleEditing(id) {
407
+ const editing = this.state.editingMessage;
408
+ const live = editing?.id === id ? this.state.messages.find((message) => message.id === id) : void 0;
409
+ if (editing && live && live.revision > editing.revision) this.patch({ editingMessage: live, error: localConflict() });
410
+ }
345
411
  alive(generation = this.generation) {
346
412
  return !this.disposed && generation === this.generation && this.owner !== null && this.client.sessionIdentity === this.owner;
347
413
  }
@@ -382,8 +448,10 @@ var ConversationStore = class {
382
448
  this.hydrationPool.queued.clear();
383
449
  this.deleted.clear();
384
450
  this.ack = acknowledgement();
451
+ this.captured = capture();
385
452
  this.sendRevision = void 0;
386
453
  this.activeSend = void 0;
454
+ this.activeEdit = void 0;
387
455
  this.refreshQueued = false;
388
456
  }
389
457
  dispose = () => {
@@ -392,14 +460,14 @@ var ConversationStore = class {
392
460
  }
393
461
  this.disposed = true;
394
462
  this.clear();
395
- this.patch(blank());
463
+ this.patch(blank("", this.support));
396
464
  };
397
465
  fail(cause, generation, history = false) {
398
466
  if (!this.alive(generation)) return;
399
467
  const status = typeof cause === "object" && cause !== null && "status" in cause ? cause.status : void 0;
400
468
  if (history && (status === 401 || status === 403 || status === 404)) {
401
469
  this.clear();
402
- this.patch({ ...blank(this.user), error: cause, hasLoaded: true, hasOlderMessages: false });
470
+ this.patch({ ...blank(this.user, this.support), error: cause, hasLoaded: true, hasOlderMessages: false });
403
471
  } else this.patch({ error: cause });
404
472
  }
405
473
  attach(generation, data = true) {
@@ -471,7 +539,7 @@ var ConversationStore = class {
471
539
  if (!this.alive(generation) || type !== "insert" && type !== "update" || !this.validMessage(message) || this.deleted.has(message.id)) return;
472
540
  const existing = this.state.messages.find((item) => item.id === message.id);
473
541
  const known = existing ?? this.changes.get(message.id)?.message;
474
- if (known && version(message) < version(known)) return;
542
+ if (known && older(message, known)) return;
475
543
  const insert = type === "insert" || this.changes.get(message.id)?.insert === true;
476
544
  if (!existing && !insert && type === "update" && !this.state.isInitialLoading && !this.state.isLoadingOlder && !this.state.isReconciling) return;
477
545
  const revision = ++this.revision;
@@ -484,7 +552,7 @@ var ConversationStore = class {
484
552
  }
485
553
  record(message, insert, revision, complete) {
486
554
  const existing = this.state.messages.find((item) => item.id === message.id);
487
- if (existing && version(existing) > version(message)) return;
555
+ if (existing && older(message, existing)) return;
488
556
  if (this.confirmSend(message) && !complete && !message.media.length) {
489
557
  message = { ...message, media: this.activeSend.pending.media };
490
558
  this.confirmSend(message);
@@ -535,7 +603,7 @@ var ConversationStore = class {
535
603
  if (!this.currentHydration(job)) return;
536
604
  const full = await this.client.getMessage(id);
537
605
  if (!this.currentHydration(job)) return;
538
- if (!this.validMessage(full) || full.id !== id || full.senderId !== job.message.senderId || version(full) < version(job.message)) {
606
+ if (!this.validMessage(full) || full.id !== id || full.senderId !== job.message.senderId || older(full, job.message)) {
539
607
  throw new Error("Complete message response does not match the observed resource/revision");
540
608
  }
541
609
  this.record(full, job.insert, job.revision, true);
@@ -615,7 +683,7 @@ var ConversationStore = class {
615
683
  if (!this.alive()) return;
616
684
  this.clear();
617
685
  const generation = this.generation;
618
- this.patch({ ...blank(this.user), isInitialLoading: true });
686
+ this.patch({ ...blank(this.user, this.support), isInitialLoading: true });
619
687
  const revision = this.revision;
620
688
  try {
621
689
  this.attach(generation);
@@ -626,9 +694,13 @@ var ConversationStore = class {
626
694
  this.validatePage(page);
627
695
  this.cursor = page.at(-1);
628
696
  this.patch({ conversation, messages: this.overlay(page, revision), hasOlderMessages: page.length === this.pageSize });
697
+ this.captured = capture(conversation);
629
698
  this.mergeReads(conversation.participants.map(readEntry));
630
699
  this.prune(revision);
631
- if (this.options.markReadOnLoad ?? true) await this.acknowledge(true);
700
+ if ((this.options.markReadOnLoad ?? true) || this.ack.suppressed) {
701
+ this.ack.suppressed = false;
702
+ await this.acknowledge(true);
703
+ }
632
704
  } catch (cause) {
633
705
  this.fail(cause, generation, true);
634
706
  } finally {
@@ -685,9 +757,12 @@ var ConversationStore = class {
685
757
  const survivingIds = new Set(reconciled.map((message) => message.id));
686
758
  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
759
  for (const id of known) if (!survivingIds.has(id)) this.forget(id);
760
+ const opening = this.state.conversation === null;
688
761
  this.patch({ conversation, messages: reconciled, hasOlderMessages: hasOlder });
762
+ if (opening) this.captured = capture(conversation);
689
763
  this.mergeReads(conversation.participants.map(readEntry));
690
764
  this.prune(revision);
765
+ if (opening) void this.resumeAcknowledgement();
691
766
  } catch (cause) {
692
767
  this.fail(cause, generation, true);
693
768
  } finally {
@@ -721,15 +796,22 @@ var ConversationStore = class {
721
796
  }
722
797
  }
723
798
  };
724
- /** Acknowledge through the newest rendered row now, regardless of visibility; no request without a target. */
799
+ /** Acknowledge through the newest rendered row now, regardless of visibility; no acknowledgement without a
800
+ * target (a room opened with a marker that renders nothing clears the marker instead, once).
801
+ */
725
802
  markRead = () => this.alive() ? this.acknowledge(false) : Promise.resolve();
726
803
  /** Automatic acknowledgements wait while hidden and are re-issued (once) on becoming visible. */
727
804
  setVisible = (visible) => {
728
805
  this.visible = visible;
729
- if (!visible || !this.ack.suppressed || !this.alive()) return;
730
- this.ack.suppressed = false;
731
- void this.acknowledge(true);
806
+ if (!visible || !this.alive()) return;
807
+ void this.resumeAcknowledgement();
732
808
  };
809
+ /** Re-issue (once) the automatic acknowledgement that waited while hidden or before this open's DTO, if any. */
810
+ resumeAcknowledgement() {
811
+ if (!this.ack.suppressed) return Promise.resolve();
812
+ this.ack.suppressed = false;
813
+ return this.acknowledge(true);
814
+ }
733
815
  /** The newest non-pending rendered row by (createdAt, id), never by list index and never a raw realtime
734
816
  * row; rows the server does not know are skipped, and nothing at or before the accepted target is re-sent.
735
817
  */
@@ -744,7 +826,7 @@ var ConversationStore = class {
744
826
  /** Resolves when the request this call issued or joined settles; a follow-up is issued, not awaited. */
745
827
  acknowledge(automatic) {
746
828
  const ack = this.ack;
747
- if (automatic && !this.visible) {
829
+ if (automatic && (!this.visible || this.state.conversation === null)) {
748
830
  ack.suppressed = true;
749
831
  return Promise.resolve();
750
832
  }
@@ -757,9 +839,10 @@ var ConversationStore = class {
757
839
  issue(ack, generation) {
758
840
  ack.followUp = false;
759
841
  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(() => {
842
+ const request = target ? this.send(ack, generation, target) : this.clearMarker(ack, generation);
843
+ if (!request) return void 0;
844
+ ack.target = target?.id;
845
+ ack.inFlight = request.finally(() => {
763
846
  ack.inFlight = void 0;
764
847
  ack.target = void 0;
765
848
  if (!this.alive(generation) || !ack.followUp) return;
@@ -770,9 +853,31 @@ var ConversationStore = class {
770
853
  });
771
854
  return ack.inFlight;
772
855
  }
856
+ /** A room opened with the caller's unread marker that renders no non-pending, acknowledgeable row cannot clear
857
+ * it through a targeted acknowledgement, so it asks the adapter to clear the marker conditionally on the captured
858
+ * version, once per open, under the acknowledgement triggers and visibility gating. Rows rendered later clear it
859
+ * through their acknowledgements; adapters without the member leave it; `cleared: false` is not an error.
860
+ */
861
+ clearMarker(ack, generation) {
862
+ const captured = this.captured;
863
+ 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;
864
+ captured.clearPending = false;
865
+ return this.clearUnread(captured.version, generation);
866
+ }
867
+ async clearUnread(version2, generation) {
868
+ try {
869
+ await this.client.clearConversationUnread(this.room, { ifVersion: version2 });
870
+ } catch (cause) {
871
+ if (this.alive(generation)) this.fail(cause, generation);
872
+ }
873
+ }
773
874
  async send(ack, generation, target) {
774
875
  try {
775
- await this.client.markConversationRead(this.room, { throughMessageId: target.id });
876
+ const version2 = this.captured.version;
877
+ await this.client.markConversationRead(this.room, {
878
+ throughMessageId: target.id,
879
+ ...version2 === void 0 ? {} : { privateStateVersion: version2 }
880
+ });
776
881
  if (!this.alive(generation)) return;
777
882
  if (!ack.acknowledged || compare(target, ack.acknowledged) > 0) ack.acknowledged = target;
778
883
  } catch (cause) {
@@ -821,7 +926,8 @@ var ConversationStore = class {
821
926
  text: normalized || null,
822
927
  media: media ?? [],
823
928
  createdAt: /* @__PURE__ */ new Date(),
824
- updatedAt: null
929
+ updatedAt: null,
930
+ revision: 0
825
931
  };
826
932
  const send = { pending };
827
933
  this.activeSend = send;
@@ -865,6 +971,132 @@ var ConversationStore = class {
865
971
  }
866
972
  }
867
973
  };
974
+ /** The caller's role in the open room when known (0.7 `membership`, else the caller's participant row). */
975
+ ownRole() {
976
+ const conversation = this.state.conversation;
977
+ return conversation?.membership?.role ?? conversation?.participants.find((participant) => participant.appUserId === this.user || participant.id === this.user)?.role;
978
+ }
979
+ /** A rendered, confirmed row of the caller's own that is not known to be gone. */
980
+ ownRow(messageId) {
981
+ const row = this.state.messages.find((message) => message.id === messageId);
982
+ return row && row.senderId === this.user && !isConvoKitPendingMessage(row) && !this.deleted.has(messageId) ? row : void 0;
983
+ }
984
+ /** Enter edit mode on one of the caller's own confirmed messages (0.8.0): the row as it stands now becomes the
985
+ * snapshot whose `revision` every save sends. A no-op unless the adapter implements `editMessage`, the row is
986
+ * rendered, own, confirmed, not tombstoned and the caller's role (when known) is not `READ`. Sends nothing.
987
+ */
988
+ startEditing = (messageId) => {
989
+ if (!this.alive() || !this.support.edit || this.ownRole() === "READ") return;
990
+ const row = this.ownRow(messageId);
991
+ if (row) this.patch({ editingMessage: row });
992
+ };
993
+ /** Leave edit mode without a request; the draft is the view's to restore. */
994
+ cancelEditing = () => {
995
+ if (this.state.editingMessage) this.patch({ editingMessage: null });
996
+ };
997
+ /** Save the edit in progress with the snapshot's revision (never the live row's), trimming the text and sending
998
+ * `null` for an empty caption. Resolves true when the server accepted the edit (the response is merged through the
999
+ * tombstone and precedence guards and edit mode ends); false when nothing was saved: a stale revision (409
1000
+ * `REVISION_CONFLICT`) reloads the row once through `getMessage`, replaces the snapshot with it (the next save
1001
+ * carries the fresh revision) and reports the conflict through `error`, keeping edit mode; a coded 404
1002
+ * (`MESSAGE_NOT_FOUND`, on the save or on that reload) removes the row and ends edit mode; any other failure
1003
+ * (403, 500, network, an uncoded 404 from a 0.7 backend) is reported through `error` without evicting anything and
1004
+ * keeps edit mode; if a newer row for the message arrived during such a request, that row is then the local
1005
+ * conflict (`settleEditing`). A text-only message cannot be saved empty (no request). Rejects when the adapter
1006
+ * lacks `editMessage`.
1007
+ */
1008
+ saveEdit = async (text) => {
1009
+ const client = this.client;
1010
+ if (typeof client.editMessage !== "function") {
1011
+ throw new TypeError("This ConvoKitUiClient adapter does not implement editMessage (0.8)");
1012
+ }
1013
+ const snapshot = this.state.editingMessage;
1014
+ if (!this.alive() || !snapshot || this.activeEdit !== void 0) return false;
1015
+ const trimmed = text.trim();
1016
+ const normalized = trimmed === "" ? null : trimmed;
1017
+ if (normalized === null && snapshot.media.length === 0) return false;
1018
+ const generation = this.generation;
1019
+ const id = snapshot.id;
1020
+ this.activeEdit = id;
1021
+ this.patch({ error: null });
1022
+ try {
1023
+ const message = await client.editMessage(id, { text: normalized, revision: snapshot.revision });
1024
+ if (!this.alive(generation)) return false;
1025
+ if (!this.validMessage(message) || message.id !== id || message.senderId !== this.user) {
1026
+ throw new Error("Edit response belongs to a different message or sender");
1027
+ }
1028
+ if (this.state.editingMessage?.id === id) this.patch({ editingMessage: null });
1029
+ this.applyRow(message);
1030
+ return true;
1031
+ } catch (cause) {
1032
+ if (!this.alive(generation)) return false;
1033
+ if (isRevisionConflict(cause)) await this.reloadConflict(id, cause, generation);
1034
+ else if (isMessageMissing(cause)) {
1035
+ this.removeMessage(id);
1036
+ this.patch({ error: cause });
1037
+ } else this.fail(cause, generation);
1038
+ return false;
1039
+ } finally {
1040
+ if (this.alive(generation)) {
1041
+ this.activeEdit = void 0;
1042
+ this.settleEditing(id);
1043
+ }
1044
+ }
1045
+ };
1046
+ /** Merge a complete REST row for a known id through the live-row guards: a tombstoned id is dropped, and an older
1047
+ * revision (or timestamp) never overwrites the newer row already recorded. Recorded as a non-insert change, so a
1048
+ * reconcile keeps it only while the row is still in the fetched range.
1049
+ */
1050
+ applyRow(message) {
1051
+ if (this.deleted.has(message.id)) return;
1052
+ this.record(message, this.changes.get(message.id)?.insert === true, ++this.revision, true);
1053
+ }
1054
+ /** The 409 path: one `getMessage` shows the conflicting content. Its row is merged through the guards and becomes
1055
+ * the new snapshot; a `MESSAGE_NOT_FOUND` answer removes the row and ends edit mode; another failure keeps the
1056
+ * snapshot. `error` carries the conflict (or the reload failure).
1057
+ */
1058
+ async reloadConflict(id, conflict, generation) {
1059
+ try {
1060
+ const current = await this.client.getMessage(id);
1061
+ if (!this.alive(generation)) return;
1062
+ if (!this.validMessage(current) || current.id !== id) throw new Error("Complete message response does not match the edited message");
1063
+ this.applyRow(current);
1064
+ const row = this.state.messages.find((message) => message.id === id);
1065
+ const editing = this.state.editingMessage?.id === id && row ? { editingMessage: row } : {};
1066
+ this.patch({ ...editing, error: conflict });
1067
+ } catch (cause) {
1068
+ if (!this.alive(generation)) return;
1069
+ if (isTargetMiss(cause)) this.removeMessage(id);
1070
+ this.patch({ error: cause });
1071
+ }
1072
+ }
1073
+ /** Delete one of the caller's own confirmed messages (0.8.0). The row stays until the server answers: on success,
1074
+ * or when the server no longer knows it (`MESSAGE_NOT_FOUND`), it is tombstoned and removed (late responses, row
1075
+ * images and hydrations for it are dropped, the acknowledgement target is re-resolved and edit mode on it ends)
1076
+ * and the call resolves true; any other failure keeps the row, reports through `error` and resolves false. Rejects
1077
+ * when the adapter lacks `deleteMessage`.
1078
+ */
1079
+ deleteMessage = async (messageId) => {
1080
+ const client = this.client;
1081
+ if (typeof client.deleteMessage !== "function") {
1082
+ throw new TypeError("This ConvoKitUiClient adapter does not implement deleteMessage (0.8)");
1083
+ }
1084
+ if (!this.alive() || !this.ownRow(messageId)) return false;
1085
+ const generation = this.generation;
1086
+ this.patch({ error: null });
1087
+ try {
1088
+ await client.deleteMessage(messageId);
1089
+ } catch (cause) {
1090
+ if (!this.alive(generation)) return false;
1091
+ if (!isMessageMissing(cause)) {
1092
+ this.fail(cause, generation);
1093
+ return false;
1094
+ }
1095
+ }
1096
+ if (!this.alive(generation)) return false;
1097
+ this.removeMessage(messageId);
1098
+ return true;
1099
+ };
868
1100
  readerIdsFor = (message) => readerIdsFor(message, this.state.readAtByUserId, this.state.readPositionByUserId);
869
1101
  };
870
1102
 
@@ -918,11 +1150,18 @@ function useConversation(options) {
918
1150
  hasLoaded: field("hasLoaded"),
919
1151
  error: field("error"),
920
1152
  currentUserId: field("currentUserId"),
1153
+ editingMessage: field("editingMessage"),
1154
+ canEditMessages: field("canEditMessages"),
1155
+ canDeleteMessages: field("canDeleteMessages"),
921
1156
  readerIdsFor: (message) => store.readerIdsFor(message),
922
1157
  loadInitial: () => store.loadInitial(),
923
1158
  refresh: () => store.refresh(),
924
1159
  loadOlderMessages: () => store.loadOlderMessages(),
925
1160
  sendMessage: (input) => store.sendMessage(input),
1161
+ startEditing: (messageId) => store.startEditing(messageId),
1162
+ cancelEditing: () => store.cancelEditing(),
1163
+ saveEdit: (text) => store.saveEdit(text),
1164
+ deleteMessage: (messageId) => store.deleteMessage(messageId),
926
1165
  markRead: () => store.markRead(),
927
1166
  updateTyping: (isTyping) => store.updateTyping(isTyping),
928
1167
  setVisible: (value) => {
@@ -934,6 +1173,7 @@ function useConversation(options) {
934
1173
  }
935
1174
 
936
1175
  // src/components/message-list.ts
1176
+ var import_sdk3 = require("@convokitapp/sdk");
937
1177
  var import_vue4 = require("@lucide/vue");
938
1178
  var import_vue5 = require("vue");
939
1179
  var appearanceProps = {
@@ -988,6 +1228,10 @@ var MessageListView = (0, import_vue5.defineComponent)({
988
1228
  isLoadingOlder: { type: Boolean, default: false },
989
1229
  error: { type: null, required: false },
990
1230
  onAttachmentClick: { type: Function, default: void 0 },
1231
+ onEditMessage: { type: Function, default: void 0 },
1232
+ onDeleteMessage: { type: Function, default: void 0 },
1233
+ canEditMessage: { type: Function, default: void 0 },
1234
+ confirmDelete: { type: Function, default: void 0 },
991
1235
  scrollElement: { type: Object, default: void 0 },
992
1236
  paginationThreshold: { type: Number, default: 240 },
993
1237
  reverse: { type: Boolean, default: true },
@@ -995,9 +1239,10 @@ var MessageListView = (0, import_vue5.defineComponent)({
995
1239
  formatTime: { type: Function, default: formatMessageTime },
996
1240
  imageLoading: { type: String, default: "lazy" }
997
1241
  },
998
- emits: ["load-older", "attachment-click"],
1242
+ emits: ["load-older", "attachment-click", "edit-message", "delete-message"],
999
1243
  setup(props, { attrs, emit, slots }) {
1000
1244
  const internalElement = (0, import_vue5.ref)(null);
1245
+ const confirming = (0, import_vue5.ref)(null);
1001
1246
  let requestInFlight = false;
1002
1247
  let lastRequestedLength = null;
1003
1248
  let previousMessageCount = 0;
@@ -1037,16 +1282,77 @@ var MessageListView = (0, import_vue5.defineComponent)({
1037
1282
  }
1038
1283
  }
1039
1284
  }, { flush: "post", immediate: true });
1285
+ const viewerRole = (0, import_vue5.computed)(() => props.conversation.membership?.role ?? props.conversation.participants.find((participant) => participant.appUserId === props.currentUserId || participant.id === props.currentUserId)?.role);
1286
+ const remove = async (message) => {
1287
+ if (props.confirmDelete && !await props.confirmDelete(message)) return false;
1288
+ return await props.onDeleteMessage?.(message) !== false;
1289
+ };
1040
1290
  const renderMessage = (message, index) => {
1041
1291
  const isCurrentUser = message.senderId === props.currentUserId;
1042
1292
  const sender = participants.value.get(message.senderId);
1043
1293
  const isPending = isConvoKitPendingMessage(message);
1044
1294
  const readerIds = isPending ? /* @__PURE__ */ new Set() : props.readersResolver ? props.readersResolver(message) : readerIdsFor(message, props.readAtByUserId, props.readPositionByUserId);
1045
- const slotProps = { message, chronologicalIndex: index, isCurrentUser, sender, readerIds };
1295
+ const isEdited = !isPending && (0, import_sdk3.isEditedMessage)(message);
1296
+ const eligible = !isPending && (props.canEditMessage ? props.canEditMessage(message) : isCurrentUser && viewerRole.value !== "READ");
1297
+ const canEdit = eligible && !!props.onEditMessage;
1298
+ const canDelete = eligible && !!props.onDeleteMessage;
1299
+ const edit = () => {
1300
+ props.onEditMessage?.(message);
1301
+ };
1302
+ const slotProps = {
1303
+ message,
1304
+ chronologicalIndex: index,
1305
+ isCurrentUser,
1306
+ sender,
1307
+ readerIds,
1308
+ isEdited,
1309
+ canEdit,
1310
+ canDelete,
1311
+ ...canEdit ? { edit } : {},
1312
+ ...canDelete ? { remove: () => remove(message) } : {}
1313
+ };
1046
1314
  const custom = slots.message?.(slotProps);
1047
1315
  if (custom) return (0, import_vue5.h)("div", { key: message.id, role: "listitem" }, custom);
1048
1316
  const currentAppearance = appearance();
1049
1317
  const messagePart = isCurrentUser ? "outgoingMessage" : "incomingMessage";
1318
+ const iconButton = (label, onClick, icon) => (0, import_vue5.h)("button", {
1319
+ type: "button",
1320
+ "aria-label": label,
1321
+ onClick,
1322
+ class: partClass("button", currentAppearance, "ckui-icon-button"),
1323
+ style: partStyle("button", currentAppearance)
1324
+ }, [icon]);
1325
+ const actions = canEdit || canDelete ? (0, import_vue5.h)("div", { class: "ckui-message-actions" }, [
1326
+ ...canEdit ? [iconButton("Edit message", edit, (0, import_vue5.h)(import_vue4.Pencil, { size: 16, "aria-hidden": "true" }))] : [],
1327
+ ...canDelete ? [iconButton("Delete message", () => {
1328
+ if (props.confirmDelete) void remove(message);
1329
+ else confirming.value = message.id;
1330
+ }, (0, import_vue5.h)(import_vue4.Trash2, { size: 16, "aria-hidden": "true" }))] : []
1331
+ ]) : null;
1332
+ const confirm = canDelete && confirming.value === message.id ? (0, import_vue5.h)("div", {
1333
+ class: "ckui-message-confirm",
1334
+ role: "group",
1335
+ "aria-label": "Delete this message?"
1336
+ }, [
1337
+ (0, import_vue5.h)("span", "Delete this message?"),
1338
+ (0, import_vue5.h)("button", {
1339
+ type: "button",
1340
+ class: "ckui-link-button",
1341
+ "aria-label": "Confirm delete",
1342
+ onClick: () => {
1343
+ confirming.value = null;
1344
+ void props.onDeleteMessage?.(message);
1345
+ }
1346
+ }, "Delete"),
1347
+ (0, import_vue5.h)("button", {
1348
+ type: "button",
1349
+ class: "ckui-link-button",
1350
+ "aria-label": "Cancel delete",
1351
+ onClick: () => {
1352
+ confirming.value = null;
1353
+ }
1354
+ }, "Cancel")
1355
+ ]) : null;
1050
1356
  const mediaNodes = message.media.map((media, mediaIndex) => {
1051
1357
  const open = props.onAttachmentClick ? () => {
1052
1358
  props.onAttachmentClick?.(media, message);
@@ -1070,15 +1376,19 @@ var MessageListView = (0, import_vue5.defineComponent)({
1070
1376
  style: [props.styles?.message, props.styles?.[messagePart]],
1071
1377
  "data-message-id": message.id
1072
1378
  }, [
1379
+ // Spread, not null: an absent action/label/prompt must not leave a comment node (0.7 markup stays byte-identical).
1380
+ ...actions ? [actions] : [],
1073
1381
  (0, import_vue5.h)("div", { class: "ckui-message-bubble" }, [
1074
1382
  !isCurrentUser ? (0, import_vue5.h)("strong", { class: "ckui-message-sender" }, sender?.name || message.senderId) : null,
1075
1383
  message.text ? (0, import_vue5.h)("div", { class: "ckui-message-text" }, message.text) : null,
1076
1384
  ...mediaNodes,
1077
1385
  (0, import_vue5.h)("span", { class: "ckui-message-time" }, [
1078
1386
  isPending ? "Sending\u2026" : props.formatTime(message.createdAt),
1387
+ ...isEdited ? [(0, import_vue5.h)("span", { class: "ckui-message-edited", "aria-label": "Edited" }, "Edited")] : [],
1079
1388
  isCurrentUser && !isPending ? readerIds.size > 0 ? (0, import_vue5.h)(import_vue4.CheckCheck, { size: 14, "aria-label": "Read" }) : (0, import_vue5.h)(import_vue4.Check, { size: 14, "aria-label": "Sent" }) : null
1080
1389
  ])
1081
1390
  ]),
1391
+ ...confirm ? [confirm] : [],
1082
1392
  isCurrentUser && !isPending ? slots["read-receipt"]?.(receiptSlotProps) ?? (0, import_vue5.h)("div", {
1083
1393
  class: partClass("receipt", currentAppearance, "ckui-read-receipt"),
1084
1394
  style: partStyle("receipt", currentAppearance)
@@ -1167,6 +1477,13 @@ var viewProps = {
1167
1477
  onTypingChange: { type: Function, default: void 0 },
1168
1478
  onAddAttachment: { type: Function, default: void 0 },
1169
1479
  onAttachmentClick: { type: Function, default: void 0 },
1480
+ editingMessage: { type: Object, default: null },
1481
+ onEditMessage: { type: Function, default: void 0 },
1482
+ onSaveEdit: { type: Function, default: void 0 },
1483
+ onCancelEdit: { type: Function, default: void 0 },
1484
+ onDeleteMessage: { type: Function, default: void 0 },
1485
+ canEditMessage: { type: Function, default: void 0 },
1486
+ confirmDelete: { type: Function, default: void 0 },
1170
1487
  isInitialLoading: { type: Boolean, default: false },
1171
1488
  isLoadingOlder: { type: Boolean, default: false },
1172
1489
  isSending: { type: Boolean, default: false },
@@ -1186,6 +1503,9 @@ var viewProps = {
1186
1503
  defaultDraft: { type: String, default: "" },
1187
1504
  onDraftChange: { type: Function, default: void 0 }
1188
1505
  };
1506
+ function editingSummary(message) {
1507
+ return message.text?.trim() || (message.media.length === 1 ? "1 attachment" : `${message.media.length} attachments`);
1508
+ }
1189
1509
  function typingLabel(userIds, displayNameForUser) {
1190
1510
  const names = [...userIds].map(displayNameForUser);
1191
1511
  if (names.length === 0) return "";
@@ -1205,7 +1525,11 @@ var ConversationView = (0, import_vue7.defineComponent)({
1205
1525
  "load-older",
1206
1526
  "add-attachment",
1207
1527
  "attachment-click",
1208
- "update:modelValue"
1528
+ "update:modelValue",
1529
+ "edit-message",
1530
+ "save-edit",
1531
+ "cancel-edit",
1532
+ "delete-message"
1209
1533
  ],
1210
1534
  setup(props, { attrs, emit, slots }) {
1211
1535
  const internalDraft = (0, import_vue7.ref)(props.defaultDraft);
@@ -1218,15 +1542,57 @@ var ConversationView = (0, import_vue7.defineComponent)({
1218
1542
  });
1219
1543
  const draft = () => props.modelValue ?? internalDraft.value;
1220
1544
  let latestDraft = draft();
1221
- const setDraft = (value) => {
1545
+ let stash;
1546
+ let saving = false;
1547
+ const setDraft = (value, typing = true) => {
1222
1548
  latestDraft = value;
1223
1549
  if (props.modelValue === void 0) internalDraft.value = value;
1224
1550
  props.onDraftChange?.(value);
1225
1551
  emit("update:modelValue", value);
1226
- const isTyping = value.trim().length > 0;
1227
- void props.onTypingChange?.(isTyping);
1552
+ if (typing) void props.onTypingChange?.(value.trim().length > 0);
1553
+ };
1554
+ const enterEdit = (message) => {
1555
+ if (stash === void 0) stash = draft();
1556
+ setDraft(message.text ?? "", false);
1557
+ };
1558
+ const leaveEdit = (message, restore) => {
1559
+ if (stash === void 0) return;
1560
+ const saved = stash;
1561
+ stash = void 0;
1562
+ const current = draft();
1563
+ if (restore === "always" || current.trim() === "" || current === (message.text ?? "")) setDraft(saved);
1228
1564
  };
1565
+ (0, import_vue7.watch)(() => props.editingMessage, (next, previous) => {
1566
+ if (next && (!previous || previous.id !== next.id)) enterEdit(next);
1567
+ else if (!next && previous && !saving) leaveEdit(previous, "unchanged");
1568
+ }, { immediate: true });
1569
+ const cancelEdit = () => {
1570
+ const editing = props.editingMessage;
1571
+ if (!editing) return;
1572
+ leaveEdit(editing, "always");
1573
+ props.onCancelEdit?.();
1574
+ };
1575
+ const canSave = (editing) => draft().trim().length > 0 || editing.media.length > 0;
1229
1576
  const submit = async () => {
1577
+ const editing = props.editingMessage;
1578
+ if (editing) {
1579
+ const text2 = draft().trim();
1580
+ if (!canSave(editing) || props.isSending || submitting.value) return;
1581
+ submitting.value = true;
1582
+ saving = true;
1583
+ try {
1584
+ const saved = await props.onSaveEdit?.(editing, text2);
1585
+ if (saved === false) {
1586
+ if (props.editingMessage === null) leaveEdit(editing, "unchanged");
1587
+ return;
1588
+ }
1589
+ leaveEdit(editing, "always");
1590
+ } finally {
1591
+ saving = false;
1592
+ submitting.value = false;
1593
+ }
1594
+ return;
1595
+ }
1230
1596
  const originalDraft = draft();
1231
1597
  const text = originalDraft.trim();
1232
1598
  if (!text || props.isSending || submitting.value) return;
@@ -1295,6 +1661,7 @@ var ConversationView = (0, import_vue7.defineComponent)({
1295
1661
  }, typingLabel(props.typingUserIds, nameForUser));
1296
1662
  };
1297
1663
  const renderComposer = () => {
1664
+ const editing = props.editingMessage;
1298
1665
  const slotProps = {
1299
1666
  value: draft(),
1300
1667
  setValue: setDraft,
@@ -1302,16 +1669,27 @@ var ConversationView = (0, import_vue7.defineComponent)({
1302
1669
  send: () => {
1303
1670
  void submit();
1304
1671
  },
1305
- ...props.onAddAttachment ? { addAttachment } : {}
1672
+ ...props.onAddAttachment ? { addAttachment } : {},
1673
+ ...editing ? { editing, cancelEdit } : {}
1306
1674
  };
1675
+ const busy = props.isSending || submitting.value;
1307
1676
  return slots.composer?.(slotProps) ?? (0, import_vue7.h)("form", {
1308
- class: partClass("composer", appearance(), "ckui-composer"),
1677
+ class: cx(partClass("composer", appearance(), "ckui-composer"), editing && !props.unstyled && "ckui-composer--editing"),
1309
1678
  style: partStyle("composer", appearance()),
1310
1679
  onSubmit: (event) => {
1311
1680
  event.preventDefault();
1312
1681
  void submit();
1313
1682
  }
1314
1683
  }, [
1684
+ // Spread, not null: outside edit mode the composer markup stays byte-identical to 0.7.
1685
+ ...editing ? [(0, import_vue7.h)("div", { class: "ckui-composer__editing", role: "status" }, [
1686
+ (0, import_vue7.h)(import_vue6.Pencil, { size: 14, "aria-hidden": "true" }),
1687
+ (0, import_vue7.h)("span", { class: "ckui-composer__editing-body" }, [
1688
+ (0, import_vue7.h)("strong", "Editing message"),
1689
+ (0, import_vue7.h)("span", editingSummary(editing))
1690
+ ]),
1691
+ (0, import_vue7.h)("button", { type: "button", class: "ckui-link-button", "aria-label": "Cancel editing", onClick: cancelEdit }, "Cancel")
1692
+ ])] : [],
1315
1693
  props.onAddAttachment ? (0, import_vue7.h)("button", {
1316
1694
  type: "button",
1317
1695
  "aria-label": "Add attachment",
@@ -1340,15 +1718,19 @@ var ConversationView = (0, import_vue7.defineComponent)({
1340
1718
  event.preventDefault();
1341
1719
  void submit();
1342
1720
  }
1721
+ if (event.key === "Escape" && props.editingMessage) {
1722
+ event.preventDefault();
1723
+ cancelEdit();
1724
+ }
1343
1725
  }
1344
1726
  }),
1345
1727
  (0, import_vue7.h)("button", {
1346
1728
  type: "submit",
1347
- "aria-label": "Send message",
1348
- disabled: !draft().trim() || props.isSending || submitting.value,
1729
+ "aria-label": editing ? "Save message" : "Send message",
1730
+ disabled: (editing ? !canSave(editing) : !draft().trim()) || busy,
1349
1731
  class: partClass("button", appearance(), "ckui-send-button"),
1350
1732
  style: partStyle("button", appearance())
1351
- }, [props.isSending || submitting.value ? (0, import_vue7.h)(import_vue6.LoaderCircle, { class: "ckui-spin", size: 18, "aria-hidden": "true" }) : (0, import_vue7.h)(import_vue6.Send, { size: 18, "aria-hidden": "true" })])
1733
+ }, [busy ? (0, import_vue7.h)(import_vue6.LoaderCircle, { class: "ckui-spin", size: 18, "aria-hidden": "true" }) : (0, import_vue7.h)(editing ? import_vue6.Check : import_vue6.Send, { size: 18, "aria-hidden": "true" })])
1352
1734
  ]);
1353
1735
  };
1354
1736
  return () => {
@@ -1397,6 +1779,12 @@ var ConversationView = (0, import_vue7.defineComponent)({
1397
1779
  ...props.onAttachmentClick ? { onAttachmentClick: (media, message) => {
1398
1780
  props.onAttachmentClick?.(media, message);
1399
1781
  } } : {},
1782
+ ...props.onEditMessage ? { onEditMessage: (message) => {
1783
+ props.onEditMessage?.(message);
1784
+ } } : {},
1785
+ ...props.onDeleteMessage ? { onDeleteMessage: (message) => props.onDeleteMessage?.(message) } : {},
1786
+ ...props.canEditMessage ? { canEditMessage: props.canEditMessage } : {},
1787
+ ...props.confirmDelete ? { confirmDelete: props.confirmDelete } : {},
1400
1788
  reverse: props.reverseMessages,
1401
1789
  stickToBottom: props.stickToBottom,
1402
1790
  paginationThreshold: props.paginationThreshold,
@@ -1427,6 +1815,11 @@ var Conversation = (0, import_vue7.defineComponent)({
1427
1815
  messages: { type: Array, default: () => [] },
1428
1816
  currentUserId: { type: String, default: "" },
1429
1817
  onSendMessage: { type: Function, default: void 0 },
1818
+ editingMessage: { type: Object, default: void 0 },
1819
+ onEditMessage: { type: Function, default: void 0 },
1820
+ onSaveEdit: { type: Function, default: void 0 },
1821
+ onCancelEdit: { type: Function, default: void 0 },
1822
+ onDeleteMessage: { type: Function, default: void 0 },
1430
1823
  client: { type: Object, required: true },
1431
1824
  conversationId: { type: String, required: true },
1432
1825
  messagePageSize: { type: Number, default: 30 },
@@ -1436,7 +1829,21 @@ var Conversation = (0, import_vue7.defineComponent)({
1436
1829
  autoLoad: { type: Boolean, default: true },
1437
1830
  onControllerChange: { type: Function, default: void 0 }
1438
1831
  },
1439
- emits: ["controller-change", "send-message", "typing-change", "back", "refresh", "load-older", "add-attachment", "attachment-click", "update:modelValue"],
1832
+ emits: [
1833
+ "controller-change",
1834
+ "send-message",
1835
+ "typing-change",
1836
+ "back",
1837
+ "refresh",
1838
+ "load-older",
1839
+ "add-attachment",
1840
+ "attachment-click",
1841
+ "update:modelValue",
1842
+ "edit-message",
1843
+ "save-edit",
1844
+ "cancel-edit",
1845
+ "delete-message"
1846
+ ],
1440
1847
  setup(props, { attrs, emit, expose, slots }) {
1441
1848
  const controller = useConversation({
1442
1849
  client: () => props.client,
@@ -1500,6 +1907,11 @@ var Conversation = (0, import_vue7.defineComponent)({
1500
1907
  isSending: _isSending,
1501
1908
  hasOlderMessages: _hasOlderMessages,
1502
1909
  error: _error,
1910
+ editingMessage: _editingMessage,
1911
+ onEditMessage: _onEditMessage,
1912
+ onSaveEdit: _onSaveEdit,
1913
+ onCancelEdit: _onCancelEdit,
1914
+ onDeleteMessage: _onDeleteMessage,
1503
1915
  ...forwarded
1504
1916
  } = props;
1505
1917
  return (0, import_vue7.h)(ConversationView, {
@@ -1523,6 +1935,28 @@ var Conversation = (0, import_vue7.defineComponent)({
1523
1935
  isSending: controller.isSending.value,
1524
1936
  hasOlderMessages: controller.hasOlderMessages.value,
1525
1937
  ...controller.error.value == null ? {} : { error: controller.error.value },
1938
+ // Edit mode is the store's; the actions render only while the adapter supports them (0.8.0).
1939
+ editingMessage: controller.editingMessage.value,
1940
+ ...controller.canEditMessages.value ? {
1941
+ onEditMessage: (message) => {
1942
+ emit("edit-message", message);
1943
+ controller.startEditing(message.id);
1944
+ },
1945
+ onSaveEdit: async (message, text) => {
1946
+ emit("save-edit", message, text);
1947
+ return controller.saveEdit(text);
1948
+ },
1949
+ onCancelEdit: () => {
1950
+ emit("cancel-edit");
1951
+ controller.cancelEditing();
1952
+ }
1953
+ } : {},
1954
+ ...controller.canDeleteMessages.value ? {
1955
+ onDeleteMessage: async (message) => {
1956
+ emit("delete-message", message);
1957
+ return controller.deleteMessage(message.id);
1958
+ }
1959
+ } : {},
1526
1960
  "onUpdate:modelValue": (value) => emit("update:modelValue", value),
1527
1961
  ...props.onBack ? { onBack: () => {
1528
1962
  props.onBack?.();
@@ -1936,6 +2370,66 @@ var ConversationListStore = class {
1936
2370
  else if (!this.state.conversations.length && this.state.hasMore) await this.loadMore();
1937
2371
  };
1938
2372
  setQuery = (query) => this.setFilter({ ...this.state.filter, query });
2373
+ /** Mark a room unread for the viewer only; the row's summary takes the response (D10). Rejects when the adapter
2374
+ * lacks `markConversationUnread` or the store is not active; a request failure is reported through `error`
2375
+ * without evicting rows and rejects.
2376
+ */
2377
+ markUnread = async (conversationId) => {
2378
+ const client = this.options.client;
2379
+ if (typeof client.markConversationUnread !== "function") {
2380
+ throw new TypeError("markUnread requires a ConvoKitUiClient adapter with markConversationUnread (core SDK 0.7)");
2381
+ }
2382
+ this.assertActive();
2383
+ this.applyPrivateState(conversationId, await this.mutate(client.markConversationUnread(conversationId)));
2384
+ };
2385
+ /** Remove the viewer's marker (conditionally on `options.ifVersion`); resolves to the response's `cleared` ("this
2386
+ * request removed the marker", not "the room is read") and patches the summary on true and false alike (D10).
2387
+ * Rejects when the adapter lacks `clearConversationUnread` or the store is not active; failures are reported like
2388
+ * `markUnread`.
2389
+ */
2390
+ clearUnread = async (conversationId, options) => {
2391
+ const client = this.options.client;
2392
+ if (typeof client.clearConversationUnread !== "function") {
2393
+ throw new TypeError("clearUnread requires a ConvoKitUiClient adapter with clearConversationUnread (core SDK 0.7)");
2394
+ }
2395
+ this.assertActive();
2396
+ const result = await this.mutate(client.clearConversationUnread(conversationId, options));
2397
+ this.applyPrivateState(conversationId, result);
2398
+ return result.cleared;
2399
+ };
2400
+ /** A disposed or session-evicted store never sends a private-state mutation: on a shared client it could go out
2401
+ * under a replacement login. Rejected without touching `error` (there is no live snapshot to report into).
2402
+ */
2403
+ assertActive() {
2404
+ if (!this.alive()) throw new Error("ConversationListStore is not active");
2405
+ }
2406
+ async mutate(request) {
2407
+ try {
2408
+ return await request;
2409
+ } catch (cause) {
2410
+ if (this.alive()) this.patch({ error: cause });
2411
+ throw cause;
2412
+ }
2413
+ }
2414
+ /** Apply a mark/clear response to the row's CURRENT summary (a refresh may have swapped it) as one unit, only while
2415
+ * the store is alive and the response is not older than the stored version: a delayed response never resurrects a
2416
+ * marker a newer action removed (equal versions are an idempotent no-op). `isUnread` is recomputed from the stored
2417
+ * counts and the response marker. Other devices learn of the change through `inbox_activity`.
2418
+ */
2419
+ applyPrivateState(conversationId, state) {
2420
+ if (!this.alive()) return;
2421
+ const current = this.entries.find((entry) => entry.conversation.id === conversationId);
2422
+ if (!current || state.privateStateVersion < current.privateStateVersion) return;
2423
+ const { unreadMarkedAt, privateStateVersion } = state;
2424
+ const patched = {
2425
+ ...current,
2426
+ unreadMarkedAt,
2427
+ privateStateVersion,
2428
+ isUnread: current.unreadCount > 0 || current.unreadCountCapped || unreadMarkedAt !== null
2429
+ };
2430
+ this.entries = this.entries.map((entry) => entry === current ? patched : entry);
2431
+ this.patch({ summaries: new Map(this.entries.map((entry) => [entry.conversation.id, summaryOf(entry)])) });
2432
+ }
1939
2433
  };
1940
2434
 
1941
2435
  // src/composables/use-conversation-list.ts
@@ -1983,6 +2477,8 @@ function useConversationList(options) {
1983
2477
  loadMore: () => store.loadMore(),
1984
2478
  setFilter: (filter) => store.setFilter(filter),
1985
2479
  setQuery: (query) => store.setQuery(query),
2480
+ markUnread: (conversationId) => store.markUnread(conversationId),
2481
+ clearUnread: (conversationId, options2) => store.clearUnread(conversationId, options2),
1986
2482
  dispose
1987
2483
  };
1988
2484
  }
@@ -2059,13 +2555,16 @@ var ConversationListView = (0, import_vue10.defineComponent)({
2059
2555
  return void 0;
2060
2556
  };
2061
2557
  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))]);
2558
+ if (summary.unreadCount > 0 || summary.unreadCountCapped) {
2559
+ const capped = summary.unreadCountCapped || summary.unreadCount > 99;
2560
+ return [(0, import_vue10.h)("span", {
2561
+ class: "ckui-unread-badge",
2562
+ role: "img",
2563
+ "aria-label": `${summary.unreadCountCapped ? "99+" : summary.unreadCount} unread`
2564
+ }, [(0, import_vue10.h)("span", { "aria-hidden": "true" }, capped ? "99+" : String(summary.unreadCount))])];
2565
+ }
2566
+ if (summary.isUnread) return [(0, import_vue10.h)("span", { class: "ckui-unread-badge ckui-unread-badge--dot", role: "img", "aria-label": "Unread" })];
2567
+ return [];
2069
2568
  };
2070
2569
  const renderContent = () => {
2071
2570
  const currentAppearance = appearance();
@@ -2108,7 +2607,7 @@ var ConversationListView = (0, import_vue10.defineComponent)({
2108
2607
  ...props.currentUserId === void 0 ? {} : { currentUserId: props.currentUserId }
2109
2608
  };
2110
2609
  const preview = inboxPreview(conversation, summary, props.currentUserId);
2111
- const unread = summary !== void 0 && (summary.unreadCount > 0 || summary.unreadCountCapped);
2610
+ const unread = summary !== void 0 && (summary.isUnread || summary.unreadCount > 0 || summary.unreadCountCapped);
2112
2611
  const item = slots["conversation-item"]?.(slotProps) ?? (0, import_vue10.h)("button", {
2113
2612
  type: "button",
2114
2613
  "data-selected": selected || void 0,
@@ -2132,7 +2631,7 @@ var ConversationListView = (0, import_vue10.defineComponent)({
2132
2631
  // summary must keep 0.5's exact markup.
2133
2632
  ...summary ? [(0, import_vue10.h)("span", { class: "ckui-conversation-item__meta" }, [
2134
2633
  (0, import_vue10.h)("time", { class: "ckui-conversation-item__time", datetime: summary.activityAt.toISOString() }, formatMessageTime(summary.activityAt)),
2135
- unreadBadge(summary)
2634
+ ...unreadBadge(summary)
2136
2635
  ])] : [],
2137
2636
  (0, import_vue10.h)(import_vue9.ChevronRight, { size: 18, "aria-hidden": "true" })
2138
2637
  ]);