@convokitapp/vue-ui 0.7.0 → 0.9.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,10 @@ 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),
77
+ getReplyPreviews: (conversationId, messageIds) => client.getReplyPreviews(conversationId, messageIds),
78
+ getMessageContext: (conversationId, options) => client.getMessageContext(conversationId, options),
75
79
  markConversationRead: (conversationId, options) => client.markConversationRead(conversationId, options),
76
80
  markConversationUnread: (conversationId) => client.markConversationUnread(conversationId),
77
81
  clearConversationUnread: (conversationId, options) => client.clearConversationUnread(conversationId, options),
@@ -246,9 +250,33 @@ function version(message) {
246
250
  function hasContent(message) {
247
251
  return !!message.text?.trim() || message.media.length > 0;
248
252
  }
253
+ function revisionOrder(left, right) {
254
+ const a = left.revision, b = right.revision;
255
+ if (typeof a !== "number" || typeof b !== "number" || a <= 0 && b <= 0) return void 0;
256
+ return a === b ? void 0 : a - b;
257
+ }
258
+ function older(candidate, reference) {
259
+ const byRevision = revisionOrder(candidate, reference);
260
+ return byRevision === void 0 ? version(candidate) < version(reference) : byRevision < 0;
261
+ }
249
262
  function newest(current, incoming, incomingComplete = true) {
263
+ const byRevision = revisionOrder(current, incoming);
264
+ if (byRevision !== void 0) return byRevision > 0 ? current : incoming;
250
265
  return version(current) > version(incoming) || !incomingComplete && version(current) === version(incoming) ? current : incoming;
251
266
  }
267
+ function isRevisionConflict(cause) {
268
+ if (typeof cause !== "object" || cause === null) return false;
269
+ const { code, status } = cause;
270
+ return code === "REVISION_CONFLICT" || code === void 0 && status === 409;
271
+ }
272
+ function isMessageMissing(cause) {
273
+ return typeof cause === "object" && cause !== null && cause.code === "MESSAGE_NOT_FOUND";
274
+ }
275
+ function localConflict() {
276
+ return Object.assign(new Error("Message was changed since it was loaded"), { code: "REVISION_CONFLICT" });
277
+ }
278
+ var JUMP_GUARD_MS = 150;
279
+ var HIGHLIGHT_MS = 2e3;
252
280
  var compare = compareMessageOrder;
253
281
  function positionCursor(position) {
254
282
  return { createdAt: position.createdAt, id: position.messageId };
@@ -268,7 +296,26 @@ function isTargetMiss(cause) {
268
296
  const { code, status } = cause;
269
297
  return code === "MESSAGE_NOT_FOUND" || code === void 0 && status === 404;
270
298
  }
271
- function blank(currentUserId = "") {
299
+ function isRouteMissing(cause) {
300
+ if (typeof cause !== "object" || cause === null) return false;
301
+ const { code, status } = cause;
302
+ return status === 404 && code !== "MESSAGE_NOT_FOUND";
303
+ }
304
+ var REPLY_PREVIEW_TEXT_LIMIT = 500;
305
+ function previewOf(message) {
306
+ const text = message.text;
307
+ return {
308
+ id: message.id,
309
+ conversationId: message.conversationId,
310
+ senderId: message.senderId,
311
+ text: text === null ? null : text.slice(0, REPLY_PREVIEW_TEXT_LIMIT),
312
+ textTruncated: (text?.length ?? 0) > REPLY_PREVIEW_TEXT_LIMIT,
313
+ createdAt: message.createdAt,
314
+ revision: message.revision,
315
+ mediaCount: message.media.length
316
+ };
317
+ }
318
+ function blank(currentUserId = "", support = { edit: false, delete: false, jump: false, previews: false }) {
272
319
  return {
273
320
  conversation: null,
274
321
  messages: [],
@@ -282,7 +329,19 @@ function blank(currentUserId = "") {
282
329
  hasOlderMessages: true,
283
330
  hasLoaded: false,
284
331
  error: null,
285
- currentUserId
332
+ currentUserId,
333
+ editingMessage: null,
334
+ canEditMessages: support.edit,
335
+ canDeleteMessages: support.delete,
336
+ replyTarget: null,
337
+ replyPreviews: /* @__PURE__ */ new Map(),
338
+ highlightedMessageId: null,
339
+ jumpInFlight: false,
340
+ windowMode: "live",
341
+ hasNewerMessages: false,
342
+ isLoadingNewer: false,
343
+ canJumpToMessage: support.jump,
344
+ canResolveReplyPreviews: support.previews
286
345
  };
287
346
  }
288
347
  var ConversationStore = class {
@@ -301,7 +360,13 @@ var ConversationStore = class {
301
360
  }
302
361
  this.owner = this.client.sessionIdentity;
303
362
  this.user = this.owner ? this.client.currentUserId : "";
304
- this.state = blank(this.user);
363
+ this.support = {
364
+ edit: typeof this.client.editMessage === "function",
365
+ delete: typeof this.client.deleteMessage === "function",
366
+ jump: typeof this.client.getMessageContext === "function",
367
+ previews: typeof this.client.getReplyPreviews === "function"
368
+ };
369
+ this.state = blank(this.user, this.support);
305
370
  }
306
371
  options;
307
372
  client;
@@ -328,7 +393,34 @@ var ConversationStore = class {
328
393
  visible = true;
329
394
  sendRevision;
330
395
  activeSend;
396
+ /** Adapter support for author edits/deletes, decided once like `listInbox`. */
397
+ support;
398
+ /** The id whose `saveEdit` request is in flight: its outcome (success or 409) decides the edit, so newer rows for
399
+ * it arriving meanwhile (its own UPDATE image, typically) are not reported as a local conflict while it lasts;
400
+ * `settleEditing` re-evaluates them once the request has settled any other way.
401
+ */
402
+ activeEdit;
331
403
  refreshQueued = false;
404
+ /** A reconcile owed to the JUMPED window (0.9.0). `refreshQueued` stays owed across a jump so the tail-anchored
405
+ * reconcile still runs on the return to `live`; this flag is the one a jumped window consumes.
406
+ */
407
+ jumpedRefreshQueued = false;
408
+ /** The jumped window's paging cursors (0.9.0), opaque and server-minted; null at that end of the history. */
409
+ olderContextCursor = null;
410
+ newerContextCursor = null;
411
+ /** Preview entries that must be re-read on the next batch: a quoted parent changed outside the window, or the
412
+ * subscription reconnected (0.9.0). `'unavailable'` is terminal and never enters this set.
413
+ */
414
+ stalePreviews = /* @__PURE__ */ new Set();
415
+ /** Ids a batch is already asking for (0.9.0): two triggers that overlap share one request instead of racing. */
416
+ requestedPreviews = /* @__PURE__ */ new Set();
417
+ /** Live inserts recorded but not rendered while jumped (0.9.0); drained — and acknowledged — by the return. */
418
+ deferred = /* @__PURE__ */ new Set();
419
+ /** The message the current jumped window was centred on, until the window is paged (0.9.0). */
420
+ jumpAnchor;
421
+ previewTimer;
422
+ jumpTimer;
423
+ highlightTimer;
332
424
  typingTimers = /* @__PURE__ */ new Map();
333
425
  ownTypingTimer;
334
426
  sentTyping = false;
@@ -346,9 +438,33 @@ var ConversationStore = class {
346
438
  for (const message of patch.messages) this.confirmSend(message);
347
439
  if (this.activeSend.confirmed) patch.messages = patch.messages.filter((message) => message.id !== this.activeSend.pending.id);
348
440
  }
441
+ if (patch.messages) patch = this.trackEditing(patch);
349
442
  this.state = { ...this.state, ...patch };
350
443
  for (const listener of this.listeners) listener();
351
444
  }
445
+ /** Edit mode follows the edited row wherever a message list reaches the state: the row leaving the list (deletion,
446
+ * reconcile tombstone, eviction) ends it, and a row for it with a higher revision than the snapshot (UPDATE image,
447
+ * hydration, reconcile, refresh) is the local conflict: the snapshot is replaced and `error` carries the conflict
448
+ * code, without a request. A save in flight owns its own outcome (`activeEdit`) and re-checks when it settles
449
+ * (`settleEditing`).
450
+ */
451
+ trackEditing(patch) {
452
+ const editing = patch.editingMessage === void 0 ? this.state.editingMessage : patch.editingMessage;
453
+ if (!editing || !patch.messages) return patch;
454
+ const live = patch.messages.find((message) => message.id === editing.id);
455
+ if (!live) return { ...patch, editingMessage: null };
456
+ if (this.activeEdit === editing.id || !(live.revision > editing.revision)) return patch;
457
+ return { ...patch, editingMessage: live, error: localConflict() };
458
+ }
459
+ /** After a save for `id` has settled without deciding the edit (a failure, or a 409 whose reload failed), a newer
460
+ * row for it that arrived during the request is the local conflict after all: the snapshot is replaced and
461
+ * `error` carries the conflict code, so the next save carries the fresh revision without another round trip.
462
+ */
463
+ settleEditing(id) {
464
+ const editing = this.state.editingMessage;
465
+ const live = editing?.id === id ? this.state.messages.find((message) => message.id === id) : void 0;
466
+ if (editing && live && live.revision > editing.revision) this.patch({ editingMessage: live, error: localConflict() });
467
+ }
352
468
  alive(generation = this.generation) {
353
469
  return !this.disposed && generation === this.generation && this.owner !== null && this.client.sessionIdentity === this.owner;
354
470
  }
@@ -392,7 +508,21 @@ var ConversationStore = class {
392
508
  this.captured = capture();
393
509
  this.sendRevision = void 0;
394
510
  this.activeSend = void 0;
511
+ this.activeEdit = void 0;
395
512
  this.refreshQueued = false;
513
+ this.jumpedRefreshQueued = false;
514
+ this.olderContextCursor = null;
515
+ this.newerContextCursor = null;
516
+ this.jumpAnchor = void 0;
517
+ this.stalePreviews.clear();
518
+ this.requestedPreviews.clear();
519
+ this.deferred.clear();
520
+ clearTimeout(this.previewTimer);
521
+ this.previewTimer = void 0;
522
+ clearTimeout(this.jumpTimer);
523
+ this.jumpTimer = void 0;
524
+ clearTimeout(this.highlightTimer);
525
+ this.highlightTimer = void 0;
396
526
  }
397
527
  dispose = () => {
398
528
  if (this.alive() && this.sentTyping) {
@@ -400,14 +530,14 @@ var ConversationStore = class {
400
530
  }
401
531
  this.disposed = true;
402
532
  this.clear();
403
- this.patch(blank());
533
+ this.patch(blank("", this.support));
404
534
  };
405
535
  fail(cause, generation, history = false) {
406
536
  if (!this.alive(generation)) return;
407
537
  const status = typeof cause === "object" && cause !== null && "status" in cause ? cause.status : void 0;
408
538
  if (history && (status === 401 || status === 403 || status === 404)) {
409
539
  this.clear();
410
- this.patch({ ...blank(this.user), error: cause, hasLoaded: true, hasOlderMessages: false });
540
+ this.patch({ ...blank(this.user, this.support), error: cause, hasLoaded: true, hasOlderMessages: false });
411
541
  } else this.patch({ error: cause });
412
542
  }
413
543
  attach(generation, data = true) {
@@ -422,8 +552,10 @@ var ConversationStore = class {
422
552
  add(() => this.client.onConnectionEvent({
423
553
  onEvent: ({ topic, status }) => {
424
554
  if (!data || !this.alive(generation) || topic !== `messages:${this.room}` && topic !== `conversation:${this.room}`) return;
425
- if (status === "SUBSCRIBED") this.queueRefresh();
426
- else this.clearTyping();
555
+ if (status === "SUBSCRIBED") {
556
+ this.markPreviewsStale();
557
+ this.queueRefresh();
558
+ } else this.clearTyping();
427
559
  },
428
560
  onSessionEnded: () => {
429
561
  if (this.disposed || generation !== this.generation) return;
@@ -479,9 +611,12 @@ var ConversationStore = class {
479
611
  if (!this.alive(generation) || type !== "insert" && type !== "update" || !this.validMessage(message) || this.deleted.has(message.id)) return;
480
612
  const existing = this.state.messages.find((item) => item.id === message.id);
481
613
  const known = existing ?? this.changes.get(message.id)?.message;
482
- if (known && version(message) < version(known)) return;
614
+ if (known && older(message, known)) return;
483
615
  const insert = type === "insert" || this.changes.get(message.id)?.insert === true;
484
- if (!existing && !insert && type === "update" && !this.state.isInitialLoading && !this.state.isLoadingOlder && !this.state.isReconciling) return;
616
+ if (!existing && !insert && type === "update") {
617
+ this.notePreviewSource(message.id);
618
+ if (!this.state.isInitialLoading && !this.state.isLoadingOlder && !this.state.isReconciling) return;
619
+ }
485
620
  const revision = ++this.revision;
486
621
  const provisional = existing && !message.media.length ? { ...message, media: existing.media } : message;
487
622
  this.record(provisional, insert, revision, false);
@@ -492,14 +627,21 @@ var ConversationStore = class {
492
627
  }
493
628
  record(message, insert, revision, complete) {
494
629
  const existing = this.state.messages.find((item) => item.id === message.id);
495
- if (existing && version(existing) > version(message)) return;
630
+ if (existing && older(message, existing)) return;
496
631
  if (this.confirmSend(message) && !complete && !message.media.length) {
497
632
  message = { ...message, media: this.activeSend.pending.media };
498
633
  this.confirmSend(message);
499
634
  }
500
635
  this.changes.set(message.id, { revision, message, insert, complete });
501
636
  if (!existing && !(insert && hasContent(message))) return;
637
+ if (!existing && this.state.windowMode === "jumped") {
638
+ this.deferred.add(message.id);
639
+ if (!this.state.hasNewerMessages) this.patch({ hasNewerMessages: true });
640
+ return;
641
+ }
502
642
  this.patch({ messages: mergeMessages(this.state.messages, [message]) });
643
+ this.notePreviewSource(message.id);
644
+ if (message.replyToMessageId) this.schedulePreviews();
503
645
  if (!existing && message.senderId !== this.user && (this.options.markReadOnReceive ?? true)) void this.acknowledge(true);
504
646
  }
505
647
  confirmSend(message) {
@@ -521,6 +663,9 @@ var ConversationStore = class {
521
663
  this.changes.delete(id);
522
664
  this.hydrations.delete(id);
523
665
  this.hydrationPool.queued.delete(id);
666
+ this.deferred.delete(id);
667
+ this.markUnavailable(id);
668
+ if (this.state.replyTarget?.id === id) this.patch({ replyTarget: null });
524
669
  const ack = this.ack;
525
670
  if (ack.target !== id && ack.acknowledged?.id !== id) return;
526
671
  ack.unacknowledgeable.add(id);
@@ -543,7 +688,7 @@ var ConversationStore = class {
543
688
  if (!this.currentHydration(job)) return;
544
689
  const full = await this.client.getMessage(id);
545
690
  if (!this.currentHydration(job)) return;
546
- if (!this.validMessage(full) || full.id !== id || full.senderId !== job.message.senderId || version(full) < version(job.message)) {
691
+ if (!this.validMessage(full) || full.id !== id || full.senderId !== job.message.senderId || older(full, job.message)) {
547
692
  throw new Error("Complete message response does not match the observed resource/revision");
548
693
  }
549
694
  this.record(full, job.insert, job.revision, true);
@@ -589,6 +734,23 @@ var ConversationStore = class {
589
734
  previous = message;
590
735
  }
591
736
  }
737
+ /** Context windows get their OWN validator (0.9.0): `validatePage` measures a page against the backward cursor
738
+ * it was fetched with, and a window centred on a message has no such cursor. Only the per-row predicates are
739
+ * shared. A CENTRED window must carry its target exactly once; a cursor page must not be held to that.
740
+ */
741
+ validateContextPage(page, limit, targetId) {
742
+ if (page.length > limit) throw new Error("Message context exceeds the requested limit");
743
+ let previous;
744
+ for (const message of page) {
745
+ if (!this.validMessage(message) || previous && compare(message, previous) >= 0) {
746
+ throw new Error("Message context must contain distinct, room-scoped rows in newest-first order");
747
+ }
748
+ previous = message;
749
+ }
750
+ if (targetId !== void 0 && page.filter((message) => message.id === targetId).length !== 1) {
751
+ throw new Error("Centred message context must contain its target exactly once");
752
+ }
753
+ }
592
754
  fetchPage(before) {
593
755
  return this.client.getMessages({
594
756
  conversationId: this.room,
@@ -611,6 +773,148 @@ var ConversationStore = class {
611
773
  }
612
774
  return mergeMessages([], [...byId.values()]);
613
775
  }
776
+ /** `overlay` for a JUMPED window (0.9.0): the same precedence for rows the window already holds, WITHOUT the
777
+ * pending replay and the `changes`-insert replay, either of which would inject live-tail rows into a
778
+ * historical window. A jumped window only ever holds rows a context response carried.
779
+ */
780
+ overlayWindow(rows, revision) {
781
+ const byId = new Map(rows.filter((message) => !this.deleted.has(message.id)).map((message) => [message.id, message]));
782
+ for (const [id, change] of this.changes) {
783
+ const current = byId.get(id);
784
+ if (current && change.revision > revision) byId.set(id, newest(current, change.message, change.complete));
785
+ }
786
+ return mergeMessages([], [...byId.values()]);
787
+ }
788
+ /** The distinct quoted parents the rendered rows point at (0.9.0). */
789
+ referencedParents() {
790
+ const referenced = /* @__PURE__ */ new Set();
791
+ for (const message of this.state.messages) {
792
+ if (message.replyToMessageId) referenced.add(message.replyToMessageId);
793
+ }
794
+ return referenced;
795
+ }
796
+ /** Cache the terminal `'unavailable'` for a quoted parent that is gone, while any rendered row still quotes it
797
+ * (or an entry for it already exists). Never re-requested: absence from a resolved batch is the only deletion
798
+ * signal the backend gives, and a deleted message cannot come back.
799
+ */
800
+ markUnavailable(id) {
801
+ const cached = this.state.replyPreviews.get(id);
802
+ if (cached === "unavailable") return;
803
+ if (cached === void 0 && !this.referencedParents().has(id)) return;
804
+ const next = new Map(this.state.replyPreviews);
805
+ next.set(id, "unavailable");
806
+ this.stalePreviews.delete(id);
807
+ this.patch({ replyPreviews: next });
808
+ }
809
+ /** Re-read every non-terminal preview on the next batch (reconnect/`SUBSCRIBED`). */
810
+ markPreviewsStale() {
811
+ for (const [id, entry] of this.state.replyPreviews) if (entry !== "unavailable") this.stalePreviews.add(id);
812
+ }
813
+ /** A row for a quoted parent reached the store: the preview that references it is refreshed from the rendered
814
+ * row, or marked stale when the parent is outside the window. Previews are re-read, never copied — a parent
815
+ * edit bumps the PARENT's revision, which no row-precedence rule on the reply can see.
816
+ */
817
+ notePreviewSource(id) {
818
+ const cached = this.state.replyPreviews.get(id);
819
+ if (cached === void 0) return;
820
+ const row = this.state.messages.find((message) => message.id === id);
821
+ if (row && !isConvoKitPendingMessage(row)) {
822
+ const fresh = previewOf(row);
823
+ this.stalePreviews.delete(id);
824
+ if (cached !== "unavailable" && cached.revision === fresh.revision && cached.text === fresh.text && cached.mediaCount === fresh.mediaCount) return;
825
+ const next = new Map(this.state.replyPreviews);
826
+ next.set(id, fresh);
827
+ this.patch({ replyPreviews: next });
828
+ return;
829
+ }
830
+ if (cached === "unavailable") return;
831
+ this.stalePreviews.add(id);
832
+ this.schedulePreviews();
833
+ }
834
+ /** Coalesce a burst of live inserts into one batch; the handle is cleared wherever subscriptions are torn down
835
+ * and the callback is dropped when the store is no longer alive for the generation that scheduled it.
836
+ */
837
+ schedulePreviews() {
838
+ if (this.previewTimer !== void 0 || !this.support.previews) return;
839
+ const generation = this.generation;
840
+ const timer = setTimeout(() => {
841
+ this.previewTimer = void 0;
842
+ if (this.alive(generation)) void this.resolvePreviews();
843
+ }, 120);
844
+ timer.unref?.();
845
+ this.previewTimer = timer;
846
+ }
847
+ /** Resolve the quoted parents of the rendered rows in ONE request, never one per row (0.9.0). A parent inside
848
+ * the loaded window is derived locally and costs nothing; `'unavailable'` is terminal; an id with no entry is
849
+ * "not resolved yet", so a rejection — which says nothing about which ids exist — writes no entry at all and
850
+ * the next trigger asks again. `prune` drops entries no rendered row references (reconcile completion), which
851
+ * is what bounds the map to the window.
852
+ */
853
+ async resolvePreviews(prune = false) {
854
+ if (!this.alive() || !this.support.previews || !this.state.canResolveReplyPreviews) return;
855
+ const referenced = this.referencedParents();
856
+ const previews = new Map(this.state.replyPreviews);
857
+ let changed = false;
858
+ if (prune) {
859
+ for (const id of [...previews.keys()]) {
860
+ if (referenced.has(id)) continue;
861
+ previews.delete(id);
862
+ this.stalePreviews.delete(id);
863
+ changed = true;
864
+ }
865
+ }
866
+ const window = new Map(this.state.messages.filter((message) => !isConvoKitPendingMessage(message)).map((message) => [message.id, message]));
867
+ const wanted = [];
868
+ for (const id of referenced) {
869
+ const local = window.get(id);
870
+ if (local) {
871
+ this.stalePreviews.delete(id);
872
+ const cached2 = previews.get(id);
873
+ const fresh = previewOf(local);
874
+ if (cached2 === void 0 || cached2 === "unavailable" || cached2.revision !== fresh.revision || cached2.text !== fresh.text || cached2.mediaCount !== fresh.mediaCount) {
875
+ previews.set(id, fresh);
876
+ changed = true;
877
+ }
878
+ continue;
879
+ }
880
+ const cached = previews.get(id);
881
+ if (cached === "unavailable") continue;
882
+ if (cached !== void 0 && !this.stalePreviews.has(id)) continue;
883
+ if (this.requestedPreviews.has(id)) continue;
884
+ wanted.push(id);
885
+ }
886
+ if (changed) this.patch({ replyPreviews: previews });
887
+ if (wanted.length === 0) return;
888
+ const generation = this.generation;
889
+ for (const id of wanted) this.requestedPreviews.add(id);
890
+ try {
891
+ const resolved = await this.client.getReplyPreviews(this.room, wanted);
892
+ if (!this.alive(generation)) return;
893
+ const next = new Map(this.state.replyPreviews);
894
+ const returned = /* @__PURE__ */ new Set();
895
+ for (const preview of resolved) {
896
+ if (preview.conversationId !== this.room) continue;
897
+ returned.add(preview.id);
898
+ next.set(preview.id, preview);
899
+ this.stalePreviews.delete(preview.id);
900
+ }
901
+ for (const id of wanted) {
902
+ if (returned.has(id)) continue;
903
+ next.set(id, "unavailable");
904
+ this.stalePreviews.delete(id);
905
+ }
906
+ this.patch({ replyPreviews: next });
907
+ } catch (cause) {
908
+ if (!this.alive(generation)) return;
909
+ if (isRouteMissing(cause)) {
910
+ this.support.previews = false;
911
+ this.patch({ canResolveReplyPreviews: false });
912
+ }
913
+ this.fail(cause, generation);
914
+ } finally {
915
+ for (const id of wanted) this.requestedPreviews.delete(id);
916
+ }
917
+ }
614
918
  prune(revision) {
615
919
  const safeRevision = Math.min(revision, this.sendRevision ?? Infinity);
616
920
  for (const [id, change] of this.changes) if (change.revision <= safeRevision) this.changes.delete(id);
@@ -623,7 +927,7 @@ var ConversationStore = class {
623
927
  if (!this.alive()) return;
624
928
  this.clear();
625
929
  const generation = this.generation;
626
- this.patch({ ...blank(this.user), isInitialLoading: true });
930
+ this.patch({ ...blank(this.user, this.support), isInitialLoading: true });
627
931
  const revision = this.revision;
628
932
  try {
629
933
  this.attach(generation);
@@ -637,6 +941,7 @@ var ConversationStore = class {
637
941
  this.captured = capture(conversation);
638
942
  this.mergeReads(conversation.participants.map(readEntry));
639
943
  this.prune(revision);
944
+ void this.resolvePreviews();
640
945
  if ((this.options.markReadOnLoad ?? true) || this.ack.suppressed) {
641
946
  this.ack.suppressed = false;
642
947
  await this.acknowledge(true);
@@ -652,19 +957,36 @@ var ConversationStore = class {
652
957
  };
653
958
  queueRefresh() {
654
959
  this.refreshQueued = true;
960
+ this.jumpedRefreshQueued = true;
655
961
  this.flushRefresh();
656
962
  }
657
963
  flushRefresh() {
658
964
  const generation = this.generation;
659
965
  void Promise.resolve().then(() => {
660
- if (!this.alive(generation) || !this.refreshQueued || this.state.isInitialLoading || this.state.isLoadingOlder || this.state.isReconciling) return;
966
+ if (!this.alive(generation) || !this.refreshQueued || this.state.isInitialLoading || this.state.isLoadingOlder || this.state.isLoadingNewer || this.state.isReconciling) return;
967
+ if (this.state.windowMode === "jumped") {
968
+ if (!this.jumpedRefreshQueued) return;
969
+ this.jumpedRefreshQueued = false;
970
+ void this.reconcileWindow();
971
+ return;
972
+ }
661
973
  this.refreshQueued = false;
974
+ this.jumpedRefreshQueued = false;
662
975
  void this.refresh();
663
976
  });
664
977
  }
665
978
  /** Re-fetch the entire viewed range atomically; a first-page-only refresh loses history. */
666
979
  refresh = async () => {
667
980
  if (!this.alive()) return;
981
+ if (this.state.windowMode === "jumped") {
982
+ this.refreshQueued = true;
983
+ if (this.state.isInitialLoading || this.state.isLoadingOlder || this.state.isLoadingNewer || this.state.isReconciling) {
984
+ this.jumpedRefreshQueued = true;
985
+ return;
986
+ }
987
+ this.jumpedRefreshQueued = false;
988
+ return this.reconcileWindow();
989
+ }
668
990
  if (this.state.isInitialLoading || this.state.isLoadingOlder || this.state.isReconciling) {
669
991
  this.refreshQueued = true;
670
992
  return;
@@ -702,6 +1024,7 @@ var ConversationStore = class {
702
1024
  if (opening) this.captured = capture(conversation);
703
1025
  this.mergeReads(conversation.participants.map(readEntry));
704
1026
  this.prune(revision);
1027
+ void this.resolvePreviews(true);
705
1028
  if (opening) void this.resumeAcknowledgement();
706
1029
  } catch (cause) {
707
1030
  this.fail(cause, generation, true);
@@ -713,7 +1036,8 @@ var ConversationStore = class {
713
1036
  }
714
1037
  };
715
1038
  loadOlderMessages = async () => {
716
- if (!this.alive() || !this.state.hasLoaded || this.state.isInitialLoading || this.state.isLoadingOlder || this.state.isReconciling || !this.state.hasOlderMessages) return;
1039
+ if (!this.alive() || !this.state.hasLoaded || this.state.isInitialLoading || this.state.isLoadingOlder || this.state.isReconciling || this.state.isLoadingNewer || !this.state.hasOlderMessages) return;
1040
+ if (this.state.windowMode === "jumped") return this.loadContextPage("older");
717
1041
  const generation = this.generation;
718
1042
  const revision = this.revision;
719
1043
  const cursor = this.cursor;
@@ -727,6 +1051,7 @@ var ConversationStore = class {
727
1051
  messages: this.overlay(mergeMessages(page, this.state.messages), revision),
728
1052
  hasOlderMessages: page.length === this.pageSize
729
1053
  });
1054
+ void this.resolvePreviews();
730
1055
  } catch (cause) {
731
1056
  this.fail(cause, generation, true);
732
1057
  } finally {
@@ -736,6 +1061,298 @@ var ConversationStore = class {
736
1061
  }
737
1062
  }
738
1063
  };
1064
+ /** Page a jumped window towards the start (0.9.0); a no-op while the window is live or already at the end. */
1065
+ loadNewerMessages = async () => {
1066
+ if (!this.alive() || !this.state.hasLoaded || this.state.windowMode !== "jumped" || !this.state.hasNewerMessages || this.state.isInitialLoading || this.state.isLoadingOlder || this.state.isLoadingNewer || this.state.isReconciling) return;
1067
+ return this.loadContextPage("newer");
1068
+ };
1069
+ /** One page of the jumped window in either direction, through the cursor the previous window returned. When
1070
+ * the newer side reaches the tail the store does NOT flip to `live` on the spot — `newerCursor: null` was only
1071
+ * true as of the server's query time and inserts have been deferred throughout the round trip, so the return
1072
+ * to the live tail is a full `returnToLatest()`.
1073
+ */
1074
+ async loadContextPage(direction) {
1075
+ const cursor = direction === "older" ? this.olderContextCursor : this.newerContextCursor;
1076
+ if (cursor === null || typeof this.client.getMessageContext !== "function") return;
1077
+ const generation = this.generation;
1078
+ const revision = this.revision;
1079
+ let reachedTail = false;
1080
+ this.patch(direction === "older" ? { isLoadingOlder: true, error: null } : { isLoadingNewer: true, error: null });
1081
+ try {
1082
+ const page = await this.client.getMessageContext(this.room, {
1083
+ ...direction === "older" ? { olderCursor: cursor } : { newerCursor: cursor },
1084
+ limit: this.pageSize
1085
+ });
1086
+ if (!this.alive(generation)) return;
1087
+ this.validateContextPage(page.messages, this.pageSize);
1088
+ this.jumpAnchor = void 0;
1089
+ if (direction === "older") {
1090
+ this.olderContextCursor = page.olderCursor;
1091
+ this.patch({
1092
+ messages: this.overlayWindow(mergeMessages(this.state.messages, page.messages), revision),
1093
+ hasOlderMessages: page.olderCursor !== null
1094
+ });
1095
+ } else {
1096
+ this.newerContextCursor = page.newerCursor;
1097
+ reachedTail = page.newerCursor === null;
1098
+ this.patch({
1099
+ messages: this.overlayWindow(mergeMessages(this.state.messages, page.messages), revision),
1100
+ hasNewerMessages: page.newerCursor !== null
1101
+ });
1102
+ }
1103
+ void this.resolvePreviews();
1104
+ } catch (cause) {
1105
+ if (!this.alive(generation)) return;
1106
+ if (isRouteMissing(cause)) this.retireJump();
1107
+ this.fail(cause, generation);
1108
+ } finally {
1109
+ if (this.alive(generation)) {
1110
+ this.patch(direction === "older" ? { isLoadingOlder: false } : { isLoadingNewer: false });
1111
+ this.flushRefresh();
1112
+ }
1113
+ }
1114
+ if (reachedTail && this.alive(generation)) await this.returnToLatest();
1115
+ }
1116
+ /** Re-read the JUMPED window with one bounded request, in place of the tail-anchored boundary walk (0.9.0).
1117
+ * The anchor is the jump target while the window has not been paged, otherwise the newest non-pending row at
1118
+ * or older than the window's midpoint, and the limit is the window's own size. Tombstones are bounded by the
1119
+ * returned range: a known row inside it that the response did not carry is gone, anything outside is not.
1120
+ */
1121
+ async reconcileWindow() {
1122
+ const anchor = this.windowAnchor();
1123
+ if (!anchor || typeof this.client.getMessageContext !== "function") return;
1124
+ const generation = this.generation;
1125
+ const revision = this.revision;
1126
+ const rendered = this.state.messages.filter((message) => !isConvoKitPendingMessage(message));
1127
+ const limit = Math.min(Math.max(rendered.length, 1), 100);
1128
+ this.patch({ isReconciling: true, error: null });
1129
+ try {
1130
+ let conversation;
1131
+ try {
1132
+ conversation = await this.client.getConversation(this.room);
1133
+ } catch (cause) {
1134
+ this.fail(cause, generation, true);
1135
+ return;
1136
+ }
1137
+ if (!this.alive(generation)) return;
1138
+ if (conversation.id !== this.room) {
1139
+ this.fail(new Error("Conversation response belongs to a different room"), generation, true);
1140
+ return;
1141
+ }
1142
+ const page = await this.client.getMessageContext(this.room, { messageId: anchor, limit });
1143
+ if (!this.alive(generation)) return;
1144
+ this.validateContextPage(page.messages, limit, anchor);
1145
+ this.olderContextCursor = page.olderCursor;
1146
+ this.newerContextCursor = page.newerCursor;
1147
+ const reconciled = this.overlayWindow(page.messages, revision);
1148
+ const surviving = new Set(reconciled.map((message) => message.id));
1149
+ const newestRow = page.messages[0];
1150
+ const oldestRow = page.messages.at(-1);
1151
+ if (newestRow && oldestRow) {
1152
+ for (const message of rendered) {
1153
+ if (surviving.has(message.id)) continue;
1154
+ if (compare(message, oldestRow) < 0 || compare(message, newestRow) > 0) continue;
1155
+ this.forget(message.id);
1156
+ }
1157
+ }
1158
+ this.patch({
1159
+ conversation,
1160
+ messages: reconciled,
1161
+ hasOlderMessages: page.olderCursor !== null,
1162
+ hasNewerMessages: page.newerCursor !== null
1163
+ });
1164
+ this.mergeReads(conversation.participants.map(readEntry));
1165
+ this.prune(revision);
1166
+ void this.resolvePreviews(true);
1167
+ } catch (cause) {
1168
+ if (!this.alive(generation)) return;
1169
+ if (isMessageMissing(cause)) this.removeMessage(anchor);
1170
+ else if (isRouteMissing(cause)) this.retireJump();
1171
+ this.fail(cause, generation);
1172
+ } finally {
1173
+ if (this.alive(generation)) {
1174
+ this.patch({ isReconciling: false });
1175
+ this.flushRefresh();
1176
+ }
1177
+ }
1178
+ }
1179
+ /** The row a jumped window re-reads around: its jump target while it has not been paged, otherwise the newest
1180
+ * non-pending row at or older than the window's midpoint.
1181
+ */
1182
+ windowAnchor() {
1183
+ const rendered = this.state.messages.filter((message) => !isConvoKitPendingMessage(message));
1184
+ if (rendered.length === 0) return void 0;
1185
+ if (this.jumpAnchor && rendered.some((message) => message.id === this.jumpAnchor)) return this.jumpAnchor;
1186
+ return rendered[Math.floor((rendered.length - 1) / 2)]?.id;
1187
+ }
1188
+ /** A 0.8 backend does not serve the context route: the jump affordance disappears for the store's life rather
1189
+ * than failing repeatedly. A coded `MESSAGE_NOT_FOUND` never trips this — that is a real missing target.
1190
+ */
1191
+ retireJump() {
1192
+ this.support.jump = false;
1193
+ this.patch({ canJumpToMessage: false });
1194
+ }
1195
+ /** Bring a message into view (0.9.0). A row already in the loaded window is only highlighted and scrolled to;
1196
+ * otherwise the window is REPLACED by a context window centred on it and `windowMode` becomes `jumped`. A jump
1197
+ * is a window operation, never a re-open: tombstones, the acknowledgement floor, this open's captured private
1198
+ * state, edit mode and the reply target all survive it, and it arms no acknowledgement. It is a no-op while a
1199
+ * send is in flight, so a replacement can never strand a pending row. A coded `MESSAGE_NOT_FOUND` is the
1200
+ * guaranteed answer for a quoted message that was deleted: it marks the preview `'unavailable'` instead of
1201
+ * reporting an error. Resolves true once the target is highlighted.
1202
+ */
1203
+ jumpToMessage = async (messageId) => {
1204
+ if (!this.alive() || !this.state.hasLoaded) return false;
1205
+ const id = messageId.trim();
1206
+ if (!id || this.state.isSending) return false;
1207
+ const rendered = this.state.messages.find((message) => message.id === id);
1208
+ if (rendered) return isConvoKitPendingMessage(rendered) ? false : (this.beginJump(), this.landJump(id), true);
1209
+ if (this.deleted.has(id)) {
1210
+ this.markUnavailable(id);
1211
+ return false;
1212
+ }
1213
+ if (!this.support.jump || typeof this.client.getMessageContext !== "function") return false;
1214
+ if (this.state.isInitialLoading || this.state.isLoadingOlder || this.state.isLoadingNewer || this.state.isReconciling) return false;
1215
+ const generation = this.generation;
1216
+ this.beginJump();
1217
+ this.patch({ isLoadingNewer: true, error: null, highlightedMessageId: null });
1218
+ try {
1219
+ const page = await this.client.getMessageContext(this.room, { messageId: id, limit: this.pageSize });
1220
+ if (!this.alive(generation)) return false;
1221
+ this.validateContextPage(page.messages, this.pageSize, id);
1222
+ this.olderContextCursor = page.olderCursor;
1223
+ this.newerContextCursor = page.newerCursor;
1224
+ this.jumpAnchor = id;
1225
+ this.patch({
1226
+ messages: mergeMessages([], page.messages.filter((message) => !this.deleted.has(message.id))),
1227
+ windowMode: "jumped",
1228
+ hasOlderMessages: page.olderCursor !== null,
1229
+ hasNewerMessages: page.newerCursor !== null
1230
+ });
1231
+ this.landJump(id);
1232
+ void this.resolvePreviews(true);
1233
+ return true;
1234
+ } catch (cause) {
1235
+ if (!this.alive(generation)) return false;
1236
+ this.releaseJump();
1237
+ if (isMessageMissing(cause)) {
1238
+ this.markUnavailable(id);
1239
+ return false;
1240
+ }
1241
+ if (isRouteMissing(cause)) this.retireJump();
1242
+ this.fail(cause, generation);
1243
+ return false;
1244
+ } finally {
1245
+ if (this.alive(generation)) {
1246
+ this.patch({ isLoadingNewer: false });
1247
+ this.flushRefresh();
1248
+ }
1249
+ }
1250
+ };
1251
+ /** Drop a jumped window and render the live tail again (0.9.0), through the normal newest-page load. There is
1252
+ * no in-place flip: `windowMode` becomes `live` BEFORE the request, so inserts arriving during the round trip
1253
+ * are folded in by `overlay` exactly as `loadInitial()` and `refresh()` already tolerate, and the rows
1254
+ * deferred while jumped are drained — and acknowledged — with it. A failure stays `jumped` with the window and
1255
+ * its affordance intact; the highlight is carried through so the target re-anchors when it is still in the
1256
+ * newest page. Resolves true once the live tail is rendered.
1257
+ */
1258
+ returnToLatest = async () => {
1259
+ if (!this.alive() || !this.state.hasLoaded) return false;
1260
+ if (this.state.windowMode !== "jumped") return true;
1261
+ if (this.state.isInitialLoading || this.state.isLoadingOlder || this.state.isLoadingNewer || this.state.isReconciling) return false;
1262
+ const generation = this.generation;
1263
+ const revision = this.revision;
1264
+ const highlighted = this.state.highlightedMessageId;
1265
+ clearTimeout(this.highlightTimer);
1266
+ this.highlightTimer = void 0;
1267
+ this.patch({ windowMode: "live", isLoadingNewer: true, error: null, highlightedMessageId: null });
1268
+ try {
1269
+ const page = await this.fetchPage();
1270
+ if (!this.alive(generation)) return false;
1271
+ this.validatePage(page);
1272
+ this.cursor = page.at(-1);
1273
+ this.olderContextCursor = null;
1274
+ this.newerContextCursor = null;
1275
+ this.jumpAnchor = void 0;
1276
+ this.patch({
1277
+ messages: this.overlay(page, revision),
1278
+ hasOlderMessages: page.length === this.pageSize,
1279
+ hasNewerMessages: false
1280
+ });
1281
+ this.prune(revision);
1282
+ const drained = [...this.deferred].some((id) => this.state.messages.some((message) => message.id === id && message.senderId !== this.user));
1283
+ this.deferred.clear();
1284
+ void this.resolvePreviews(true);
1285
+ if (drained && (this.options.markReadOnReceive ?? true) || this.ack.followUp) void this.acknowledge(true);
1286
+ if (highlighted && this.state.messages.some((message) => message.id === highlighted)) {
1287
+ this.beginJump();
1288
+ this.landJump(highlighted);
1289
+ }
1290
+ return true;
1291
+ } catch (cause) {
1292
+ if (!this.alive(generation)) return false;
1293
+ this.patch({
1294
+ windowMode: "jumped",
1295
+ hasNewerMessages: this.newerContextCursor !== null,
1296
+ highlightedMessageId: highlighted
1297
+ });
1298
+ this.fail(cause, generation, true);
1299
+ return false;
1300
+ } finally {
1301
+ if (this.alive(generation)) {
1302
+ this.patch({ isLoadingNewer: false });
1303
+ this.flushRefresh();
1304
+ }
1305
+ }
1306
+ };
1307
+ /** The guard the views read while a jump lands: it is set BEFORE the window is replaced, because shrinking the
1308
+ * list clamps `scrollTop` and emits a scroll event of its own.
1309
+ */
1310
+ beginJump() {
1311
+ clearTimeout(this.jumpTimer);
1312
+ this.jumpTimer = void 0;
1313
+ clearTimeout(this.highlightTimer);
1314
+ this.highlightTimer = void 0;
1315
+ if (!this.state.jumpInFlight) this.patch({ jumpInFlight: true });
1316
+ }
1317
+ /** The window is in place: highlight the target and release the guard on a timer, never on "the first scroll
1318
+ * event" — a target already in view produces none. The highlight's own timeout starts when the guard clears.
1319
+ */
1320
+ landJump(id) {
1321
+ this.patch({ highlightedMessageId: id });
1322
+ const generation = this.generation;
1323
+ const timer = setTimeout(() => {
1324
+ this.jumpTimer = void 0;
1325
+ if (!this.alive(generation)) return;
1326
+ this.patch({ jumpInFlight: false });
1327
+ const clearing = setTimeout(() => {
1328
+ this.highlightTimer = void 0;
1329
+ if (this.alive(generation) && this.state.highlightedMessageId === id) this.patch({ highlightedMessageId: null });
1330
+ }, HIGHLIGHT_MS);
1331
+ clearing.unref?.();
1332
+ this.highlightTimer = clearing;
1333
+ }, JUMP_GUARD_MS);
1334
+ timer.unref?.();
1335
+ this.jumpTimer = timer;
1336
+ }
1337
+ releaseJump() {
1338
+ clearTimeout(this.jumpTimer);
1339
+ this.jumpTimer = void 0;
1340
+ if (this.state.jumpInFlight) this.patch({ jumpInFlight: false });
1341
+ }
1342
+ /** Quote a rendered, confirmed message in the composer (0.9.0). A no-op for pending, tombstoned and unknown
1343
+ * rows and while the caller's role (when known) is `READ`; any member may quote any row, own or not. Replying
1344
+ * and editing are mutually exclusive, so this leaves edit mode. Sends nothing.
1345
+ */
1346
+ startReply = (messageId) => {
1347
+ if (!this.alive() || this.ownRole() === "READ") return;
1348
+ const row = this.state.messages.find((message) => message.id === messageId);
1349
+ if (!row || isConvoKitPendingMessage(row) || this.deleted.has(messageId)) return;
1350
+ this.patch({ replyTarget: row, editingMessage: null });
1351
+ };
1352
+ /** Drop the reply target without a request; the draft is untouched (replying never replaces it). */
1353
+ cancelReply = () => {
1354
+ if (this.state.replyTarget) this.patch({ replyTarget: null });
1355
+ };
739
1356
  /** Acknowledge through the newest rendered row now, regardless of visibility; no acknowledgement without a
740
1357
  * target (a room opened with a marker that renders nothing clears the marker instead, once).
741
1358
  */
@@ -765,6 +1382,7 @@ var ConversationStore = class {
765
1382
  }
766
1383
  /** Resolves when the request this call issued or joined settles; a follow-up is issued, not awaited. */
767
1384
  acknowledge(automatic) {
1385
+ if (this.state.windowMode === "jumped") return Promise.resolve();
768
1386
  const ack = this.ack;
769
1387
  if (automatic && (!this.visible || this.state.conversation === null)) {
770
1388
  ack.suppressed = true;
@@ -777,6 +1395,10 @@ var ConversationStore = class {
777
1395
  return this.issue(ack, this.generation) ?? Promise.resolve();
778
1396
  }
779
1397
  issue(ack, generation) {
1398
+ if (this.state.windowMode === "jumped") {
1399
+ ack.followUp = true;
1400
+ return void 0;
1401
+ }
780
1402
  ack.followUp = false;
781
1403
  const target = this.ackTarget(ack);
782
1404
  const request = target ? this.send(ack, generation, target) : this.clearMarker(ack, generation);
@@ -853,11 +1475,14 @@ var ConversationStore = class {
853
1475
  sendMessage = async ({ text, media }) => {
854
1476
  const normalized = text?.trim();
855
1477
  if (!this.alive() || this.state.isSending || !normalized && !media?.length) return null;
1478
+ if (this.state.windowMode === "jumped" && !await this.returnToLatest()) return null;
1479
+ if (!this.alive() || this.state.isSending) return null;
856
1480
  const generation = this.generation;
857
1481
  const revision = this.revision;
858
1482
  this.sendRevision = revision;
859
1483
  const clientMessageId = (0, import_sdk2.createClientMessageId)();
860
1484
  const pendingId = `convokit-pending-${clientMessageId}`;
1485
+ const replyToMessageId = this.state.replyTarget?.id;
861
1486
  const pending = {
862
1487
  id: pendingId,
863
1488
  clientMessageId,
@@ -866,7 +1491,9 @@ var ConversationStore = class {
866
1491
  text: normalized || null,
867
1492
  media: media ?? [],
868
1493
  createdAt: /* @__PURE__ */ new Date(),
869
- updatedAt: null
1494
+ updatedAt: null,
1495
+ revision: 0,
1496
+ ...replyToMessageId ? { replyToMessageId } : {}
870
1497
  };
871
1498
  const send = { pending };
872
1499
  this.activeSend = send;
@@ -876,7 +1503,9 @@ var ConversationStore = class {
876
1503
  conversationId: this.room,
877
1504
  clientMessageId,
878
1505
  ...normalized ? { text: normalized } : {},
879
- ...media?.length ? { media } : {}
1506
+ ...media?.length ? { media } : {},
1507
+ // Omitted entirely when there is no quote, so a plain send is byte-identical to 0.8.
1508
+ ...replyToMessageId ? { replyToMessageId } : {}
880
1509
  });
881
1510
  if (!this.alive(generation)) return null;
882
1511
  if (!this.validMessage(message) || message.senderId !== this.user) throw new Error("Send response belongs to a different room or sender");
@@ -886,10 +1515,15 @@ var ConversationStore = class {
886
1515
  let latest = existing ? newest(message, existing, live?.complete !== false) : message;
887
1516
  if (live && live.revision > revision) latest = newest(latest, live.message, live.complete);
888
1517
  if (!this.deleted.has(message.id)) this.changes.set(message.id, { revision: ++this.revision, message: latest, insert: true, complete: true });
889
- this.patch({ messages: mergeMessages(
890
- this.state.messages.filter((item) => item.id !== pendingId),
891
- this.deleted.has(message.id) ? [] : [latest]
892
- ) });
1518
+ this.patch({
1519
+ messages: mergeMessages(
1520
+ this.state.messages.filter((item) => item.id !== pendingId),
1521
+ this.deleted.has(message.id) ? [] : [latest]
1522
+ ),
1523
+ // The quote is spent: it is cleared only once the server accepted the send.
1524
+ ...replyToMessageId ? { replyTarget: null } : {}
1525
+ });
1526
+ if (replyToMessageId) void this.resolvePreviews();
893
1527
  void this.updateTyping(false);
894
1528
  return this.alive(generation) ? latest : null;
895
1529
  } catch (cause) {
@@ -910,6 +1544,132 @@ var ConversationStore = class {
910
1544
  }
911
1545
  }
912
1546
  };
1547
+ /** The caller's role in the open room when known (0.7 `membership`, else the caller's participant row). */
1548
+ ownRole() {
1549
+ const conversation = this.state.conversation;
1550
+ return conversation?.membership?.role ?? conversation?.participants.find((participant) => participant.appUserId === this.user || participant.id === this.user)?.role;
1551
+ }
1552
+ /** A rendered, confirmed row of the caller's own that is not known to be gone. */
1553
+ ownRow(messageId) {
1554
+ const row = this.state.messages.find((message) => message.id === messageId);
1555
+ return row && row.senderId === this.user && !isConvoKitPendingMessage(row) && !this.deleted.has(messageId) ? row : void 0;
1556
+ }
1557
+ /** Enter edit mode on one of the caller's own confirmed messages (0.8.0): the row as it stands now becomes the
1558
+ * snapshot whose `revision` every save sends. A no-op unless the adapter implements `editMessage`, the row is
1559
+ * rendered, own, confirmed, not tombstoned and the caller's role (when known) is not `READ`. Sends nothing.
1560
+ */
1561
+ startEditing = (messageId) => {
1562
+ if (!this.alive() || !this.support.edit || this.ownRole() === "READ") return;
1563
+ const row = this.ownRow(messageId);
1564
+ if (row) this.patch({ editingMessage: row, replyTarget: null });
1565
+ };
1566
+ /** Leave edit mode without a request; the draft is the view's to restore. */
1567
+ cancelEditing = () => {
1568
+ if (this.state.editingMessage) this.patch({ editingMessage: null });
1569
+ };
1570
+ /** Save the edit in progress with the snapshot's revision (never the live row's), trimming the text and sending
1571
+ * `null` for an empty caption. Resolves true when the server accepted the edit (the response is merged through the
1572
+ * tombstone and precedence guards and edit mode ends); false when nothing was saved: a stale revision (409
1573
+ * `REVISION_CONFLICT`) reloads the row once through `getMessage`, replaces the snapshot with it (the next save
1574
+ * carries the fresh revision) and reports the conflict through `error`, keeping edit mode; a coded 404
1575
+ * (`MESSAGE_NOT_FOUND`, on the save or on that reload) removes the row and ends edit mode; any other failure
1576
+ * (403, 500, network, an uncoded 404 from a 0.7 backend) is reported through `error` without evicting anything and
1577
+ * keeps edit mode; if a newer row for the message arrived during such a request, that row is then the local
1578
+ * conflict (`settleEditing`). A text-only message cannot be saved empty (no request). Rejects when the adapter
1579
+ * lacks `editMessage`.
1580
+ */
1581
+ saveEdit = async (text) => {
1582
+ const client = this.client;
1583
+ if (typeof client.editMessage !== "function") {
1584
+ throw new TypeError("This ConvoKitUiClient adapter does not implement editMessage (0.8)");
1585
+ }
1586
+ const snapshot = this.state.editingMessage;
1587
+ if (!this.alive() || !snapshot || this.activeEdit !== void 0) return false;
1588
+ const trimmed = text.trim();
1589
+ const normalized = trimmed === "" ? null : trimmed;
1590
+ if (normalized === null && snapshot.media.length === 0) return false;
1591
+ const generation = this.generation;
1592
+ const id = snapshot.id;
1593
+ this.activeEdit = id;
1594
+ this.patch({ error: null });
1595
+ try {
1596
+ const message = await client.editMessage(id, { text: normalized, revision: snapshot.revision });
1597
+ if (!this.alive(generation)) return false;
1598
+ if (!this.validMessage(message) || message.id !== id || message.senderId !== this.user) {
1599
+ throw new Error("Edit response belongs to a different message or sender");
1600
+ }
1601
+ if (this.state.editingMessage?.id === id) this.patch({ editingMessage: null });
1602
+ this.applyRow(message);
1603
+ return true;
1604
+ } catch (cause) {
1605
+ if (!this.alive(generation)) return false;
1606
+ if (isRevisionConflict(cause)) await this.reloadConflict(id, cause, generation);
1607
+ else if (isMessageMissing(cause)) {
1608
+ this.removeMessage(id);
1609
+ this.patch({ error: cause });
1610
+ } else this.fail(cause, generation);
1611
+ return false;
1612
+ } finally {
1613
+ if (this.alive(generation)) {
1614
+ this.activeEdit = void 0;
1615
+ this.settleEditing(id);
1616
+ }
1617
+ }
1618
+ };
1619
+ /** Merge a complete REST row for a known id through the live-row guards: a tombstoned id is dropped, and an older
1620
+ * revision (or timestamp) never overwrites the newer row already recorded. Recorded as a non-insert change, so a
1621
+ * reconcile keeps it only while the row is still in the fetched range.
1622
+ */
1623
+ applyRow(message) {
1624
+ if (this.deleted.has(message.id)) return;
1625
+ this.record(message, this.changes.get(message.id)?.insert === true, ++this.revision, true);
1626
+ }
1627
+ /** The 409 path: one `getMessage` shows the conflicting content. Its row is merged through the guards and becomes
1628
+ * the new snapshot; a `MESSAGE_NOT_FOUND` answer removes the row and ends edit mode; another failure keeps the
1629
+ * snapshot. `error` carries the conflict (or the reload failure).
1630
+ */
1631
+ async reloadConflict(id, conflict, generation) {
1632
+ try {
1633
+ const current = await this.client.getMessage(id);
1634
+ if (!this.alive(generation)) return;
1635
+ if (!this.validMessage(current) || current.id !== id) throw new Error("Complete message response does not match the edited message");
1636
+ this.applyRow(current);
1637
+ const row = this.state.messages.find((message) => message.id === id);
1638
+ const editing = this.state.editingMessage?.id === id && row ? { editingMessage: row } : {};
1639
+ this.patch({ ...editing, error: conflict });
1640
+ } catch (cause) {
1641
+ if (!this.alive(generation)) return;
1642
+ if (isTargetMiss(cause)) this.removeMessage(id);
1643
+ this.patch({ error: cause });
1644
+ }
1645
+ }
1646
+ /** Delete one of the caller's own confirmed messages (0.8.0). The row stays until the server answers: on success,
1647
+ * or when the server no longer knows it (`MESSAGE_NOT_FOUND`), it is tombstoned and removed (late responses, row
1648
+ * images and hydrations for it are dropped, the acknowledgement target is re-resolved and edit mode on it ends)
1649
+ * and the call resolves true; any other failure keeps the row, reports through `error` and resolves false. Rejects
1650
+ * when the adapter lacks `deleteMessage`.
1651
+ */
1652
+ deleteMessage = async (messageId) => {
1653
+ const client = this.client;
1654
+ if (typeof client.deleteMessage !== "function") {
1655
+ throw new TypeError("This ConvoKitUiClient adapter does not implement deleteMessage (0.8)");
1656
+ }
1657
+ if (!this.alive() || !this.ownRow(messageId)) return false;
1658
+ const generation = this.generation;
1659
+ this.patch({ error: null });
1660
+ try {
1661
+ await client.deleteMessage(messageId);
1662
+ } catch (cause) {
1663
+ if (!this.alive(generation)) return false;
1664
+ if (!isMessageMissing(cause)) {
1665
+ this.fail(cause, generation);
1666
+ return false;
1667
+ }
1668
+ }
1669
+ if (!this.alive(generation)) return false;
1670
+ this.removeMessage(messageId);
1671
+ return true;
1672
+ };
913
1673
  readerIdsFor = (message) => readerIdsFor(message, this.state.readAtByUserId, this.state.readPositionByUserId);
914
1674
  };
915
1675
 
@@ -963,11 +1723,32 @@ function useConversation(options) {
963
1723
  hasLoaded: field("hasLoaded"),
964
1724
  error: field("error"),
965
1725
  currentUserId: field("currentUserId"),
1726
+ editingMessage: field("editingMessage"),
1727
+ canEditMessages: field("canEditMessages"),
1728
+ canDeleteMessages: field("canDeleteMessages"),
1729
+ replyTarget: field("replyTarget"),
1730
+ replyPreviews: field("replyPreviews"),
1731
+ highlightedMessageId: field("highlightedMessageId"),
1732
+ jumpInFlight: field("jumpInFlight"),
1733
+ windowMode: field("windowMode"),
1734
+ hasNewerMessages: field("hasNewerMessages"),
1735
+ isLoadingNewer: field("isLoadingNewer"),
1736
+ canJumpToMessage: field("canJumpToMessage"),
1737
+ canResolveReplyPreviews: field("canResolveReplyPreviews"),
966
1738
  readerIdsFor: (message) => store.readerIdsFor(message),
967
1739
  loadInitial: () => store.loadInitial(),
968
1740
  refresh: () => store.refresh(),
969
1741
  loadOlderMessages: () => store.loadOlderMessages(),
970
1742
  sendMessage: (input) => store.sendMessage(input),
1743
+ startEditing: (messageId) => store.startEditing(messageId),
1744
+ cancelEditing: () => store.cancelEditing(),
1745
+ saveEdit: (text) => store.saveEdit(text),
1746
+ deleteMessage: (messageId) => store.deleteMessage(messageId),
1747
+ startReply: (messageId) => store.startReply(messageId),
1748
+ cancelReply: () => store.cancelReply(),
1749
+ jumpToMessage: (messageId) => store.jumpToMessage(messageId),
1750
+ loadNewerMessages: () => store.loadNewerMessages(),
1751
+ returnToLatest: () => store.returnToLatest(),
971
1752
  markRead: () => store.markRead(),
972
1753
  updateTyping: (isTyping) => store.updateTyping(isTyping),
973
1754
  setVisible: (value) => {
@@ -979,6 +1760,7 @@ function useConversation(options) {
979
1760
  }
980
1761
 
981
1762
  // src/components/message-list.ts
1763
+ var import_sdk3 = require("@convokitapp/sdk");
982
1764
  var import_vue4 = require("@lucide/vue");
983
1765
  var import_vue5 = require("vue");
984
1766
  var appearanceProps = {
@@ -1033,6 +1815,19 @@ var MessageListView = (0, import_vue5.defineComponent)({
1033
1815
  isLoadingOlder: { type: Boolean, default: false },
1034
1816
  error: { type: null, required: false },
1035
1817
  onAttachmentClick: { type: Function, default: void 0 },
1818
+ onEditMessage: { type: Function, default: void 0 },
1819
+ onDeleteMessage: { type: Function, default: void 0 },
1820
+ canEditMessage: { type: Function, default: void 0 },
1821
+ confirmDelete: { type: Function, default: void 0 },
1822
+ onReplyToMessage: { type: Function, default: void 0 },
1823
+ canReplyToMessage: { type: Function, default: void 0 },
1824
+ replyPreviewByMessageId: { type: Object, default: void 0 },
1825
+ onJumpToMessage: { type: Function, default: void 0 },
1826
+ highlightedMessageId: { type: String, default: null },
1827
+ jumpInFlight: { type: Boolean, default: false },
1828
+ hasNewerMessages: { type: Boolean, default: false },
1829
+ isLoadingNewer: { type: Boolean, default: false },
1830
+ onLoadNewer: { type: Function, default: void 0 },
1036
1831
  scrollElement: { type: Object, default: void 0 },
1037
1832
  paginationThreshold: { type: Number, default: 240 },
1038
1833
  reverse: { type: Boolean, default: true },
@@ -1040,12 +1835,18 @@ var MessageListView = (0, import_vue5.defineComponent)({
1040
1835
  formatTime: { type: Function, default: formatMessageTime },
1041
1836
  imageLoading: { type: String, default: "lazy" }
1042
1837
  },
1043
- emits: ["load-older", "attachment-click"],
1838
+ emits: ["load-older", "load-newer", "attachment-click", "edit-message", "delete-message", "reply-to-message", "jump-to-message"],
1044
1839
  setup(props, { attrs, emit, slots }) {
1045
1840
  const internalElement = (0, import_vue5.ref)(null);
1841
+ const confirming = (0, import_vue5.ref)(null);
1842
+ const highlightCleared = (0, import_vue5.ref)(false);
1046
1843
  let requestInFlight = false;
1047
1844
  let lastRequestedLength = null;
1845
+ let newerInFlight = false;
1846
+ let lastNewerLength = null;
1048
1847
  let previousMessageCount = 0;
1848
+ let scrolledTo = null;
1849
+ let jumpArmed = false;
1049
1850
  const participants = (0, import_vue5.computed)(() => new Map(props.conversation.participants.flatMap((participant) => [
1050
1851
  [participant.id, participant],
1051
1852
  [participant.appUserId, participant]
@@ -1068,13 +1869,26 @@ var MessageListView = (0, import_vue5.defineComponent)({
1068
1869
  requestInFlight = false;
1069
1870
  }
1070
1871
  };
1071
- (0, import_vue5.watch)(() => [props.messages.length, props.hasOlderMessages], async ([count, hasOlder]) => {
1872
+ const requestNewer = async () => {
1873
+ if (newerInFlight || lastNewerLength === props.messages.length || props.isLoadingNewer || !props.hasNewerMessages || !props.onLoadNewer) return;
1874
+ newerInFlight = true;
1875
+ lastNewerLength = props.messages.length;
1876
+ try {
1877
+ await props.onLoadNewer();
1878
+ } catch {
1879
+ lastNewerLength = null;
1880
+ } finally {
1881
+ newerInFlight = false;
1882
+ }
1883
+ };
1884
+ (0, import_vue5.watch)(() => [props.messages.length, props.hasOlderMessages, props.hasNewerMessages], async ([count, hasOlder, hasNewer]) => {
1072
1885
  const previous = previousMessageCount;
1073
1886
  if (count !== previousMessageCount || !hasOlder) lastRequestedLength = null;
1887
+ if (count !== previousMessageCount || !hasNewer) lastNewerLength = null;
1074
1888
  const appended = count > previous;
1075
1889
  const element = internalElement.value;
1076
1890
  previousMessageCount = count;
1077
- if (element && props.reverse && props.stickToBottom && appended) {
1891
+ if (element && props.reverse && props.stickToBottom && appended && !props.jumpInFlight) {
1078
1892
  const distanceFromBottom = element.scrollHeight - element.scrollTop - element.clientHeight;
1079
1893
  if (previous === 0 || distanceFromBottom < 320) {
1080
1894
  await (0, import_vue5.nextTick)();
@@ -1082,16 +1896,136 @@ var MessageListView = (0, import_vue5.defineComponent)({
1082
1896
  }
1083
1897
  }
1084
1898
  }, { flush: "post", immediate: true });
1899
+ (0, import_vue5.watch)(() => [props.highlightedMessageId, props.messages, props.jumpInFlight], ([highlighted, , inFlight]) => {
1900
+ if (inFlight && !jumpArmed) scrolledTo = null;
1901
+ jumpArmed = !!inFlight;
1902
+ if (!highlighted) {
1903
+ scrolledTo = null;
1904
+ highlightCleared.value = false;
1905
+ return;
1906
+ }
1907
+ if (scrolledTo === highlighted) return;
1908
+ const root = internalElement.value;
1909
+ const row = root ? [...root.querySelectorAll("[data-message-id]")].find((node) => node.dataset.messageId === highlighted) : void 0;
1910
+ if (!row) return;
1911
+ scrolledTo = highlighted;
1912
+ highlightCleared.value = false;
1913
+ row.scrollIntoView({ block: "center", behavior: "instant" });
1914
+ if (props.onJumpToMessage) row.focus({ preventScroll: true });
1915
+ }, { flush: "post" });
1916
+ const viewerRole = (0, import_vue5.computed)(() => props.conversation.membership?.role ?? props.conversation.participants.find((participant) => participant.appUserId === props.currentUserId || participant.id === props.currentUserId)?.role);
1917
+ const remove = async (message) => {
1918
+ if (props.confirmDelete && !await props.confirmDelete(message)) return false;
1919
+ return await props.onDeleteMessage?.(message) !== false;
1920
+ };
1921
+ const renderQuote = (parentId, preview, jump) => {
1922
+ const resolved = preview === void 0 || preview === "unavailable" ? void 0 : preview;
1923
+ const author = resolved ? participants.value.get(resolved.senderId)?.name || resolved.senderId : void 0;
1924
+ const attachments = resolved && resolved.mediaCount > 0 ? resolved.mediaCount === 1 ? "1 attachment" : `${resolved.mediaCount} attachments` : "";
1925
+ const body = resolved ? resolved.text?.trim() || attachments : preview === "unavailable" ? "Original message unavailable" : "";
1926
+ return (0, import_vue5.h)(jump ? "button" : "div", {
1927
+ class: cx("ckui-message-quote", preview === "unavailable" && "ckui-message-quote--unavailable"),
1928
+ "data-reply-to": parentId,
1929
+ "aria-label": resolved ? `Quoted message from ${author}` : preview === "unavailable" ? "Original message unavailable" : "Quoted message",
1930
+ ...jump ? { type: "button", onClick: jump } : {}
1931
+ }, [
1932
+ ...author ? [(0, import_vue5.h)("strong", { class: "ckui-message-quote__author" }, author)] : [],
1933
+ ...body ? [(0, import_vue5.h)("span", { class: "ckui-message-quote__body" }, body)] : []
1934
+ ]);
1935
+ };
1085
1936
  const renderMessage = (message, index) => {
1086
1937
  const isCurrentUser = message.senderId === props.currentUserId;
1087
1938
  const sender = participants.value.get(message.senderId);
1088
1939
  const isPending = isConvoKitPendingMessage(message);
1089
1940
  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 };
1941
+ const isEdited = !isPending && (0, import_sdk3.isEditedMessage)(message);
1942
+ const eligible = !isPending && (props.canEditMessage ? props.canEditMessage(message) : isCurrentUser && viewerRole.value !== "READ");
1943
+ const canEdit = eligible && !!props.onEditMessage;
1944
+ const canDelete = eligible && !!props.onDeleteMessage;
1945
+ const replyEligible = !isPending && (props.canReplyToMessage ? props.canReplyToMessage(message) : viewerRole.value !== "READ");
1946
+ const canReply = replyEligible && !!props.onReplyToMessage;
1947
+ const edit = () => {
1948
+ props.onEditMessage?.(message);
1949
+ };
1950
+ const replyTo = () => {
1951
+ props.onReplyToMessage?.(message);
1952
+ };
1953
+ const parentId = message.replyToMessageId;
1954
+ const replyPreview = parentId ? props.replyPreviewByMessageId?.get(parentId) : void 0;
1955
+ const jumpToReplyTarget = parentId && props.onJumpToMessage ? () => {
1956
+ props.onJumpToMessage?.(parentId);
1957
+ } : void 0;
1958
+ const slotProps = {
1959
+ message,
1960
+ chronologicalIndex: index,
1961
+ isCurrentUser,
1962
+ sender,
1963
+ readerIds,
1964
+ isEdited,
1965
+ canEdit,
1966
+ canDelete,
1967
+ canReply,
1968
+ ...canEdit ? { edit } : {},
1969
+ ...canDelete ? { remove: () => remove(message) } : {},
1970
+ ...canReply ? { reply: replyTo } : {},
1971
+ ...parentId && replyPreview !== void 0 ? { replyPreview } : {},
1972
+ ...jumpToReplyTarget ? { jumpToReplyTarget } : {}
1973
+ };
1974
+ const anchor = {
1975
+ "data-message-id": message.id,
1976
+ ...props.onJumpToMessage ? { tabindex: "-1" } : {}
1977
+ };
1978
+ const highlighted = !highlightCleared.value && props.highlightedMessageId === message.id;
1091
1979
  const custom = slots.message?.(slotProps);
1092
- if (custom) return (0, import_vue5.h)("div", { key: message.id, role: "listitem" }, custom);
1980
+ if (custom) {
1981
+ return (0, import_vue5.h)("div", {
1982
+ key: message.id,
1983
+ role: "listitem",
1984
+ ...anchor,
1985
+ ...highlighted ? { class: "ckui-message-highlight" } : {}
1986
+ }, custom);
1987
+ }
1093
1988
  const currentAppearance = appearance();
1094
1989
  const messagePart = isCurrentUser ? "outgoingMessage" : "incomingMessage";
1990
+ const iconButton = (label, onClick, icon) => (0, import_vue5.h)("button", {
1991
+ type: "button",
1992
+ "aria-label": label,
1993
+ onClick,
1994
+ class: partClass("button", currentAppearance, "ckui-icon-button"),
1995
+ style: partStyle("button", currentAppearance)
1996
+ }, [icon]);
1997
+ const actions = canReply || canEdit || canDelete ? (0, import_vue5.h)("div", { class: "ckui-message-actions" }, [
1998
+ ...canReply ? [iconButton("Reply to message", replyTo, (0, import_vue5.h)(import_vue4.Reply, { size: 16, "aria-hidden": "true" }))] : [],
1999
+ ...canEdit ? [iconButton("Edit message", edit, (0, import_vue5.h)(import_vue4.Pencil, { size: 16, "aria-hidden": "true" }))] : [],
2000
+ ...canDelete ? [iconButton("Delete message", () => {
2001
+ if (props.confirmDelete) void remove(message);
2002
+ else confirming.value = message.id;
2003
+ }, (0, import_vue5.h)(import_vue4.Trash2, { size: 16, "aria-hidden": "true" }))] : []
2004
+ ]) : null;
2005
+ const confirm = canDelete && confirming.value === message.id ? (0, import_vue5.h)("div", {
2006
+ class: "ckui-message-confirm",
2007
+ role: "group",
2008
+ "aria-label": "Delete this message?"
2009
+ }, [
2010
+ (0, import_vue5.h)("span", "Delete this message?"),
2011
+ (0, import_vue5.h)("button", {
2012
+ type: "button",
2013
+ class: "ckui-link-button",
2014
+ "aria-label": "Confirm delete",
2015
+ onClick: () => {
2016
+ confirming.value = null;
2017
+ void props.onDeleteMessage?.(message);
2018
+ }
2019
+ }, "Delete"),
2020
+ (0, import_vue5.h)("button", {
2021
+ type: "button",
2022
+ class: "ckui-link-button",
2023
+ "aria-label": "Cancel delete",
2024
+ onClick: () => {
2025
+ confirming.value = null;
2026
+ }
2027
+ }, "Cancel")
2028
+ ]) : null;
1095
2029
  const mediaNodes = message.media.map((media, mediaIndex) => {
1096
2030
  const open = props.onAttachmentClick ? () => {
1097
2031
  props.onAttachmentClick?.(media, message);
@@ -1104,26 +2038,33 @@ var MessageListView = (0, import_vue5.defineComponent)({
1104
2038
  }, slots.media?.(mediaSlotProps) ?? [defaultMedia(media, open, props.imageLoading)]);
1105
2039
  });
1106
2040
  const receiptSlotProps = { message, readerIds };
2041
+ const quote = parentId ? renderQuote(parentId, replyPreview, jumpToReplyTarget) : null;
1107
2042
  return (0, import_vue5.h)("div", { key: message.id, role: "listitem" }, [
1108
2043
  (0, import_vue5.h)("article", {
1109
2044
  class: cx(
1110
2045
  !props.unstyled && "ckui-message-row",
1111
2046
  isCurrentUser && !props.unstyled && "ckui-message-row--outgoing",
2047
+ highlighted && "ckui-message-highlight",
1112
2048
  props.classNames?.message,
1113
2049
  props.classNames?.[messagePart]
1114
2050
  ),
1115
2051
  style: [props.styles?.message, props.styles?.[messagePart]],
1116
- "data-message-id": message.id
2052
+ ...anchor
1117
2053
  }, [
2054
+ // Spread, not null: an absent action/label/prompt must not leave a comment node (0.7 markup stays byte-identical).
2055
+ ...actions ? [actions] : [],
1118
2056
  (0, import_vue5.h)("div", { class: "ckui-message-bubble" }, [
1119
2057
  !isCurrentUser ? (0, import_vue5.h)("strong", { class: "ckui-message-sender" }, sender?.name || message.senderId) : null,
2058
+ ...quote ? [quote] : [],
1120
2059
  message.text ? (0, import_vue5.h)("div", { class: "ckui-message-text" }, message.text) : null,
1121
2060
  ...mediaNodes,
1122
2061
  (0, import_vue5.h)("span", { class: "ckui-message-time" }, [
1123
2062
  isPending ? "Sending\u2026" : props.formatTime(message.createdAt),
2063
+ ...isEdited ? [(0, import_vue5.h)("span", { class: "ckui-message-edited", "aria-label": "Edited" }, "Edited")] : [],
1124
2064
  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
2065
  ])
1126
2066
  ]),
2067
+ ...confirm ? [confirm] : [],
1127
2068
  isCurrentUser && !isPending ? slots["read-receipt"]?.(receiptSlotProps) ?? (0, import_vue5.h)("div", {
1128
2069
  class: partClass("receipt", currentAppearance, "ckui-read-receipt"),
1129
2070
  style: partStyle("receipt", currentAppearance)
@@ -1162,6 +2103,13 @@ var MessageListView = (0, import_vue5.defineComponent)({
1162
2103
  } else {
1163
2104
  children.push(...props.messages.map(renderMessage));
1164
2105
  }
2106
+ if (props.isLoadingNewer) {
2107
+ children.push(slots["loading-newer"]?.() ?? (0, import_vue5.h)("div", {
2108
+ class: partClass("loading", currentAppearance, "ckui-inline-state"),
2109
+ style: partStyle("loading", currentAppearance),
2110
+ role: "status"
2111
+ }, [(0, import_vue5.h)(import_vue4.LoaderCircle, { class: "ckui-spin", "aria-hidden": "true" }), " Loading newer messages\u2026"]));
2112
+ }
1165
2113
  return (0, import_vue5.h)("div", {
1166
2114
  ...attrs,
1167
2115
  ref: (element) => {
@@ -1174,12 +2122,18 @@ var MessageListView = (0, import_vue5.defineComponent)({
1174
2122
  role: "log",
1175
2123
  "aria-live": "polite",
1176
2124
  "aria-label": `Messages in ${props.conversation.displayTitle}`,
2125
+ // Omitted entirely while idle, never `aria-busy="false"`: the 0.7 markup of this element is pinned.
2126
+ ...props.jumpInFlight ? { "aria-busy": "true" } : {},
1177
2127
  onScroll: (event) => {
1178
2128
  const nativeHandler = attrs.onScroll;
1179
2129
  if (typeof nativeHandler === "function") nativeHandler(event);
2130
+ if (props.jumpInFlight) return;
2131
+ if (props.highlightedMessageId && !highlightCleared.value) highlightCleared.value = true;
1180
2132
  const element = event.currentTarget;
1181
2133
  const distanceFromOldest = props.reverse ? element.scrollTop : element.scrollHeight - element.scrollTop - element.clientHeight;
1182
2134
  if (distanceFromOldest <= props.paginationThreshold) void requestOlder();
2135
+ const distanceFromNewest = props.reverse ? element.scrollHeight - element.scrollTop - element.clientHeight : element.scrollTop;
2136
+ if (distanceFromNewest <= props.paginationThreshold) void requestNewer();
1183
2137
  }
1184
2138
  }, children);
1185
2139
  };
@@ -1212,6 +2166,25 @@ var viewProps = {
1212
2166
  onTypingChange: { type: Function, default: void 0 },
1213
2167
  onAddAttachment: { type: Function, default: void 0 },
1214
2168
  onAttachmentClick: { type: Function, default: void 0 },
2169
+ editingMessage: { type: Object, default: null },
2170
+ onEditMessage: { type: Function, default: void 0 },
2171
+ onSaveEdit: { type: Function, default: void 0 },
2172
+ onCancelEdit: { type: Function, default: void 0 },
2173
+ onDeleteMessage: { type: Function, default: void 0 },
2174
+ canEditMessage: { type: Function, default: void 0 },
2175
+ confirmDelete: { type: Function, default: void 0 },
2176
+ replyTarget: { type: Object, default: null },
2177
+ onReplyToMessage: { type: Function, default: void 0 },
2178
+ onCancelReply: { type: Function, default: void 0 },
2179
+ canReplyToMessage: { type: Function, default: void 0 },
2180
+ replyPreviewByMessageId: { type: Object, default: void 0 },
2181
+ onJumpToMessage: { type: Function, default: void 0 },
2182
+ highlightedMessageId: { type: String, default: null },
2183
+ jumpInFlight: { type: Boolean, default: false },
2184
+ hasNewerMessages: { type: Boolean, default: false },
2185
+ isLoadingNewer: { type: Boolean, default: false },
2186
+ onLoadNewer: { type: Function, default: void 0 },
2187
+ onReturnToLatest: { type: Function, default: void 0 },
1215
2188
  isInitialLoading: { type: Boolean, default: false },
1216
2189
  isLoadingOlder: { type: Boolean, default: false },
1217
2190
  isSending: { type: Boolean, default: false },
@@ -1231,6 +2204,9 @@ var viewProps = {
1231
2204
  defaultDraft: { type: String, default: "" },
1232
2205
  onDraftChange: { type: Function, default: void 0 }
1233
2206
  };
2207
+ function messageSummary(message) {
2208
+ return message.text?.trim() || (message.media.length === 1 ? "1 attachment" : `${message.media.length} attachments`);
2209
+ }
1234
2210
  function typingLabel(userIds, displayNameForUser) {
1235
2211
  const names = [...userIds].map(displayNameForUser);
1236
2212
  if (names.length === 0) return "";
@@ -1250,7 +2226,16 @@ var ConversationView = (0, import_vue7.defineComponent)({
1250
2226
  "load-older",
1251
2227
  "add-attachment",
1252
2228
  "attachment-click",
1253
- "update:modelValue"
2229
+ "update:modelValue",
2230
+ "edit-message",
2231
+ "save-edit",
2232
+ "cancel-edit",
2233
+ "delete-message",
2234
+ "reply-to-message",
2235
+ "cancel-reply",
2236
+ "jump-to-message",
2237
+ "load-newer",
2238
+ "return-to-latest"
1254
2239
  ],
1255
2240
  setup(props, { attrs, emit, slots }) {
1256
2241
  const internalDraft = (0, import_vue7.ref)(props.defaultDraft);
@@ -1263,15 +2248,61 @@ var ConversationView = (0, import_vue7.defineComponent)({
1263
2248
  });
1264
2249
  const draft = () => props.modelValue ?? internalDraft.value;
1265
2250
  let latestDraft = draft();
1266
- const setDraft = (value) => {
2251
+ let stash;
2252
+ let saving = false;
2253
+ const setDraft = (value, typing = true) => {
1267
2254
  latestDraft = value;
1268
2255
  if (props.modelValue === void 0) internalDraft.value = value;
1269
2256
  props.onDraftChange?.(value);
1270
2257
  emit("update:modelValue", value);
1271
- const isTyping = value.trim().length > 0;
1272
- void props.onTypingChange?.(isTyping);
2258
+ if (typing) void props.onTypingChange?.(value.trim().length > 0);
2259
+ };
2260
+ const enterEdit = (message) => {
2261
+ if (stash === void 0) stash = draft();
2262
+ setDraft(message.text ?? "", false);
2263
+ };
2264
+ const leaveEdit = (message, restore) => {
2265
+ if (stash === void 0) return;
2266
+ const saved = stash;
2267
+ stash = void 0;
2268
+ const current = draft();
2269
+ if (restore === "always" || current.trim() === "" || current === (message.text ?? "")) setDraft(saved);
1273
2270
  };
2271
+ (0, import_vue7.watch)(() => props.editingMessage, (next, previous) => {
2272
+ if (next && (!previous || previous.id !== next.id)) enterEdit(next);
2273
+ else if (!next && previous && !saving) leaveEdit(previous, "unchanged");
2274
+ }, { immediate: true });
2275
+ const cancelEdit = () => {
2276
+ const editing = props.editingMessage;
2277
+ if (!editing) return;
2278
+ leaveEdit(editing, "always");
2279
+ props.onCancelEdit?.();
2280
+ };
2281
+ const cancelReply = () => {
2282
+ props.onCancelReply?.();
2283
+ };
2284
+ const returnToLatest = () => props.onReturnToLatest?.();
2285
+ const canSave = (editing) => draft().trim().length > 0 || editing.media.length > 0;
1274
2286
  const submit = async () => {
2287
+ const editing = props.editingMessage;
2288
+ if (editing) {
2289
+ const text2 = draft().trim();
2290
+ if (!canSave(editing) || props.isSending || submitting.value) return;
2291
+ submitting.value = true;
2292
+ saving = true;
2293
+ try {
2294
+ const saved = await props.onSaveEdit?.(editing, text2);
2295
+ if (saved === false) {
2296
+ if (props.editingMessage === null) leaveEdit(editing, "unchanged");
2297
+ return;
2298
+ }
2299
+ leaveEdit(editing, "always");
2300
+ } finally {
2301
+ saving = false;
2302
+ submitting.value = false;
2303
+ }
2304
+ return;
2305
+ }
1275
2306
  const originalDraft = draft();
1276
2307
  const text = originalDraft.trim();
1277
2308
  if (!text || props.isSending || submitting.value) return;
@@ -1295,6 +2326,7 @@ var ConversationView = (0, import_vue7.defineComponent)({
1295
2326
  };
1296
2327
  const refresh = () => props.onRefresh?.();
1297
2328
  const loadOlder = () => props.onLoadOlder?.();
2329
+ const loadNewer = () => props.onLoadNewer?.();
1298
2330
  const addAttachment = () => {
1299
2331
  props.onAddAttachment?.();
1300
2332
  };
@@ -1340,6 +2372,8 @@ var ConversationView = (0, import_vue7.defineComponent)({
1340
2372
  }, typingLabel(props.typingUserIds, nameForUser));
1341
2373
  };
1342
2374
  const renderComposer = () => {
2375
+ const editing = props.editingMessage;
2376
+ const replying = editing ? null : props.replyTarget;
1343
2377
  const slotProps = {
1344
2378
  value: draft(),
1345
2379
  setValue: setDraft,
@@ -1347,16 +2381,40 @@ var ConversationView = (0, import_vue7.defineComponent)({
1347
2381
  send: () => {
1348
2382
  void submit();
1349
2383
  },
1350
- ...props.onAddAttachment ? { addAttachment } : {}
2384
+ ...props.onAddAttachment ? { addAttachment } : {},
2385
+ ...editing ? { editing, cancelEdit } : {},
2386
+ ...replying ? { replying, cancelReply } : {}
1351
2387
  };
2388
+ const busy = props.isSending || submitting.value;
1352
2389
  return slots.composer?.(slotProps) ?? (0, import_vue7.h)("form", {
1353
- class: partClass("composer", appearance(), "ckui-composer"),
2390
+ class: cx(
2391
+ partClass("composer", appearance(), "ckui-composer"),
2392
+ editing && !props.unstyled && "ckui-composer--editing",
2393
+ replying && !props.unstyled && "ckui-composer--replying"
2394
+ ),
1354
2395
  style: partStyle("composer", appearance()),
1355
2396
  onSubmit: (event) => {
1356
2397
  event.preventDefault();
1357
2398
  void submit();
1358
2399
  }
1359
2400
  }, [
2401
+ // Spread, not null: outside edit mode the composer markup stays byte-identical to 0.7.
2402
+ ...editing ? [(0, import_vue7.h)("div", { class: "ckui-composer__editing", role: "status" }, [
2403
+ (0, import_vue7.h)(import_vue6.Pencil, { size: 14, "aria-hidden": "true" }),
2404
+ (0, import_vue7.h)("span", { class: "ckui-composer__editing-body" }, [
2405
+ (0, import_vue7.h)("strong", "Editing message"),
2406
+ (0, import_vue7.h)("span", messageSummary(editing))
2407
+ ]),
2408
+ (0, import_vue7.h)("button", { type: "button", class: "ckui-link-button", "aria-label": "Cancel editing", onClick: cancelEdit }, "Cancel")
2409
+ ])] : [],
2410
+ ...replying ? [(0, import_vue7.h)("div", { class: "ckui-composer__replying", role: "status" }, [
2411
+ (0, import_vue7.h)(import_vue6.Reply, { size: 14, "aria-hidden": "true" }),
2412
+ (0, import_vue7.h)("span", { class: "ckui-composer__replying-body" }, [
2413
+ (0, import_vue7.h)("strong", `Replying to ${nameForUser(replying.senderId)}`),
2414
+ (0, import_vue7.h)("span", messageSummary(replying))
2415
+ ]),
2416
+ (0, import_vue7.h)("button", { type: "button", class: "ckui-link-button", "aria-label": "Cancel reply", onClick: cancelReply }, "Cancel")
2417
+ ])] : [],
1360
2418
  props.onAddAttachment ? (0, import_vue7.h)("button", {
1361
2419
  type: "button",
1362
2420
  "aria-label": "Add attachment",
@@ -1385,15 +2443,22 @@ var ConversationView = (0, import_vue7.defineComponent)({
1385
2443
  event.preventDefault();
1386
2444
  void submit();
1387
2445
  }
2446
+ if (event.key === "Escape" && props.editingMessage) {
2447
+ event.preventDefault();
2448
+ cancelEdit();
2449
+ } else if (event.key === "Escape" && props.replyTarget) {
2450
+ event.preventDefault();
2451
+ cancelReply();
2452
+ }
1388
2453
  }
1389
2454
  }),
1390
2455
  (0, import_vue7.h)("button", {
1391
2456
  type: "submit",
1392
- "aria-label": "Send message",
1393
- disabled: !draft().trim() || props.isSending || submitting.value,
2457
+ "aria-label": editing ? "Save message" : "Send message",
2458
+ disabled: (editing ? !canSave(editing) : !draft().trim()) || busy,
1394
2459
  class: partClass("button", appearance(), "ckui-send-button"),
1395
2460
  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" })])
2461
+ }, [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
2462
  ]);
1398
2463
  };
1399
2464
  return () => {
@@ -1426,6 +2491,7 @@ var ConversationView = (0, import_vue7.defineComponent)({
1426
2491
  ...slots["read-receipt"] ? { "read-receipt": slots["read-receipt"] } : {},
1427
2492
  ...slots.empty ? { empty: slots.empty } : {},
1428
2493
  ...slots["loading-older"] ? { "loading-older": slots["loading-older"] } : {},
2494
+ ...slots["loading-newer"] ? { "loading-newer": slots["loading-newer"] } : {},
1429
2495
  ...slots["message-error"] ? { error: slots["message-error"] } : {}
1430
2496
  };
1431
2497
  children.push((0, import_vue7.h)(MessageListView, {
@@ -1442,6 +2508,25 @@ var ConversationView = (0, import_vue7.defineComponent)({
1442
2508
  ...props.onAttachmentClick ? { onAttachmentClick: (media, message) => {
1443
2509
  props.onAttachmentClick?.(media, message);
1444
2510
  } } : {},
2511
+ ...props.onEditMessage ? { onEditMessage: (message) => {
2512
+ props.onEditMessage?.(message);
2513
+ } } : {},
2514
+ ...props.onDeleteMessage ? { onDeleteMessage: (message) => props.onDeleteMessage?.(message) } : {},
2515
+ ...props.canEditMessage ? { canEditMessage: props.canEditMessage } : {},
2516
+ ...props.confirmDelete ? { confirmDelete: props.confirmDelete } : {},
2517
+ ...props.onReplyToMessage ? { onReplyToMessage: (message) => {
2518
+ props.onReplyToMessage?.(message);
2519
+ } } : {},
2520
+ ...props.canReplyToMessage ? { canReplyToMessage: props.canReplyToMessage } : {},
2521
+ ...props.replyPreviewByMessageId ? { replyPreviewByMessageId: props.replyPreviewByMessageId } : {},
2522
+ ...props.onJumpToMessage ? { onJumpToMessage: (messageId) => {
2523
+ props.onJumpToMessage?.(messageId);
2524
+ } } : {},
2525
+ highlightedMessageId: props.highlightedMessageId,
2526
+ jumpInFlight: props.jumpInFlight,
2527
+ hasNewerMessages: props.hasNewerMessages,
2528
+ isLoadingNewer: props.isLoadingNewer,
2529
+ ...props.onLoadNewer ? { onLoadNewer: loadNewer } : {},
1445
2530
  reverse: props.reverseMessages,
1446
2531
  stickToBottom: props.stickToBottom,
1447
2532
  paginationThreshold: props.paginationThreshold,
@@ -1452,6 +2537,19 @@ var ConversationView = (0, import_vue7.defineComponent)({
1452
2537
  density: props.density,
1453
2538
  unstyled: props.unstyled
1454
2539
  }, messageSlots));
2540
+ if (props.onReturnToLatest) {
2541
+ const jumpSlotProps = { returnToLatest };
2542
+ children.push(slots["jump-to-latest"]?.(jumpSlotProps) ?? (0, import_vue7.h)("div", { class: "ckui-conversation-jump" }, [
2543
+ (0, import_vue7.h)("button", {
2544
+ type: "button",
2545
+ class: "ckui-link-button",
2546
+ "aria-label": "Jump to latest messages",
2547
+ onClick: () => {
2548
+ void returnToLatest();
2549
+ }
2550
+ }, [(0, import_vue7.h)(import_vue6.ArrowDown, { size: 14, "aria-hidden": "true" }), " Jump to latest"])
2551
+ ]));
2552
+ }
1455
2553
  children.push(renderTyping(), renderComposer());
1456
2554
  return (0, import_vue7.h)("section", {
1457
2555
  ...attrs,
@@ -1472,6 +2570,15 @@ var Conversation = (0, import_vue7.defineComponent)({
1472
2570
  messages: { type: Array, default: () => [] },
1473
2571
  currentUserId: { type: String, default: "" },
1474
2572
  onSendMessage: { type: Function, default: void 0 },
2573
+ editingMessage: { type: Object, default: void 0 },
2574
+ onEditMessage: { type: Function, default: void 0 },
2575
+ onSaveEdit: { type: Function, default: void 0 },
2576
+ onCancelEdit: { type: Function, default: void 0 },
2577
+ onDeleteMessage: { type: Function, default: void 0 },
2578
+ replyTarget: { type: Object, default: void 0 },
2579
+ onReplyToMessage: { type: Function, default: void 0 },
2580
+ onCancelReply: { type: Function, default: void 0 },
2581
+ onJumpToMessage: { type: Function, default: void 0 },
1475
2582
  client: { type: Object, required: true },
1476
2583
  conversationId: { type: String, required: true },
1477
2584
  messagePageSize: { type: Number, default: 30 },
@@ -1481,7 +2588,26 @@ var Conversation = (0, import_vue7.defineComponent)({
1481
2588
  autoLoad: { type: Boolean, default: true },
1482
2589
  onControllerChange: { type: Function, default: void 0 }
1483
2590
  },
1484
- emits: ["controller-change", "send-message", "typing-change", "back", "refresh", "load-older", "add-attachment", "attachment-click", "update:modelValue"],
2591
+ emits: [
2592
+ "controller-change",
2593
+ "send-message",
2594
+ "typing-change",
2595
+ "back",
2596
+ "refresh",
2597
+ "load-older",
2598
+ "add-attachment",
2599
+ "attachment-click",
2600
+ "update:modelValue",
2601
+ "edit-message",
2602
+ "save-edit",
2603
+ "cancel-edit",
2604
+ "delete-message",
2605
+ "reply-to-message",
2606
+ "cancel-reply",
2607
+ "jump-to-message",
2608
+ "load-newer",
2609
+ "return-to-latest"
2610
+ ],
1485
2611
  setup(props, { attrs, emit, expose, slots }) {
1486
2612
  const controller = useConversation({
1487
2613
  client: () => props.client,
@@ -1545,6 +2671,22 @@ var Conversation = (0, import_vue7.defineComponent)({
1545
2671
  isSending: _isSending,
1546
2672
  hasOlderMessages: _hasOlderMessages,
1547
2673
  error: _error,
2674
+ editingMessage: _editingMessage,
2675
+ onEditMessage: _onEditMessage,
2676
+ onSaveEdit: _onSaveEdit,
2677
+ onCancelEdit: _onCancelEdit,
2678
+ onDeleteMessage: _onDeleteMessage,
2679
+ replyTarget: _replyTarget,
2680
+ onReplyToMessage: _onReplyToMessage,
2681
+ onCancelReply: _onCancelReply,
2682
+ replyPreviewByMessageId: _replyPreviewByMessageId,
2683
+ onJumpToMessage: _onJumpToMessage,
2684
+ highlightedMessageId: _highlightedMessageId,
2685
+ jumpInFlight: _jumpInFlight,
2686
+ hasNewerMessages: _hasNewerMessages,
2687
+ isLoadingNewer: _isLoadingNewer,
2688
+ onLoadNewer: _onLoadNewer,
2689
+ onReturnToLatest: _onReturnToLatest,
1548
2690
  ...forwarded
1549
2691
  } = props;
1550
2692
  return (0, import_vue7.h)(ConversationView, {
@@ -1568,6 +2710,61 @@ var Conversation = (0, import_vue7.defineComponent)({
1568
2710
  isSending: controller.isSending.value,
1569
2711
  hasOlderMessages: controller.hasOlderMessages.value,
1570
2712
  ...controller.error.value == null ? {} : { error: controller.error.value },
2713
+ // Edit mode is the store's; the actions render only while the adapter supports them (0.8.0).
2714
+ editingMessage: controller.editingMessage.value,
2715
+ ...controller.canEditMessages.value ? {
2716
+ onEditMessage: (message) => {
2717
+ emit("edit-message", message);
2718
+ controller.startEditing(message.id);
2719
+ },
2720
+ onSaveEdit: async (message, text) => {
2721
+ emit("save-edit", message, text);
2722
+ return controller.saveEdit(text);
2723
+ },
2724
+ onCancelEdit: () => {
2725
+ emit("cancel-edit");
2726
+ controller.cancelEditing();
2727
+ }
2728
+ } : {},
2729
+ ...controller.canDeleteMessages.value ? {
2730
+ onDeleteMessage: async (message) => {
2731
+ emit("delete-message", message);
2732
+ return controller.deleteMessage(message.id);
2733
+ }
2734
+ } : {},
2735
+ // Quoted replies and jump windows are the store's (0.9.0). Replying needs no adapter member; the jump
2736
+ // affordances appear only while the adapter can fetch a context window, and disappear for the store's
2737
+ // life against a backend that does not serve the route.
2738
+ replyTarget: controller.replyTarget.value,
2739
+ onReplyToMessage: (message) => {
2740
+ emit("reply-to-message", message);
2741
+ controller.startReply(message.id);
2742
+ },
2743
+ onCancelReply: () => {
2744
+ emit("cancel-reply");
2745
+ controller.cancelReply();
2746
+ },
2747
+ replyPreviewByMessageId: controller.replyPreviews.value,
2748
+ highlightedMessageId: controller.highlightedMessageId.value,
2749
+ jumpInFlight: controller.jumpInFlight.value,
2750
+ hasNewerMessages: controller.hasNewerMessages.value,
2751
+ isLoadingNewer: controller.isLoadingNewer.value,
2752
+ ...controller.canJumpToMessage.value ? {
2753
+ onJumpToMessage: (messageId) => {
2754
+ emit("jump-to-message", messageId);
2755
+ void controller.jumpToMessage(messageId);
2756
+ }
2757
+ } : {},
2758
+ ...controller.windowMode.value === "jumped" ? {
2759
+ onLoadNewer: () => {
2760
+ emit("load-newer");
2761
+ return controller.loadNewerMessages();
2762
+ },
2763
+ onReturnToLatest: () => {
2764
+ emit("return-to-latest");
2765
+ return controller.returnToLatest();
2766
+ }
2767
+ } : {},
1571
2768
  "onUpdate:modelValue": (value) => emit("update:modelValue", value),
1572
2769
  ...props.onBack ? { onBack: () => {
1573
2770
  props.onBack?.();
@@ -2393,6 +3590,7 @@ var defaultConvoKitTheme = {
2393
3590
  outgoingBubble: "#18181b",
2394
3591
  outgoingText: "#fafafa",
2395
3592
  badge: "#18181b",
3593
+ highlight: "color-mix(in srgb, #18181b 14%, transparent)",
2396
3594
  radius: "10px",
2397
3595
  avatarSize: "40px",
2398
3596
  fontFamily: 'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'
@@ -2425,6 +3623,7 @@ var ConvoKitThemeProvider = (0, import_vue11.defineComponent)({
2425
3623
  "--ckui-outgoing": theme.outgoingBubble,
2426
3624
  "--ckui-outgoing-text": theme.outgoingText,
2427
3625
  "--ckui-badge": theme.badge,
3626
+ "--ckui-highlight": theme.highlight,
2428
3627
  "--ckui-radius": theme.radius,
2429
3628
  "--ckui-avatar-size": theme.avatarSize,
2430
3629
  "--ckui-font": theme.fontFamily