@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.js CHANGED
@@ -26,6 +26,8 @@ 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),
30
32
  markConversationUnread: (conversationId) => client.markConversationUnread(conversationId),
31
33
  clearConversationUnread: (conversationId, options) => client.clearConversationUnread(conversationId, options),
@@ -186,12 +188,13 @@ var ConvoKitAvatar = defineComponent({
186
188
  });
187
189
 
188
190
  // src/components/conversation.ts
189
- 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";
190
192
  import {
191
193
  defineComponent as defineComponent3,
192
194
  h as h3,
193
195
  onBeforeUnmount,
194
196
  ref as ref2,
197
+ watch as watch3,
195
198
  watchEffect
196
199
  } from "vue";
197
200
 
@@ -206,9 +209,31 @@ function version(message) {
206
209
  function hasContent(message) {
207
210
  return !!message.text?.trim() || message.media.length > 0;
208
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
+ }
209
221
  function newest(current, incoming, incomingComplete = true) {
222
+ const byRevision = revisionOrder(current, incoming);
223
+ if (byRevision !== void 0) return byRevision > 0 ? current : incoming;
210
224
  return version(current) > version(incoming) || !incomingComplete && version(current) === version(incoming) ? current : incoming;
211
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
+ }
212
237
  var compare = compareMessageOrder;
213
238
  function positionCursor(position) {
214
239
  return { createdAt: position.createdAt, id: position.messageId };
@@ -228,7 +253,7 @@ function isTargetMiss(cause) {
228
253
  const { code, status } = cause;
229
254
  return code === "MESSAGE_NOT_FOUND" || code === void 0 && status === 404;
230
255
  }
231
- function blank(currentUserId = "") {
256
+ function blank(currentUserId = "", support = { edit: false, delete: false }) {
232
257
  return {
233
258
  conversation: null,
234
259
  messages: [],
@@ -242,7 +267,10 @@ function blank(currentUserId = "") {
242
267
  hasOlderMessages: true,
243
268
  hasLoaded: false,
244
269
  error: null,
245
- currentUserId
270
+ currentUserId,
271
+ editingMessage: null,
272
+ canEditMessages: support.edit,
273
+ canDeleteMessages: support.delete
246
274
  };
247
275
  }
248
276
  var ConversationStore = class {
@@ -261,7 +289,8 @@ var ConversationStore = class {
261
289
  }
262
290
  this.owner = this.client.sessionIdentity;
263
291
  this.user = this.owner ? this.client.currentUserId : "";
264
- 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);
265
294
  }
266
295
  options;
267
296
  client;
@@ -288,6 +317,13 @@ var ConversationStore = class {
288
317
  visible = true;
289
318
  sendRevision;
290
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;
291
327
  refreshQueued = false;
292
328
  typingTimers = /* @__PURE__ */ new Map();
293
329
  ownTypingTimer;
@@ -306,9 +342,33 @@ var ConversationStore = class {
306
342
  for (const message of patch.messages) this.confirmSend(message);
307
343
  if (this.activeSend.confirmed) patch.messages = patch.messages.filter((message) => message.id !== this.activeSend.pending.id);
308
344
  }
345
+ if (patch.messages) patch = this.trackEditing(patch);
309
346
  this.state = { ...this.state, ...patch };
310
347
  for (const listener of this.listeners) listener();
311
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
+ }
312
372
  alive(generation = this.generation) {
313
373
  return !this.disposed && generation === this.generation && this.owner !== null && this.client.sessionIdentity === this.owner;
314
374
  }
@@ -352,6 +412,7 @@ var ConversationStore = class {
352
412
  this.captured = capture();
353
413
  this.sendRevision = void 0;
354
414
  this.activeSend = void 0;
415
+ this.activeEdit = void 0;
355
416
  this.refreshQueued = false;
356
417
  }
357
418
  dispose = () => {
@@ -360,14 +421,14 @@ var ConversationStore = class {
360
421
  }
361
422
  this.disposed = true;
362
423
  this.clear();
363
- this.patch(blank());
424
+ this.patch(blank("", this.support));
364
425
  };
365
426
  fail(cause, generation, history = false) {
366
427
  if (!this.alive(generation)) return;
367
428
  const status = typeof cause === "object" && cause !== null && "status" in cause ? cause.status : void 0;
368
429
  if (history && (status === 401 || status === 403 || status === 404)) {
369
430
  this.clear();
370
- 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 });
371
432
  } else this.patch({ error: cause });
372
433
  }
373
434
  attach(generation, data = true) {
@@ -439,7 +500,7 @@ var ConversationStore = class {
439
500
  if (!this.alive(generation) || type !== "insert" && type !== "update" || !this.validMessage(message) || this.deleted.has(message.id)) return;
440
501
  const existing = this.state.messages.find((item) => item.id === message.id);
441
502
  const known = existing ?? this.changes.get(message.id)?.message;
442
- if (known && version(message) < version(known)) return;
503
+ if (known && older(message, known)) return;
443
504
  const insert = type === "insert" || this.changes.get(message.id)?.insert === true;
444
505
  if (!existing && !insert && type === "update" && !this.state.isInitialLoading && !this.state.isLoadingOlder && !this.state.isReconciling) return;
445
506
  const revision = ++this.revision;
@@ -452,7 +513,7 @@ var ConversationStore = class {
452
513
  }
453
514
  record(message, insert, revision, complete) {
454
515
  const existing = this.state.messages.find((item) => item.id === message.id);
455
- if (existing && version(existing) > version(message)) return;
516
+ if (existing && older(message, existing)) return;
456
517
  if (this.confirmSend(message) && !complete && !message.media.length) {
457
518
  message = { ...message, media: this.activeSend.pending.media };
458
519
  this.confirmSend(message);
@@ -503,7 +564,7 @@ var ConversationStore = class {
503
564
  if (!this.currentHydration(job)) return;
504
565
  const full = await this.client.getMessage(id);
505
566
  if (!this.currentHydration(job)) return;
506
- 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)) {
507
568
  throw new Error("Complete message response does not match the observed resource/revision");
508
569
  }
509
570
  this.record(full, job.insert, job.revision, true);
@@ -583,7 +644,7 @@ var ConversationStore = class {
583
644
  if (!this.alive()) return;
584
645
  this.clear();
585
646
  const generation = this.generation;
586
- this.patch({ ...blank(this.user), isInitialLoading: true });
647
+ this.patch({ ...blank(this.user, this.support), isInitialLoading: true });
587
648
  const revision = this.revision;
588
649
  try {
589
650
  this.attach(generation);
@@ -826,7 +887,8 @@ var ConversationStore = class {
826
887
  text: normalized || null,
827
888
  media: media ?? [],
828
889
  createdAt: /* @__PURE__ */ new Date(),
829
- updatedAt: null
890
+ updatedAt: null,
891
+ revision: 0
830
892
  };
831
893
  const send = { pending };
832
894
  this.activeSend = send;
@@ -870,6 +932,132 @@ var ConversationStore = class {
870
932
  }
871
933
  }
872
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
+ };
873
1061
  readerIdsFor = (message) => readerIdsFor(message, this.state.readAtByUserId, this.state.readPositionByUserId);
874
1062
  };
875
1063
 
@@ -923,11 +1111,18 @@ function useConversation(options) {
923
1111
  hasLoaded: field("hasLoaded"),
924
1112
  error: field("error"),
925
1113
  currentUserId: field("currentUserId"),
1114
+ editingMessage: field("editingMessage"),
1115
+ canEditMessages: field("canEditMessages"),
1116
+ canDeleteMessages: field("canDeleteMessages"),
926
1117
  readerIdsFor: (message) => store.readerIdsFor(message),
927
1118
  loadInitial: () => store.loadInitial(),
928
1119
  refresh: () => store.refresh(),
929
1120
  loadOlderMessages: () => store.loadOlderMessages(),
930
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),
931
1126
  markRead: () => store.markRead(),
932
1127
  updateTyping: (isTyping) => store.updateTyping(isTyping),
933
1128
  setVisible: (value) => {
@@ -939,6 +1134,7 @@ function useConversation(options) {
939
1134
  }
940
1135
 
941
1136
  // src/components/message-list.ts
1137
+ import { isEditedMessage } from "@convokitapp/sdk";
942
1138
  import {
943
1139
  Check,
944
1140
  CheckCheck,
@@ -948,7 +1144,9 @@ import {
948
1144
  ImageOff,
949
1145
  LoaderCircle,
950
1146
  MapPin,
951
- MessageCircle
1147
+ MessageCircle,
1148
+ Pencil,
1149
+ Trash2
952
1150
  } from "@lucide/vue";
953
1151
  import {
954
1152
  computed as computed2,
@@ -1010,6 +1208,10 @@ var MessageListView = defineComponent2({
1010
1208
  isLoadingOlder: { type: Boolean, default: false },
1011
1209
  error: { type: null, required: false },
1012
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 },
1013
1215
  scrollElement: { type: Object, default: void 0 },
1014
1216
  paginationThreshold: { type: Number, default: 240 },
1015
1217
  reverse: { type: Boolean, default: true },
@@ -1017,9 +1219,10 @@ var MessageListView = defineComponent2({
1017
1219
  formatTime: { type: Function, default: formatMessageTime },
1018
1220
  imageLoading: { type: String, default: "lazy" }
1019
1221
  },
1020
- emits: ["load-older", "attachment-click"],
1222
+ emits: ["load-older", "attachment-click", "edit-message", "delete-message"],
1021
1223
  setup(props, { attrs, emit, slots }) {
1022
1224
  const internalElement = ref(null);
1225
+ const confirming = ref(null);
1023
1226
  let requestInFlight = false;
1024
1227
  let lastRequestedLength = null;
1025
1228
  let previousMessageCount = 0;
@@ -1059,16 +1262,77 @@ var MessageListView = defineComponent2({
1059
1262
  }
1060
1263
  }
1061
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
+ };
1062
1270
  const renderMessage = (message, index) => {
1063
1271
  const isCurrentUser = message.senderId === props.currentUserId;
1064
1272
  const sender = participants.value.get(message.senderId);
1065
1273
  const isPending = isConvoKitPendingMessage(message);
1066
1274
  const readerIds = isPending ? /* @__PURE__ */ new Set() : props.readersResolver ? props.readersResolver(message) : readerIdsFor(message, props.readAtByUserId, props.readPositionByUserId);
1067
- 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
+ };
1068
1294
  const custom = slots.message?.(slotProps);
1069
1295
  if (custom) return h2("div", { key: message.id, role: "listitem" }, custom);
1070
1296
  const currentAppearance = appearance();
1071
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;
1072
1336
  const mediaNodes = message.media.map((media, mediaIndex) => {
1073
1337
  const open = props.onAttachmentClick ? () => {
1074
1338
  props.onAttachmentClick?.(media, message);
@@ -1092,15 +1356,19 @@ var MessageListView = defineComponent2({
1092
1356
  style: [props.styles?.message, props.styles?.[messagePart]],
1093
1357
  "data-message-id": message.id
1094
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] : [],
1095
1361
  h2("div", { class: "ckui-message-bubble" }, [
1096
1362
  !isCurrentUser ? h2("strong", { class: "ckui-message-sender" }, sender?.name || message.senderId) : null,
1097
1363
  message.text ? h2("div", { class: "ckui-message-text" }, message.text) : null,
1098
1364
  ...mediaNodes,
1099
1365
  h2("span", { class: "ckui-message-time" }, [
1100
1366
  isPending ? "Sending\u2026" : props.formatTime(message.createdAt),
1367
+ ...isEdited ? [h2("span", { class: "ckui-message-edited", "aria-label": "Edited" }, "Edited")] : [],
1101
1368
  isCurrentUser && !isPending ? readerIds.size > 0 ? h2(CheckCheck, { size: 14, "aria-label": "Read" }) : h2(Check, { size: 14, "aria-label": "Sent" }) : null
1102
1369
  ])
1103
1370
  ]),
1371
+ ...confirm ? [confirm] : [],
1104
1372
  isCurrentUser && !isPending ? slots["read-receipt"]?.(receiptSlotProps) ?? h2("div", {
1105
1373
  class: partClass("receipt", currentAppearance, "ckui-read-receipt"),
1106
1374
  style: partStyle("receipt", currentAppearance)
@@ -1189,6 +1457,13 @@ var viewProps = {
1189
1457
  onTypingChange: { type: Function, default: void 0 },
1190
1458
  onAddAttachment: { type: Function, default: void 0 },
1191
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 },
1192
1467
  isInitialLoading: { type: Boolean, default: false },
1193
1468
  isLoadingOlder: { type: Boolean, default: false },
1194
1469
  isSending: { type: Boolean, default: false },
@@ -1208,6 +1483,9 @@ var viewProps = {
1208
1483
  defaultDraft: { type: String, default: "" },
1209
1484
  onDraftChange: { type: Function, default: void 0 }
1210
1485
  };
1486
+ function editingSummary(message) {
1487
+ return message.text?.trim() || (message.media.length === 1 ? "1 attachment" : `${message.media.length} attachments`);
1488
+ }
1211
1489
  function typingLabel(userIds, displayNameForUser) {
1212
1490
  const names = [...userIds].map(displayNameForUser);
1213
1491
  if (names.length === 0) return "";
@@ -1227,7 +1505,11 @@ var ConversationView = defineComponent3({
1227
1505
  "load-older",
1228
1506
  "add-attachment",
1229
1507
  "attachment-click",
1230
- "update:modelValue"
1508
+ "update:modelValue",
1509
+ "edit-message",
1510
+ "save-edit",
1511
+ "cancel-edit",
1512
+ "delete-message"
1231
1513
  ],
1232
1514
  setup(props, { attrs, emit, slots }) {
1233
1515
  const internalDraft = ref2(props.defaultDraft);
@@ -1240,15 +1522,57 @@ var ConversationView = defineComponent3({
1240
1522
  });
1241
1523
  const draft = () => props.modelValue ?? internalDraft.value;
1242
1524
  let latestDraft = draft();
1243
- const setDraft = (value) => {
1525
+ let stash;
1526
+ let saving = false;
1527
+ const setDraft = (value, typing = true) => {
1244
1528
  latestDraft = value;
1245
1529
  if (props.modelValue === void 0) internalDraft.value = value;
1246
1530
  props.onDraftChange?.(value);
1247
1531
  emit("update:modelValue", value);
1248
- const isTyping = value.trim().length > 0;
1249
- 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);
1250
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;
1251
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
+ }
1252
1576
  const originalDraft = draft();
1253
1577
  const text = originalDraft.trim();
1254
1578
  if (!text || props.isSending || submitting.value) return;
@@ -1317,6 +1641,7 @@ var ConversationView = defineComponent3({
1317
1641
  }, typingLabel(props.typingUserIds, nameForUser));
1318
1642
  };
1319
1643
  const renderComposer = () => {
1644
+ const editing = props.editingMessage;
1320
1645
  const slotProps = {
1321
1646
  value: draft(),
1322
1647
  setValue: setDraft,
@@ -1324,16 +1649,27 @@ var ConversationView = defineComponent3({
1324
1649
  send: () => {
1325
1650
  void submit();
1326
1651
  },
1327
- ...props.onAddAttachment ? { addAttachment } : {}
1652
+ ...props.onAddAttachment ? { addAttachment } : {},
1653
+ ...editing ? { editing, cancelEdit } : {}
1328
1654
  };
1655
+ const busy = props.isSending || submitting.value;
1329
1656
  return slots.composer?.(slotProps) ?? h3("form", {
1330
- class: partClass("composer", appearance(), "ckui-composer"),
1657
+ class: cx(partClass("composer", appearance(), "ckui-composer"), editing && !props.unstyled && "ckui-composer--editing"),
1331
1658
  style: partStyle("composer", appearance()),
1332
1659
  onSubmit: (event) => {
1333
1660
  event.preventDefault();
1334
1661
  void submit();
1335
1662
  }
1336
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
+ ])] : [],
1337
1673
  props.onAddAttachment ? h3("button", {
1338
1674
  type: "button",
1339
1675
  "aria-label": "Add attachment",
@@ -1362,15 +1698,19 @@ var ConversationView = defineComponent3({
1362
1698
  event.preventDefault();
1363
1699
  void submit();
1364
1700
  }
1701
+ if (event.key === "Escape" && props.editingMessage) {
1702
+ event.preventDefault();
1703
+ cancelEdit();
1704
+ }
1365
1705
  }
1366
1706
  }),
1367
1707
  h3("button", {
1368
1708
  type: "submit",
1369
- "aria-label": "Send message",
1370
- disabled: !draft().trim() || props.isSending || submitting.value,
1709
+ "aria-label": editing ? "Save message" : "Send message",
1710
+ disabled: (editing ? !canSave(editing) : !draft().trim()) || busy,
1371
1711
  class: partClass("button", appearance(), "ckui-send-button"),
1372
1712
  style: partStyle("button", appearance())
1373
- }, [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" })])
1374
1714
  ]);
1375
1715
  };
1376
1716
  return () => {
@@ -1419,6 +1759,12 @@ var ConversationView = defineComponent3({
1419
1759
  ...props.onAttachmentClick ? { onAttachmentClick: (media, message) => {
1420
1760
  props.onAttachmentClick?.(media, message);
1421
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 } : {},
1422
1768
  reverse: props.reverseMessages,
1423
1769
  stickToBottom: props.stickToBottom,
1424
1770
  paginationThreshold: props.paginationThreshold,
@@ -1449,6 +1795,11 @@ var Conversation = defineComponent3({
1449
1795
  messages: { type: Array, default: () => [] },
1450
1796
  currentUserId: { type: String, default: "" },
1451
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 },
1452
1803
  client: { type: Object, required: true },
1453
1804
  conversationId: { type: String, required: true },
1454
1805
  messagePageSize: { type: Number, default: 30 },
@@ -1458,7 +1809,21 @@ var Conversation = defineComponent3({
1458
1809
  autoLoad: { type: Boolean, default: true },
1459
1810
  onControllerChange: { type: Function, default: void 0 }
1460
1811
  },
1461
- 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
+ ],
1462
1827
  setup(props, { attrs, emit, expose, slots }) {
1463
1828
  const controller = useConversation({
1464
1829
  client: () => props.client,
@@ -1522,6 +1887,11 @@ var Conversation = defineComponent3({
1522
1887
  isSending: _isSending,
1523
1888
  hasOlderMessages: _hasOlderMessages,
1524
1889
  error: _error,
1890
+ editingMessage: _editingMessage,
1891
+ onEditMessage: _onEditMessage,
1892
+ onSaveEdit: _onSaveEdit,
1893
+ onCancelEdit: _onCancelEdit,
1894
+ onDeleteMessage: _onDeleteMessage,
1525
1895
  ...forwarded
1526
1896
  } = props;
1527
1897
  return h3(ConversationView, {
@@ -1545,6 +1915,28 @@ var Conversation = defineComponent3({
1545
1915
  isSending: controller.isSending.value,
1546
1916
  hasOlderMessages: controller.hasOlderMessages.value,
1547
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
+ } : {},
1548
1940
  "onUpdate:modelValue": (value) => emit("update:modelValue", value),
1549
1941
  ...props.onBack ? { onBack: () => {
1550
1942
  props.onBack?.();
@@ -1573,7 +1965,7 @@ import {
1573
1965
  } from "vue";
1574
1966
 
1575
1967
  // src/composables/use-conversation-list.ts
1576
- 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";
1577
1969
 
1578
1970
  // src/conversation-list-store.ts
1579
1971
  var defaultActivityRefreshWindowMs = 500;
@@ -2031,7 +2423,7 @@ function useConversationList(options) {
2031
2423
  let store = createStore();
2032
2424
  const snapshot = shallowRef2(store.getSnapshot());
2033
2425
  let unsubscribe;
2034
- const stop = watch3(
2426
+ const stop = watch4(
2035
2427
  () => [toValue2(options.client), toValue2(options.client).sessionIdentity],
2036
2428
  () => {
2037
2429
  store.dispose();