@convokitapp/vue-ui 0.7.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,6 +72,8 @@ 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),
76
78
  markConversationUnread: (conversationId) => client.markConversationUnread(conversationId),
77
79
  clearConversationUnread: (conversationId, options) => client.clearConversationUnread(conversationId, options),
@@ -246,9 +248,31 @@ function version(message) {
246
248
  function hasContent(message) {
247
249
  return !!message.text?.trim() || message.media.length > 0;
248
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
+ }
249
260
  function newest(current, incoming, incomingComplete = true) {
261
+ const byRevision = revisionOrder(current, incoming);
262
+ if (byRevision !== void 0) return byRevision > 0 ? current : incoming;
250
263
  return version(current) > version(incoming) || !incomingComplete && version(current) === version(incoming) ? current : incoming;
251
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
+ }
252
276
  var compare = compareMessageOrder;
253
277
  function positionCursor(position) {
254
278
  return { createdAt: position.createdAt, id: position.messageId };
@@ -268,7 +292,7 @@ function isTargetMiss(cause) {
268
292
  const { code, status } = cause;
269
293
  return code === "MESSAGE_NOT_FOUND" || code === void 0 && status === 404;
270
294
  }
271
- function blank(currentUserId = "") {
295
+ function blank(currentUserId = "", support = { edit: false, delete: false }) {
272
296
  return {
273
297
  conversation: null,
274
298
  messages: [],
@@ -282,7 +306,10 @@ function blank(currentUserId = "") {
282
306
  hasOlderMessages: true,
283
307
  hasLoaded: false,
284
308
  error: null,
285
- currentUserId
309
+ currentUserId,
310
+ editingMessage: null,
311
+ canEditMessages: support.edit,
312
+ canDeleteMessages: support.delete
286
313
  };
287
314
  }
288
315
  var ConversationStore = class {
@@ -301,7 +328,8 @@ var ConversationStore = class {
301
328
  }
302
329
  this.owner = this.client.sessionIdentity;
303
330
  this.user = this.owner ? this.client.currentUserId : "";
304
- 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);
305
333
  }
306
334
  options;
307
335
  client;
@@ -328,6 +356,13 @@ var ConversationStore = class {
328
356
  visible = true;
329
357
  sendRevision;
330
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;
331
366
  refreshQueued = false;
332
367
  typingTimers = /* @__PURE__ */ new Map();
333
368
  ownTypingTimer;
@@ -346,9 +381,33 @@ var ConversationStore = class {
346
381
  for (const message of patch.messages) this.confirmSend(message);
347
382
  if (this.activeSend.confirmed) patch.messages = patch.messages.filter((message) => message.id !== this.activeSend.pending.id);
348
383
  }
384
+ if (patch.messages) patch = this.trackEditing(patch);
349
385
  this.state = { ...this.state, ...patch };
350
386
  for (const listener of this.listeners) listener();
351
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
+ }
352
411
  alive(generation = this.generation) {
353
412
  return !this.disposed && generation === this.generation && this.owner !== null && this.client.sessionIdentity === this.owner;
354
413
  }
@@ -392,6 +451,7 @@ var ConversationStore = class {
392
451
  this.captured = capture();
393
452
  this.sendRevision = void 0;
394
453
  this.activeSend = void 0;
454
+ this.activeEdit = void 0;
395
455
  this.refreshQueued = false;
396
456
  }
397
457
  dispose = () => {
@@ -400,14 +460,14 @@ var ConversationStore = class {
400
460
  }
401
461
  this.disposed = true;
402
462
  this.clear();
403
- this.patch(blank());
463
+ this.patch(blank("", this.support));
404
464
  };
405
465
  fail(cause, generation, history = false) {
406
466
  if (!this.alive(generation)) return;
407
467
  const status = typeof cause === "object" && cause !== null && "status" in cause ? cause.status : void 0;
408
468
  if (history && (status === 401 || status === 403 || status === 404)) {
409
469
  this.clear();
410
- 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 });
411
471
  } else this.patch({ error: cause });
412
472
  }
413
473
  attach(generation, data = true) {
@@ -479,7 +539,7 @@ var ConversationStore = class {
479
539
  if (!this.alive(generation) || type !== "insert" && type !== "update" || !this.validMessage(message) || this.deleted.has(message.id)) return;
480
540
  const existing = this.state.messages.find((item) => item.id === message.id);
481
541
  const known = existing ?? this.changes.get(message.id)?.message;
482
- if (known && version(message) < version(known)) return;
542
+ if (known && older(message, known)) return;
483
543
  const insert = type === "insert" || this.changes.get(message.id)?.insert === true;
484
544
  if (!existing && !insert && type === "update" && !this.state.isInitialLoading && !this.state.isLoadingOlder && !this.state.isReconciling) return;
485
545
  const revision = ++this.revision;
@@ -492,7 +552,7 @@ var ConversationStore = class {
492
552
  }
493
553
  record(message, insert, revision, complete) {
494
554
  const existing = this.state.messages.find((item) => item.id === message.id);
495
- if (existing && version(existing) > version(message)) return;
555
+ if (existing && older(message, existing)) return;
496
556
  if (this.confirmSend(message) && !complete && !message.media.length) {
497
557
  message = { ...message, media: this.activeSend.pending.media };
498
558
  this.confirmSend(message);
@@ -543,7 +603,7 @@ var ConversationStore = class {
543
603
  if (!this.currentHydration(job)) return;
544
604
  const full = await this.client.getMessage(id);
545
605
  if (!this.currentHydration(job)) return;
546
- 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)) {
547
607
  throw new Error("Complete message response does not match the observed resource/revision");
548
608
  }
549
609
  this.record(full, job.insert, job.revision, true);
@@ -623,7 +683,7 @@ var ConversationStore = class {
623
683
  if (!this.alive()) return;
624
684
  this.clear();
625
685
  const generation = this.generation;
626
- this.patch({ ...blank(this.user), isInitialLoading: true });
686
+ this.patch({ ...blank(this.user, this.support), isInitialLoading: true });
627
687
  const revision = this.revision;
628
688
  try {
629
689
  this.attach(generation);
@@ -866,7 +926,8 @@ var ConversationStore = class {
866
926
  text: normalized || null,
867
927
  media: media ?? [],
868
928
  createdAt: /* @__PURE__ */ new Date(),
869
- updatedAt: null
929
+ updatedAt: null,
930
+ revision: 0
870
931
  };
871
932
  const send = { pending };
872
933
  this.activeSend = send;
@@ -910,6 +971,132 @@ var ConversationStore = class {
910
971
  }
911
972
  }
912
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
+ };
913
1100
  readerIdsFor = (message) => readerIdsFor(message, this.state.readAtByUserId, this.state.readPositionByUserId);
914
1101
  };
915
1102
 
@@ -963,11 +1150,18 @@ function useConversation(options) {
963
1150
  hasLoaded: field("hasLoaded"),
964
1151
  error: field("error"),
965
1152
  currentUserId: field("currentUserId"),
1153
+ editingMessage: field("editingMessage"),
1154
+ canEditMessages: field("canEditMessages"),
1155
+ canDeleteMessages: field("canDeleteMessages"),
966
1156
  readerIdsFor: (message) => store.readerIdsFor(message),
967
1157
  loadInitial: () => store.loadInitial(),
968
1158
  refresh: () => store.refresh(),
969
1159
  loadOlderMessages: () => store.loadOlderMessages(),
970
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),
971
1165
  markRead: () => store.markRead(),
972
1166
  updateTyping: (isTyping) => store.updateTyping(isTyping),
973
1167
  setVisible: (value) => {
@@ -979,6 +1173,7 @@ function useConversation(options) {
979
1173
  }
980
1174
 
981
1175
  // src/components/message-list.ts
1176
+ var import_sdk3 = require("@convokitapp/sdk");
982
1177
  var import_vue4 = require("@lucide/vue");
983
1178
  var import_vue5 = require("vue");
984
1179
  var appearanceProps = {
@@ -1033,6 +1228,10 @@ var MessageListView = (0, import_vue5.defineComponent)({
1033
1228
  isLoadingOlder: { type: Boolean, default: false },
1034
1229
  error: { type: null, required: false },
1035
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 },
1036
1235
  scrollElement: { type: Object, default: void 0 },
1037
1236
  paginationThreshold: { type: Number, default: 240 },
1038
1237
  reverse: { type: Boolean, default: true },
@@ -1040,9 +1239,10 @@ var MessageListView = (0, import_vue5.defineComponent)({
1040
1239
  formatTime: { type: Function, default: formatMessageTime },
1041
1240
  imageLoading: { type: String, default: "lazy" }
1042
1241
  },
1043
- emits: ["load-older", "attachment-click"],
1242
+ emits: ["load-older", "attachment-click", "edit-message", "delete-message"],
1044
1243
  setup(props, { attrs, emit, slots }) {
1045
1244
  const internalElement = (0, import_vue5.ref)(null);
1245
+ const confirming = (0, import_vue5.ref)(null);
1046
1246
  let requestInFlight = false;
1047
1247
  let lastRequestedLength = null;
1048
1248
  let previousMessageCount = 0;
@@ -1082,16 +1282,77 @@ var MessageListView = (0, import_vue5.defineComponent)({
1082
1282
  }
1083
1283
  }
1084
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
+ };
1085
1290
  const renderMessage = (message, index) => {
1086
1291
  const isCurrentUser = message.senderId === props.currentUserId;
1087
1292
  const sender = participants.value.get(message.senderId);
1088
1293
  const isPending = isConvoKitPendingMessage(message);
1089
1294
  const readerIds = isPending ? /* @__PURE__ */ new Set() : props.readersResolver ? props.readersResolver(message) : readerIdsFor(message, props.readAtByUserId, props.readPositionByUserId);
1090
- 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
+ };
1091
1314
  const custom = slots.message?.(slotProps);
1092
1315
  if (custom) return (0, import_vue5.h)("div", { key: message.id, role: "listitem" }, custom);
1093
1316
  const currentAppearance = appearance();
1094
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;
1095
1356
  const mediaNodes = message.media.map((media, mediaIndex) => {
1096
1357
  const open = props.onAttachmentClick ? () => {
1097
1358
  props.onAttachmentClick?.(media, message);
@@ -1115,15 +1376,19 @@ var MessageListView = (0, import_vue5.defineComponent)({
1115
1376
  style: [props.styles?.message, props.styles?.[messagePart]],
1116
1377
  "data-message-id": message.id
1117
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] : [],
1118
1381
  (0, import_vue5.h)("div", { class: "ckui-message-bubble" }, [
1119
1382
  !isCurrentUser ? (0, import_vue5.h)("strong", { class: "ckui-message-sender" }, sender?.name || message.senderId) : null,
1120
1383
  message.text ? (0, import_vue5.h)("div", { class: "ckui-message-text" }, message.text) : null,
1121
1384
  ...mediaNodes,
1122
1385
  (0, import_vue5.h)("span", { class: "ckui-message-time" }, [
1123
1386
  isPending ? "Sending\u2026" : props.formatTime(message.createdAt),
1387
+ ...isEdited ? [(0, import_vue5.h)("span", { class: "ckui-message-edited", "aria-label": "Edited" }, "Edited")] : [],
1124
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
1125
1389
  ])
1126
1390
  ]),
1391
+ ...confirm ? [confirm] : [],
1127
1392
  isCurrentUser && !isPending ? slots["read-receipt"]?.(receiptSlotProps) ?? (0, import_vue5.h)("div", {
1128
1393
  class: partClass("receipt", currentAppearance, "ckui-read-receipt"),
1129
1394
  style: partStyle("receipt", currentAppearance)
@@ -1212,6 +1477,13 @@ var viewProps = {
1212
1477
  onTypingChange: { type: Function, default: void 0 },
1213
1478
  onAddAttachment: { type: Function, default: void 0 },
1214
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 },
1215
1487
  isInitialLoading: { type: Boolean, default: false },
1216
1488
  isLoadingOlder: { type: Boolean, default: false },
1217
1489
  isSending: { type: Boolean, default: false },
@@ -1231,6 +1503,9 @@ var viewProps = {
1231
1503
  defaultDraft: { type: String, default: "" },
1232
1504
  onDraftChange: { type: Function, default: void 0 }
1233
1505
  };
1506
+ function editingSummary(message) {
1507
+ return message.text?.trim() || (message.media.length === 1 ? "1 attachment" : `${message.media.length} attachments`);
1508
+ }
1234
1509
  function typingLabel(userIds, displayNameForUser) {
1235
1510
  const names = [...userIds].map(displayNameForUser);
1236
1511
  if (names.length === 0) return "";
@@ -1250,7 +1525,11 @@ var ConversationView = (0, import_vue7.defineComponent)({
1250
1525
  "load-older",
1251
1526
  "add-attachment",
1252
1527
  "attachment-click",
1253
- "update:modelValue"
1528
+ "update:modelValue",
1529
+ "edit-message",
1530
+ "save-edit",
1531
+ "cancel-edit",
1532
+ "delete-message"
1254
1533
  ],
1255
1534
  setup(props, { attrs, emit, slots }) {
1256
1535
  const internalDraft = (0, import_vue7.ref)(props.defaultDraft);
@@ -1263,15 +1542,57 @@ var ConversationView = (0, import_vue7.defineComponent)({
1263
1542
  });
1264
1543
  const draft = () => props.modelValue ?? internalDraft.value;
1265
1544
  let latestDraft = draft();
1266
- const setDraft = (value) => {
1545
+ let stash;
1546
+ let saving = false;
1547
+ const setDraft = (value, typing = true) => {
1267
1548
  latestDraft = value;
1268
1549
  if (props.modelValue === void 0) internalDraft.value = value;
1269
1550
  props.onDraftChange?.(value);
1270
1551
  emit("update:modelValue", value);
1271
- const isTyping = value.trim().length > 0;
1272
- 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);
1273
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;
1274
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
+ }
1275
1596
  const originalDraft = draft();
1276
1597
  const text = originalDraft.trim();
1277
1598
  if (!text || props.isSending || submitting.value) return;
@@ -1340,6 +1661,7 @@ var ConversationView = (0, import_vue7.defineComponent)({
1340
1661
  }, typingLabel(props.typingUserIds, nameForUser));
1341
1662
  };
1342
1663
  const renderComposer = () => {
1664
+ const editing = props.editingMessage;
1343
1665
  const slotProps = {
1344
1666
  value: draft(),
1345
1667
  setValue: setDraft,
@@ -1347,16 +1669,27 @@ var ConversationView = (0, import_vue7.defineComponent)({
1347
1669
  send: () => {
1348
1670
  void submit();
1349
1671
  },
1350
- ...props.onAddAttachment ? { addAttachment } : {}
1672
+ ...props.onAddAttachment ? { addAttachment } : {},
1673
+ ...editing ? { editing, cancelEdit } : {}
1351
1674
  };
1675
+ const busy = props.isSending || submitting.value;
1352
1676
  return slots.composer?.(slotProps) ?? (0, import_vue7.h)("form", {
1353
- class: partClass("composer", appearance(), "ckui-composer"),
1677
+ class: cx(partClass("composer", appearance(), "ckui-composer"), editing && !props.unstyled && "ckui-composer--editing"),
1354
1678
  style: partStyle("composer", appearance()),
1355
1679
  onSubmit: (event) => {
1356
1680
  event.preventDefault();
1357
1681
  void submit();
1358
1682
  }
1359
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
+ ])] : [],
1360
1693
  props.onAddAttachment ? (0, import_vue7.h)("button", {
1361
1694
  type: "button",
1362
1695
  "aria-label": "Add attachment",
@@ -1385,15 +1718,19 @@ var ConversationView = (0, import_vue7.defineComponent)({
1385
1718
  event.preventDefault();
1386
1719
  void submit();
1387
1720
  }
1721
+ if (event.key === "Escape" && props.editingMessage) {
1722
+ event.preventDefault();
1723
+ cancelEdit();
1724
+ }
1388
1725
  }
1389
1726
  }),
1390
1727
  (0, import_vue7.h)("button", {
1391
1728
  type: "submit",
1392
- "aria-label": "Send message",
1393
- disabled: !draft().trim() || props.isSending || submitting.value,
1729
+ "aria-label": editing ? "Save message" : "Send message",
1730
+ disabled: (editing ? !canSave(editing) : !draft().trim()) || busy,
1394
1731
  class: partClass("button", appearance(), "ckui-send-button"),
1395
1732
  style: partStyle("button", appearance())
1396
- }, [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" })])
1397
1734
  ]);
1398
1735
  };
1399
1736
  return () => {
@@ -1442,6 +1779,12 @@ var ConversationView = (0, import_vue7.defineComponent)({
1442
1779
  ...props.onAttachmentClick ? { onAttachmentClick: (media, message) => {
1443
1780
  props.onAttachmentClick?.(media, message);
1444
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 } : {},
1445
1788
  reverse: props.reverseMessages,
1446
1789
  stickToBottom: props.stickToBottom,
1447
1790
  paginationThreshold: props.paginationThreshold,
@@ -1472,6 +1815,11 @@ var Conversation = (0, import_vue7.defineComponent)({
1472
1815
  messages: { type: Array, default: () => [] },
1473
1816
  currentUserId: { type: String, default: "" },
1474
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 },
1475
1823
  client: { type: Object, required: true },
1476
1824
  conversationId: { type: String, required: true },
1477
1825
  messagePageSize: { type: Number, default: 30 },
@@ -1481,7 +1829,21 @@ var Conversation = (0, import_vue7.defineComponent)({
1481
1829
  autoLoad: { type: Boolean, default: true },
1482
1830
  onControllerChange: { type: Function, default: void 0 }
1483
1831
  },
1484
- 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
+ ],
1485
1847
  setup(props, { attrs, emit, expose, slots }) {
1486
1848
  const controller = useConversation({
1487
1849
  client: () => props.client,
@@ -1545,6 +1907,11 @@ var Conversation = (0, import_vue7.defineComponent)({
1545
1907
  isSending: _isSending,
1546
1908
  hasOlderMessages: _hasOlderMessages,
1547
1909
  error: _error,
1910
+ editingMessage: _editingMessage,
1911
+ onEditMessage: _onEditMessage,
1912
+ onSaveEdit: _onSaveEdit,
1913
+ onCancelEdit: _onCancelEdit,
1914
+ onDeleteMessage: _onDeleteMessage,
1548
1915
  ...forwarded
1549
1916
  } = props;
1550
1917
  return (0, import_vue7.h)(ConversationView, {
@@ -1568,6 +1935,28 @@ var Conversation = (0, import_vue7.defineComponent)({
1568
1935
  isSending: controller.isSending.value,
1569
1936
  hasOlderMessages: controller.hasOlderMessages.value,
1570
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
+ } : {},
1571
1960
  "onUpdate:modelValue": (value) => emit("update:modelValue", value),
1572
1961
  ...props.onBack ? { onBack: () => {
1573
1962
  props.onBack?.();