@convokitapp/vue-ui 0.8.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.js CHANGED
@@ -28,6 +28,8 @@ function createConvoKitUiClient(client) {
28
28
  sendMessage: (input) => client.sendMessage(input),
29
29
  editMessage: (messageId, input) => client.editMessage(messageId, input),
30
30
  deleteMessage: (messageId) => client.deleteMessage(messageId),
31
+ getReplyPreviews: (conversationId, messageIds) => client.getReplyPreviews(conversationId, messageIds),
32
+ getMessageContext: (conversationId, options) => client.getMessageContext(conversationId, options),
31
33
  markConversationRead: (conversationId, options) => client.markConversationRead(conversationId, options),
32
34
  markConversationUnread: (conversationId) => client.markConversationUnread(conversationId),
33
35
  clearConversationUnread: (conversationId, options) => client.clearConversationUnread(conversationId, options),
@@ -188,7 +190,7 @@ var ConvoKitAvatar = defineComponent({
188
190
  });
189
191
 
190
192
  // src/components/conversation.ts
191
- import { ArrowLeft, Check as Check2, LoaderCircle as LoaderCircle2, Paperclip, Pencil as Pencil2, RefreshCw, Send } from "@lucide/vue";
193
+ import { ArrowDown, ArrowLeft, Check as Check2, LoaderCircle as LoaderCircle2, Paperclip, Pencil as Pencil2, Reply as Reply2, RefreshCw, Send } from "@lucide/vue";
192
194
  import {
193
195
  defineComponent as defineComponent3,
194
196
  h as h3,
@@ -234,6 +236,8 @@ function isMessageMissing(cause) {
234
236
  function localConflict() {
235
237
  return Object.assign(new Error("Message was changed since it was loaded"), { code: "REVISION_CONFLICT" });
236
238
  }
239
+ var JUMP_GUARD_MS = 150;
240
+ var HIGHLIGHT_MS = 2e3;
237
241
  var compare = compareMessageOrder;
238
242
  function positionCursor(position) {
239
243
  return { createdAt: position.createdAt, id: position.messageId };
@@ -253,7 +257,26 @@ function isTargetMiss(cause) {
253
257
  const { code, status } = cause;
254
258
  return code === "MESSAGE_NOT_FOUND" || code === void 0 && status === 404;
255
259
  }
256
- function blank(currentUserId = "", support = { edit: false, delete: false }) {
260
+ function isRouteMissing(cause) {
261
+ if (typeof cause !== "object" || cause === null) return false;
262
+ const { code, status } = cause;
263
+ return status === 404 && code !== "MESSAGE_NOT_FOUND";
264
+ }
265
+ var REPLY_PREVIEW_TEXT_LIMIT = 500;
266
+ function previewOf(message) {
267
+ const text = message.text;
268
+ return {
269
+ id: message.id,
270
+ conversationId: message.conversationId,
271
+ senderId: message.senderId,
272
+ text: text === null ? null : text.slice(0, REPLY_PREVIEW_TEXT_LIMIT),
273
+ textTruncated: (text?.length ?? 0) > REPLY_PREVIEW_TEXT_LIMIT,
274
+ createdAt: message.createdAt,
275
+ revision: message.revision,
276
+ mediaCount: message.media.length
277
+ };
278
+ }
279
+ function blank(currentUserId = "", support = { edit: false, delete: false, jump: false, previews: false }) {
257
280
  return {
258
281
  conversation: null,
259
282
  messages: [],
@@ -270,7 +293,16 @@ function blank(currentUserId = "", support = { edit: false, delete: false }) {
270
293
  currentUserId,
271
294
  editingMessage: null,
272
295
  canEditMessages: support.edit,
273
- canDeleteMessages: support.delete
296
+ canDeleteMessages: support.delete,
297
+ replyTarget: null,
298
+ replyPreviews: /* @__PURE__ */ new Map(),
299
+ highlightedMessageId: null,
300
+ jumpInFlight: false,
301
+ windowMode: "live",
302
+ hasNewerMessages: false,
303
+ isLoadingNewer: false,
304
+ canJumpToMessage: support.jump,
305
+ canResolveReplyPreviews: support.previews
274
306
  };
275
307
  }
276
308
  var ConversationStore = class {
@@ -289,7 +321,12 @@ var ConversationStore = class {
289
321
  }
290
322
  this.owner = this.client.sessionIdentity;
291
323
  this.user = this.owner ? this.client.currentUserId : "";
292
- this.support = { edit: typeof this.client.editMessage === "function", delete: typeof this.client.deleteMessage === "function" };
324
+ this.support = {
325
+ edit: typeof this.client.editMessage === "function",
326
+ delete: typeof this.client.deleteMessage === "function",
327
+ jump: typeof this.client.getMessageContext === "function",
328
+ previews: typeof this.client.getReplyPreviews === "function"
329
+ };
293
330
  this.state = blank(this.user, this.support);
294
331
  }
295
332
  options;
@@ -325,6 +362,26 @@ var ConversationStore = class {
325
362
  */
326
363
  activeEdit;
327
364
  refreshQueued = false;
365
+ /** A reconcile owed to the JUMPED window (0.9.0). `refreshQueued` stays owed across a jump so the tail-anchored
366
+ * reconcile still runs on the return to `live`; this flag is the one a jumped window consumes.
367
+ */
368
+ jumpedRefreshQueued = false;
369
+ /** The jumped window's paging cursors (0.9.0), opaque and server-minted; null at that end of the history. */
370
+ olderContextCursor = null;
371
+ newerContextCursor = null;
372
+ /** Preview entries that must be re-read on the next batch: a quoted parent changed outside the window, or the
373
+ * subscription reconnected (0.9.0). `'unavailable'` is terminal and never enters this set.
374
+ */
375
+ stalePreviews = /* @__PURE__ */ new Set();
376
+ /** Ids a batch is already asking for (0.9.0): two triggers that overlap share one request instead of racing. */
377
+ requestedPreviews = /* @__PURE__ */ new Set();
378
+ /** Live inserts recorded but not rendered while jumped (0.9.0); drained — and acknowledged — by the return. */
379
+ deferred = /* @__PURE__ */ new Set();
380
+ /** The message the current jumped window was centred on, until the window is paged (0.9.0). */
381
+ jumpAnchor;
382
+ previewTimer;
383
+ jumpTimer;
384
+ highlightTimer;
328
385
  typingTimers = /* @__PURE__ */ new Map();
329
386
  ownTypingTimer;
330
387
  sentTyping = false;
@@ -414,6 +471,19 @@ var ConversationStore = class {
414
471
  this.activeSend = void 0;
415
472
  this.activeEdit = void 0;
416
473
  this.refreshQueued = false;
474
+ this.jumpedRefreshQueued = false;
475
+ this.olderContextCursor = null;
476
+ this.newerContextCursor = null;
477
+ this.jumpAnchor = void 0;
478
+ this.stalePreviews.clear();
479
+ this.requestedPreviews.clear();
480
+ this.deferred.clear();
481
+ clearTimeout(this.previewTimer);
482
+ this.previewTimer = void 0;
483
+ clearTimeout(this.jumpTimer);
484
+ this.jumpTimer = void 0;
485
+ clearTimeout(this.highlightTimer);
486
+ this.highlightTimer = void 0;
417
487
  }
418
488
  dispose = () => {
419
489
  if (this.alive() && this.sentTyping) {
@@ -443,8 +513,10 @@ var ConversationStore = class {
443
513
  add(() => this.client.onConnectionEvent({
444
514
  onEvent: ({ topic, status }) => {
445
515
  if (!data || !this.alive(generation) || topic !== `messages:${this.room}` && topic !== `conversation:${this.room}`) return;
446
- if (status === "SUBSCRIBED") this.queueRefresh();
447
- else this.clearTyping();
516
+ if (status === "SUBSCRIBED") {
517
+ this.markPreviewsStale();
518
+ this.queueRefresh();
519
+ } else this.clearTyping();
448
520
  },
449
521
  onSessionEnded: () => {
450
522
  if (this.disposed || generation !== this.generation) return;
@@ -502,7 +574,10 @@ var ConversationStore = class {
502
574
  const known = existing ?? this.changes.get(message.id)?.message;
503
575
  if (known && older(message, known)) return;
504
576
  const insert = type === "insert" || this.changes.get(message.id)?.insert === true;
505
- if (!existing && !insert && type === "update" && !this.state.isInitialLoading && !this.state.isLoadingOlder && !this.state.isReconciling) return;
577
+ if (!existing && !insert && type === "update") {
578
+ this.notePreviewSource(message.id);
579
+ if (!this.state.isInitialLoading && !this.state.isLoadingOlder && !this.state.isReconciling) return;
580
+ }
506
581
  const revision = ++this.revision;
507
582
  const provisional = existing && !message.media.length ? { ...message, media: existing.media } : message;
508
583
  this.record(provisional, insert, revision, false);
@@ -520,7 +595,14 @@ var ConversationStore = class {
520
595
  }
521
596
  this.changes.set(message.id, { revision, message, insert, complete });
522
597
  if (!existing && !(insert && hasContent(message))) return;
598
+ if (!existing && this.state.windowMode === "jumped") {
599
+ this.deferred.add(message.id);
600
+ if (!this.state.hasNewerMessages) this.patch({ hasNewerMessages: true });
601
+ return;
602
+ }
523
603
  this.patch({ messages: mergeMessages(this.state.messages, [message]) });
604
+ this.notePreviewSource(message.id);
605
+ if (message.replyToMessageId) this.schedulePreviews();
524
606
  if (!existing && message.senderId !== this.user && (this.options.markReadOnReceive ?? true)) void this.acknowledge(true);
525
607
  }
526
608
  confirmSend(message) {
@@ -542,6 +624,9 @@ var ConversationStore = class {
542
624
  this.changes.delete(id);
543
625
  this.hydrations.delete(id);
544
626
  this.hydrationPool.queued.delete(id);
627
+ this.deferred.delete(id);
628
+ this.markUnavailable(id);
629
+ if (this.state.replyTarget?.id === id) this.patch({ replyTarget: null });
545
630
  const ack = this.ack;
546
631
  if (ack.target !== id && ack.acknowledged?.id !== id) return;
547
632
  ack.unacknowledgeable.add(id);
@@ -610,6 +695,23 @@ var ConversationStore = class {
610
695
  previous = message;
611
696
  }
612
697
  }
698
+ /** Context windows get their OWN validator (0.9.0): `validatePage` measures a page against the backward cursor
699
+ * it was fetched with, and a window centred on a message has no such cursor. Only the per-row predicates are
700
+ * shared. A CENTRED window must carry its target exactly once; a cursor page must not be held to that.
701
+ */
702
+ validateContextPage(page, limit, targetId) {
703
+ if (page.length > limit) throw new Error("Message context exceeds the requested limit");
704
+ let previous;
705
+ for (const message of page) {
706
+ if (!this.validMessage(message) || previous && compare(message, previous) >= 0) {
707
+ throw new Error("Message context must contain distinct, room-scoped rows in newest-first order");
708
+ }
709
+ previous = message;
710
+ }
711
+ if (targetId !== void 0 && page.filter((message) => message.id === targetId).length !== 1) {
712
+ throw new Error("Centred message context must contain its target exactly once");
713
+ }
714
+ }
613
715
  fetchPage(before) {
614
716
  return this.client.getMessages({
615
717
  conversationId: this.room,
@@ -632,6 +734,148 @@ var ConversationStore = class {
632
734
  }
633
735
  return mergeMessages([], [...byId.values()]);
634
736
  }
737
+ /** `overlay` for a JUMPED window (0.9.0): the same precedence for rows the window already holds, WITHOUT the
738
+ * pending replay and the `changes`-insert replay, either of which would inject live-tail rows into a
739
+ * historical window. A jumped window only ever holds rows a context response carried.
740
+ */
741
+ overlayWindow(rows, revision) {
742
+ const byId = new Map(rows.filter((message) => !this.deleted.has(message.id)).map((message) => [message.id, message]));
743
+ for (const [id, change] of this.changes) {
744
+ const current = byId.get(id);
745
+ if (current && change.revision > revision) byId.set(id, newest(current, change.message, change.complete));
746
+ }
747
+ return mergeMessages([], [...byId.values()]);
748
+ }
749
+ /** The distinct quoted parents the rendered rows point at (0.9.0). */
750
+ referencedParents() {
751
+ const referenced = /* @__PURE__ */ new Set();
752
+ for (const message of this.state.messages) {
753
+ if (message.replyToMessageId) referenced.add(message.replyToMessageId);
754
+ }
755
+ return referenced;
756
+ }
757
+ /** Cache the terminal `'unavailable'` for a quoted parent that is gone, while any rendered row still quotes it
758
+ * (or an entry for it already exists). Never re-requested: absence from a resolved batch is the only deletion
759
+ * signal the backend gives, and a deleted message cannot come back.
760
+ */
761
+ markUnavailable(id) {
762
+ const cached = this.state.replyPreviews.get(id);
763
+ if (cached === "unavailable") return;
764
+ if (cached === void 0 && !this.referencedParents().has(id)) return;
765
+ const next = new Map(this.state.replyPreviews);
766
+ next.set(id, "unavailable");
767
+ this.stalePreviews.delete(id);
768
+ this.patch({ replyPreviews: next });
769
+ }
770
+ /** Re-read every non-terminal preview on the next batch (reconnect/`SUBSCRIBED`). */
771
+ markPreviewsStale() {
772
+ for (const [id, entry] of this.state.replyPreviews) if (entry !== "unavailable") this.stalePreviews.add(id);
773
+ }
774
+ /** A row for a quoted parent reached the store: the preview that references it is refreshed from the rendered
775
+ * row, or marked stale when the parent is outside the window. Previews are re-read, never copied — a parent
776
+ * edit bumps the PARENT's revision, which no row-precedence rule on the reply can see.
777
+ */
778
+ notePreviewSource(id) {
779
+ const cached = this.state.replyPreviews.get(id);
780
+ if (cached === void 0) return;
781
+ const row = this.state.messages.find((message) => message.id === id);
782
+ if (row && !isConvoKitPendingMessage(row)) {
783
+ const fresh = previewOf(row);
784
+ this.stalePreviews.delete(id);
785
+ if (cached !== "unavailable" && cached.revision === fresh.revision && cached.text === fresh.text && cached.mediaCount === fresh.mediaCount) return;
786
+ const next = new Map(this.state.replyPreviews);
787
+ next.set(id, fresh);
788
+ this.patch({ replyPreviews: next });
789
+ return;
790
+ }
791
+ if (cached === "unavailable") return;
792
+ this.stalePreviews.add(id);
793
+ this.schedulePreviews();
794
+ }
795
+ /** Coalesce a burst of live inserts into one batch; the handle is cleared wherever subscriptions are torn down
796
+ * and the callback is dropped when the store is no longer alive for the generation that scheduled it.
797
+ */
798
+ schedulePreviews() {
799
+ if (this.previewTimer !== void 0 || !this.support.previews) return;
800
+ const generation = this.generation;
801
+ const timer = setTimeout(() => {
802
+ this.previewTimer = void 0;
803
+ if (this.alive(generation)) void this.resolvePreviews();
804
+ }, 120);
805
+ timer.unref?.();
806
+ this.previewTimer = timer;
807
+ }
808
+ /** Resolve the quoted parents of the rendered rows in ONE request, never one per row (0.9.0). A parent inside
809
+ * the loaded window is derived locally and costs nothing; `'unavailable'` is terminal; an id with no entry is
810
+ * "not resolved yet", so a rejection — which says nothing about which ids exist — writes no entry at all and
811
+ * the next trigger asks again. `prune` drops entries no rendered row references (reconcile completion), which
812
+ * is what bounds the map to the window.
813
+ */
814
+ async resolvePreviews(prune = false) {
815
+ if (!this.alive() || !this.support.previews || !this.state.canResolveReplyPreviews) return;
816
+ const referenced = this.referencedParents();
817
+ const previews = new Map(this.state.replyPreviews);
818
+ let changed = false;
819
+ if (prune) {
820
+ for (const id of [...previews.keys()]) {
821
+ if (referenced.has(id)) continue;
822
+ previews.delete(id);
823
+ this.stalePreviews.delete(id);
824
+ changed = true;
825
+ }
826
+ }
827
+ const window = new Map(this.state.messages.filter((message) => !isConvoKitPendingMessage(message)).map((message) => [message.id, message]));
828
+ const wanted = [];
829
+ for (const id of referenced) {
830
+ const local = window.get(id);
831
+ if (local) {
832
+ this.stalePreviews.delete(id);
833
+ const cached2 = previews.get(id);
834
+ const fresh = previewOf(local);
835
+ if (cached2 === void 0 || cached2 === "unavailable" || cached2.revision !== fresh.revision || cached2.text !== fresh.text || cached2.mediaCount !== fresh.mediaCount) {
836
+ previews.set(id, fresh);
837
+ changed = true;
838
+ }
839
+ continue;
840
+ }
841
+ const cached = previews.get(id);
842
+ if (cached === "unavailable") continue;
843
+ if (cached !== void 0 && !this.stalePreviews.has(id)) continue;
844
+ if (this.requestedPreviews.has(id)) continue;
845
+ wanted.push(id);
846
+ }
847
+ if (changed) this.patch({ replyPreviews: previews });
848
+ if (wanted.length === 0) return;
849
+ const generation = this.generation;
850
+ for (const id of wanted) this.requestedPreviews.add(id);
851
+ try {
852
+ const resolved = await this.client.getReplyPreviews(this.room, wanted);
853
+ if (!this.alive(generation)) return;
854
+ const next = new Map(this.state.replyPreviews);
855
+ const returned = /* @__PURE__ */ new Set();
856
+ for (const preview of resolved) {
857
+ if (preview.conversationId !== this.room) continue;
858
+ returned.add(preview.id);
859
+ next.set(preview.id, preview);
860
+ this.stalePreviews.delete(preview.id);
861
+ }
862
+ for (const id of wanted) {
863
+ if (returned.has(id)) continue;
864
+ next.set(id, "unavailable");
865
+ this.stalePreviews.delete(id);
866
+ }
867
+ this.patch({ replyPreviews: next });
868
+ } catch (cause) {
869
+ if (!this.alive(generation)) return;
870
+ if (isRouteMissing(cause)) {
871
+ this.support.previews = false;
872
+ this.patch({ canResolveReplyPreviews: false });
873
+ }
874
+ this.fail(cause, generation);
875
+ } finally {
876
+ for (const id of wanted) this.requestedPreviews.delete(id);
877
+ }
878
+ }
635
879
  prune(revision) {
636
880
  const safeRevision = Math.min(revision, this.sendRevision ?? Infinity);
637
881
  for (const [id, change] of this.changes) if (change.revision <= safeRevision) this.changes.delete(id);
@@ -658,6 +902,7 @@ var ConversationStore = class {
658
902
  this.captured = capture(conversation);
659
903
  this.mergeReads(conversation.participants.map(readEntry));
660
904
  this.prune(revision);
905
+ void this.resolvePreviews();
661
906
  if ((this.options.markReadOnLoad ?? true) || this.ack.suppressed) {
662
907
  this.ack.suppressed = false;
663
908
  await this.acknowledge(true);
@@ -673,19 +918,36 @@ var ConversationStore = class {
673
918
  };
674
919
  queueRefresh() {
675
920
  this.refreshQueued = true;
921
+ this.jumpedRefreshQueued = true;
676
922
  this.flushRefresh();
677
923
  }
678
924
  flushRefresh() {
679
925
  const generation = this.generation;
680
926
  void Promise.resolve().then(() => {
681
- if (!this.alive(generation) || !this.refreshQueued || this.state.isInitialLoading || this.state.isLoadingOlder || this.state.isReconciling) return;
927
+ if (!this.alive(generation) || !this.refreshQueued || this.state.isInitialLoading || this.state.isLoadingOlder || this.state.isLoadingNewer || this.state.isReconciling) return;
928
+ if (this.state.windowMode === "jumped") {
929
+ if (!this.jumpedRefreshQueued) return;
930
+ this.jumpedRefreshQueued = false;
931
+ void this.reconcileWindow();
932
+ return;
933
+ }
682
934
  this.refreshQueued = false;
935
+ this.jumpedRefreshQueued = false;
683
936
  void this.refresh();
684
937
  });
685
938
  }
686
939
  /** Re-fetch the entire viewed range atomically; a first-page-only refresh loses history. */
687
940
  refresh = async () => {
688
941
  if (!this.alive()) return;
942
+ if (this.state.windowMode === "jumped") {
943
+ this.refreshQueued = true;
944
+ if (this.state.isInitialLoading || this.state.isLoadingOlder || this.state.isLoadingNewer || this.state.isReconciling) {
945
+ this.jumpedRefreshQueued = true;
946
+ return;
947
+ }
948
+ this.jumpedRefreshQueued = false;
949
+ return this.reconcileWindow();
950
+ }
689
951
  if (this.state.isInitialLoading || this.state.isLoadingOlder || this.state.isReconciling) {
690
952
  this.refreshQueued = true;
691
953
  return;
@@ -723,6 +985,7 @@ var ConversationStore = class {
723
985
  if (opening) this.captured = capture(conversation);
724
986
  this.mergeReads(conversation.participants.map(readEntry));
725
987
  this.prune(revision);
988
+ void this.resolvePreviews(true);
726
989
  if (opening) void this.resumeAcknowledgement();
727
990
  } catch (cause) {
728
991
  this.fail(cause, generation, true);
@@ -734,7 +997,8 @@ var ConversationStore = class {
734
997
  }
735
998
  };
736
999
  loadOlderMessages = async () => {
737
- if (!this.alive() || !this.state.hasLoaded || this.state.isInitialLoading || this.state.isLoadingOlder || this.state.isReconciling || !this.state.hasOlderMessages) return;
1000
+ if (!this.alive() || !this.state.hasLoaded || this.state.isInitialLoading || this.state.isLoadingOlder || this.state.isReconciling || this.state.isLoadingNewer || !this.state.hasOlderMessages) return;
1001
+ if (this.state.windowMode === "jumped") return this.loadContextPage("older");
738
1002
  const generation = this.generation;
739
1003
  const revision = this.revision;
740
1004
  const cursor = this.cursor;
@@ -748,6 +1012,7 @@ var ConversationStore = class {
748
1012
  messages: this.overlay(mergeMessages(page, this.state.messages), revision),
749
1013
  hasOlderMessages: page.length === this.pageSize
750
1014
  });
1015
+ void this.resolvePreviews();
751
1016
  } catch (cause) {
752
1017
  this.fail(cause, generation, true);
753
1018
  } finally {
@@ -757,6 +1022,298 @@ var ConversationStore = class {
757
1022
  }
758
1023
  }
759
1024
  };
1025
+ /** Page a jumped window towards the start (0.9.0); a no-op while the window is live or already at the end. */
1026
+ loadNewerMessages = async () => {
1027
+ 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;
1028
+ return this.loadContextPage("newer");
1029
+ };
1030
+ /** One page of the jumped window in either direction, through the cursor the previous window returned. When
1031
+ * the newer side reaches the tail the store does NOT flip to `live` on the spot — `newerCursor: null` was only
1032
+ * true as of the server's query time and inserts have been deferred throughout the round trip, so the return
1033
+ * to the live tail is a full `returnToLatest()`.
1034
+ */
1035
+ async loadContextPage(direction) {
1036
+ const cursor = direction === "older" ? this.olderContextCursor : this.newerContextCursor;
1037
+ if (cursor === null || typeof this.client.getMessageContext !== "function") return;
1038
+ const generation = this.generation;
1039
+ const revision = this.revision;
1040
+ let reachedTail = false;
1041
+ this.patch(direction === "older" ? { isLoadingOlder: true, error: null } : { isLoadingNewer: true, error: null });
1042
+ try {
1043
+ const page = await this.client.getMessageContext(this.room, {
1044
+ ...direction === "older" ? { olderCursor: cursor } : { newerCursor: cursor },
1045
+ limit: this.pageSize
1046
+ });
1047
+ if (!this.alive(generation)) return;
1048
+ this.validateContextPage(page.messages, this.pageSize);
1049
+ this.jumpAnchor = void 0;
1050
+ if (direction === "older") {
1051
+ this.olderContextCursor = page.olderCursor;
1052
+ this.patch({
1053
+ messages: this.overlayWindow(mergeMessages(this.state.messages, page.messages), revision),
1054
+ hasOlderMessages: page.olderCursor !== null
1055
+ });
1056
+ } else {
1057
+ this.newerContextCursor = page.newerCursor;
1058
+ reachedTail = page.newerCursor === null;
1059
+ this.patch({
1060
+ messages: this.overlayWindow(mergeMessages(this.state.messages, page.messages), revision),
1061
+ hasNewerMessages: page.newerCursor !== null
1062
+ });
1063
+ }
1064
+ void this.resolvePreviews();
1065
+ } catch (cause) {
1066
+ if (!this.alive(generation)) return;
1067
+ if (isRouteMissing(cause)) this.retireJump();
1068
+ this.fail(cause, generation);
1069
+ } finally {
1070
+ if (this.alive(generation)) {
1071
+ this.patch(direction === "older" ? { isLoadingOlder: false } : { isLoadingNewer: false });
1072
+ this.flushRefresh();
1073
+ }
1074
+ }
1075
+ if (reachedTail && this.alive(generation)) await this.returnToLatest();
1076
+ }
1077
+ /** Re-read the JUMPED window with one bounded request, in place of the tail-anchored boundary walk (0.9.0).
1078
+ * The anchor is the jump target while the window has not been paged, otherwise the newest non-pending row at
1079
+ * or older than the window's midpoint, and the limit is the window's own size. Tombstones are bounded by the
1080
+ * returned range: a known row inside it that the response did not carry is gone, anything outside is not.
1081
+ */
1082
+ async reconcileWindow() {
1083
+ const anchor = this.windowAnchor();
1084
+ if (!anchor || typeof this.client.getMessageContext !== "function") return;
1085
+ const generation = this.generation;
1086
+ const revision = this.revision;
1087
+ const rendered = this.state.messages.filter((message) => !isConvoKitPendingMessage(message));
1088
+ const limit = Math.min(Math.max(rendered.length, 1), 100);
1089
+ this.patch({ isReconciling: true, error: null });
1090
+ try {
1091
+ let conversation;
1092
+ try {
1093
+ conversation = await this.client.getConversation(this.room);
1094
+ } catch (cause) {
1095
+ this.fail(cause, generation, true);
1096
+ return;
1097
+ }
1098
+ if (!this.alive(generation)) return;
1099
+ if (conversation.id !== this.room) {
1100
+ this.fail(new Error("Conversation response belongs to a different room"), generation, true);
1101
+ return;
1102
+ }
1103
+ const page = await this.client.getMessageContext(this.room, { messageId: anchor, limit });
1104
+ if (!this.alive(generation)) return;
1105
+ this.validateContextPage(page.messages, limit, anchor);
1106
+ this.olderContextCursor = page.olderCursor;
1107
+ this.newerContextCursor = page.newerCursor;
1108
+ const reconciled = this.overlayWindow(page.messages, revision);
1109
+ const surviving = new Set(reconciled.map((message) => message.id));
1110
+ const newestRow = page.messages[0];
1111
+ const oldestRow = page.messages.at(-1);
1112
+ if (newestRow && oldestRow) {
1113
+ for (const message of rendered) {
1114
+ if (surviving.has(message.id)) continue;
1115
+ if (compare(message, oldestRow) < 0 || compare(message, newestRow) > 0) continue;
1116
+ this.forget(message.id);
1117
+ }
1118
+ }
1119
+ this.patch({
1120
+ conversation,
1121
+ messages: reconciled,
1122
+ hasOlderMessages: page.olderCursor !== null,
1123
+ hasNewerMessages: page.newerCursor !== null
1124
+ });
1125
+ this.mergeReads(conversation.participants.map(readEntry));
1126
+ this.prune(revision);
1127
+ void this.resolvePreviews(true);
1128
+ } catch (cause) {
1129
+ if (!this.alive(generation)) return;
1130
+ if (isMessageMissing(cause)) this.removeMessage(anchor);
1131
+ else if (isRouteMissing(cause)) this.retireJump();
1132
+ this.fail(cause, generation);
1133
+ } finally {
1134
+ if (this.alive(generation)) {
1135
+ this.patch({ isReconciling: false });
1136
+ this.flushRefresh();
1137
+ }
1138
+ }
1139
+ }
1140
+ /** The row a jumped window re-reads around: its jump target while it has not been paged, otherwise the newest
1141
+ * non-pending row at or older than the window's midpoint.
1142
+ */
1143
+ windowAnchor() {
1144
+ const rendered = this.state.messages.filter((message) => !isConvoKitPendingMessage(message));
1145
+ if (rendered.length === 0) return void 0;
1146
+ if (this.jumpAnchor && rendered.some((message) => message.id === this.jumpAnchor)) return this.jumpAnchor;
1147
+ return rendered[Math.floor((rendered.length - 1) / 2)]?.id;
1148
+ }
1149
+ /** A 0.8 backend does not serve the context route: the jump affordance disappears for the store's life rather
1150
+ * than failing repeatedly. A coded `MESSAGE_NOT_FOUND` never trips this — that is a real missing target.
1151
+ */
1152
+ retireJump() {
1153
+ this.support.jump = false;
1154
+ this.patch({ canJumpToMessage: false });
1155
+ }
1156
+ /** Bring a message into view (0.9.0). A row already in the loaded window is only highlighted and scrolled to;
1157
+ * otherwise the window is REPLACED by a context window centred on it and `windowMode` becomes `jumped`. A jump
1158
+ * is a window operation, never a re-open: tombstones, the acknowledgement floor, this open's captured private
1159
+ * state, edit mode and the reply target all survive it, and it arms no acknowledgement. It is a no-op while a
1160
+ * send is in flight, so a replacement can never strand a pending row. A coded `MESSAGE_NOT_FOUND` is the
1161
+ * guaranteed answer for a quoted message that was deleted: it marks the preview `'unavailable'` instead of
1162
+ * reporting an error. Resolves true once the target is highlighted.
1163
+ */
1164
+ jumpToMessage = async (messageId) => {
1165
+ if (!this.alive() || !this.state.hasLoaded) return false;
1166
+ const id = messageId.trim();
1167
+ if (!id || this.state.isSending) return false;
1168
+ const rendered = this.state.messages.find((message) => message.id === id);
1169
+ if (rendered) return isConvoKitPendingMessage(rendered) ? false : (this.beginJump(), this.landJump(id), true);
1170
+ if (this.deleted.has(id)) {
1171
+ this.markUnavailable(id);
1172
+ return false;
1173
+ }
1174
+ if (!this.support.jump || typeof this.client.getMessageContext !== "function") return false;
1175
+ if (this.state.isInitialLoading || this.state.isLoadingOlder || this.state.isLoadingNewer || this.state.isReconciling) return false;
1176
+ const generation = this.generation;
1177
+ this.beginJump();
1178
+ this.patch({ isLoadingNewer: true, error: null, highlightedMessageId: null });
1179
+ try {
1180
+ const page = await this.client.getMessageContext(this.room, { messageId: id, limit: this.pageSize });
1181
+ if (!this.alive(generation)) return false;
1182
+ this.validateContextPage(page.messages, this.pageSize, id);
1183
+ this.olderContextCursor = page.olderCursor;
1184
+ this.newerContextCursor = page.newerCursor;
1185
+ this.jumpAnchor = id;
1186
+ this.patch({
1187
+ messages: mergeMessages([], page.messages.filter((message) => !this.deleted.has(message.id))),
1188
+ windowMode: "jumped",
1189
+ hasOlderMessages: page.olderCursor !== null,
1190
+ hasNewerMessages: page.newerCursor !== null
1191
+ });
1192
+ this.landJump(id);
1193
+ void this.resolvePreviews(true);
1194
+ return true;
1195
+ } catch (cause) {
1196
+ if (!this.alive(generation)) return false;
1197
+ this.releaseJump();
1198
+ if (isMessageMissing(cause)) {
1199
+ this.markUnavailable(id);
1200
+ return false;
1201
+ }
1202
+ if (isRouteMissing(cause)) this.retireJump();
1203
+ this.fail(cause, generation);
1204
+ return false;
1205
+ } finally {
1206
+ if (this.alive(generation)) {
1207
+ this.patch({ isLoadingNewer: false });
1208
+ this.flushRefresh();
1209
+ }
1210
+ }
1211
+ };
1212
+ /** Drop a jumped window and render the live tail again (0.9.0), through the normal newest-page load. There is
1213
+ * no in-place flip: `windowMode` becomes `live` BEFORE the request, so inserts arriving during the round trip
1214
+ * are folded in by `overlay` exactly as `loadInitial()` and `refresh()` already tolerate, and the rows
1215
+ * deferred while jumped are drained — and acknowledged — with it. A failure stays `jumped` with the window and
1216
+ * its affordance intact; the highlight is carried through so the target re-anchors when it is still in the
1217
+ * newest page. Resolves true once the live tail is rendered.
1218
+ */
1219
+ returnToLatest = async () => {
1220
+ if (!this.alive() || !this.state.hasLoaded) return false;
1221
+ if (this.state.windowMode !== "jumped") return true;
1222
+ if (this.state.isInitialLoading || this.state.isLoadingOlder || this.state.isLoadingNewer || this.state.isReconciling) return false;
1223
+ const generation = this.generation;
1224
+ const revision = this.revision;
1225
+ const highlighted = this.state.highlightedMessageId;
1226
+ clearTimeout(this.highlightTimer);
1227
+ this.highlightTimer = void 0;
1228
+ this.patch({ windowMode: "live", isLoadingNewer: true, error: null, highlightedMessageId: null });
1229
+ try {
1230
+ const page = await this.fetchPage();
1231
+ if (!this.alive(generation)) return false;
1232
+ this.validatePage(page);
1233
+ this.cursor = page.at(-1);
1234
+ this.olderContextCursor = null;
1235
+ this.newerContextCursor = null;
1236
+ this.jumpAnchor = void 0;
1237
+ this.patch({
1238
+ messages: this.overlay(page, revision),
1239
+ hasOlderMessages: page.length === this.pageSize,
1240
+ hasNewerMessages: false
1241
+ });
1242
+ this.prune(revision);
1243
+ const drained = [...this.deferred].some((id) => this.state.messages.some((message) => message.id === id && message.senderId !== this.user));
1244
+ this.deferred.clear();
1245
+ void this.resolvePreviews(true);
1246
+ if (drained && (this.options.markReadOnReceive ?? true) || this.ack.followUp) void this.acknowledge(true);
1247
+ if (highlighted && this.state.messages.some((message) => message.id === highlighted)) {
1248
+ this.beginJump();
1249
+ this.landJump(highlighted);
1250
+ }
1251
+ return true;
1252
+ } catch (cause) {
1253
+ if (!this.alive(generation)) return false;
1254
+ this.patch({
1255
+ windowMode: "jumped",
1256
+ hasNewerMessages: this.newerContextCursor !== null,
1257
+ highlightedMessageId: highlighted
1258
+ });
1259
+ this.fail(cause, generation, true);
1260
+ return false;
1261
+ } finally {
1262
+ if (this.alive(generation)) {
1263
+ this.patch({ isLoadingNewer: false });
1264
+ this.flushRefresh();
1265
+ }
1266
+ }
1267
+ };
1268
+ /** The guard the views read while a jump lands: it is set BEFORE the window is replaced, because shrinking the
1269
+ * list clamps `scrollTop` and emits a scroll event of its own.
1270
+ */
1271
+ beginJump() {
1272
+ clearTimeout(this.jumpTimer);
1273
+ this.jumpTimer = void 0;
1274
+ clearTimeout(this.highlightTimer);
1275
+ this.highlightTimer = void 0;
1276
+ if (!this.state.jumpInFlight) this.patch({ jumpInFlight: true });
1277
+ }
1278
+ /** The window is in place: highlight the target and release the guard on a timer, never on "the first scroll
1279
+ * event" — a target already in view produces none. The highlight's own timeout starts when the guard clears.
1280
+ */
1281
+ landJump(id) {
1282
+ this.patch({ highlightedMessageId: id });
1283
+ const generation = this.generation;
1284
+ const timer = setTimeout(() => {
1285
+ this.jumpTimer = void 0;
1286
+ if (!this.alive(generation)) return;
1287
+ this.patch({ jumpInFlight: false });
1288
+ const clearing = setTimeout(() => {
1289
+ this.highlightTimer = void 0;
1290
+ if (this.alive(generation) && this.state.highlightedMessageId === id) this.patch({ highlightedMessageId: null });
1291
+ }, HIGHLIGHT_MS);
1292
+ clearing.unref?.();
1293
+ this.highlightTimer = clearing;
1294
+ }, JUMP_GUARD_MS);
1295
+ timer.unref?.();
1296
+ this.jumpTimer = timer;
1297
+ }
1298
+ releaseJump() {
1299
+ clearTimeout(this.jumpTimer);
1300
+ this.jumpTimer = void 0;
1301
+ if (this.state.jumpInFlight) this.patch({ jumpInFlight: false });
1302
+ }
1303
+ /** Quote a rendered, confirmed message in the composer (0.9.0). A no-op for pending, tombstoned and unknown
1304
+ * rows and while the caller's role (when known) is `READ`; any member may quote any row, own or not. Replying
1305
+ * and editing are mutually exclusive, so this leaves edit mode. Sends nothing.
1306
+ */
1307
+ startReply = (messageId) => {
1308
+ if (!this.alive() || this.ownRole() === "READ") return;
1309
+ const row = this.state.messages.find((message) => message.id === messageId);
1310
+ if (!row || isConvoKitPendingMessage(row) || this.deleted.has(messageId)) return;
1311
+ this.patch({ replyTarget: row, editingMessage: null });
1312
+ };
1313
+ /** Drop the reply target without a request; the draft is untouched (replying never replaces it). */
1314
+ cancelReply = () => {
1315
+ if (this.state.replyTarget) this.patch({ replyTarget: null });
1316
+ };
760
1317
  /** Acknowledge through the newest rendered row now, regardless of visibility; no acknowledgement without a
761
1318
  * target (a room opened with a marker that renders nothing clears the marker instead, once).
762
1319
  */
@@ -786,6 +1343,7 @@ var ConversationStore = class {
786
1343
  }
787
1344
  /** Resolves when the request this call issued or joined settles; a follow-up is issued, not awaited. */
788
1345
  acknowledge(automatic) {
1346
+ if (this.state.windowMode === "jumped") return Promise.resolve();
789
1347
  const ack = this.ack;
790
1348
  if (automatic && (!this.visible || this.state.conversation === null)) {
791
1349
  ack.suppressed = true;
@@ -798,6 +1356,10 @@ var ConversationStore = class {
798
1356
  return this.issue(ack, this.generation) ?? Promise.resolve();
799
1357
  }
800
1358
  issue(ack, generation) {
1359
+ if (this.state.windowMode === "jumped") {
1360
+ ack.followUp = true;
1361
+ return void 0;
1362
+ }
801
1363
  ack.followUp = false;
802
1364
  const target = this.ackTarget(ack);
803
1365
  const request = target ? this.send(ack, generation, target) : this.clearMarker(ack, generation);
@@ -874,11 +1436,14 @@ var ConversationStore = class {
874
1436
  sendMessage = async ({ text, media }) => {
875
1437
  const normalized = text?.trim();
876
1438
  if (!this.alive() || this.state.isSending || !normalized && !media?.length) return null;
1439
+ if (this.state.windowMode === "jumped" && !await this.returnToLatest()) return null;
1440
+ if (!this.alive() || this.state.isSending) return null;
877
1441
  const generation = this.generation;
878
1442
  const revision = this.revision;
879
1443
  this.sendRevision = revision;
880
1444
  const clientMessageId = createClientMessageId();
881
1445
  const pendingId = `convokit-pending-${clientMessageId}`;
1446
+ const replyToMessageId = this.state.replyTarget?.id;
882
1447
  const pending = {
883
1448
  id: pendingId,
884
1449
  clientMessageId,
@@ -888,7 +1453,8 @@ var ConversationStore = class {
888
1453
  media: media ?? [],
889
1454
  createdAt: /* @__PURE__ */ new Date(),
890
1455
  updatedAt: null,
891
- revision: 0
1456
+ revision: 0,
1457
+ ...replyToMessageId ? { replyToMessageId } : {}
892
1458
  };
893
1459
  const send = { pending };
894
1460
  this.activeSend = send;
@@ -898,7 +1464,9 @@ var ConversationStore = class {
898
1464
  conversationId: this.room,
899
1465
  clientMessageId,
900
1466
  ...normalized ? { text: normalized } : {},
901
- ...media?.length ? { media } : {}
1467
+ ...media?.length ? { media } : {},
1468
+ // Omitted entirely when there is no quote, so a plain send is byte-identical to 0.8.
1469
+ ...replyToMessageId ? { replyToMessageId } : {}
902
1470
  });
903
1471
  if (!this.alive(generation)) return null;
904
1472
  if (!this.validMessage(message) || message.senderId !== this.user) throw new Error("Send response belongs to a different room or sender");
@@ -908,10 +1476,15 @@ var ConversationStore = class {
908
1476
  let latest = existing ? newest(message, existing, live?.complete !== false) : message;
909
1477
  if (live && live.revision > revision) latest = newest(latest, live.message, live.complete);
910
1478
  if (!this.deleted.has(message.id)) this.changes.set(message.id, { revision: ++this.revision, message: latest, insert: true, complete: true });
911
- this.patch({ messages: mergeMessages(
912
- this.state.messages.filter((item) => item.id !== pendingId),
913
- this.deleted.has(message.id) ? [] : [latest]
914
- ) });
1479
+ this.patch({
1480
+ messages: mergeMessages(
1481
+ this.state.messages.filter((item) => item.id !== pendingId),
1482
+ this.deleted.has(message.id) ? [] : [latest]
1483
+ ),
1484
+ // The quote is spent: it is cleared only once the server accepted the send.
1485
+ ...replyToMessageId ? { replyTarget: null } : {}
1486
+ });
1487
+ if (replyToMessageId) void this.resolvePreviews();
915
1488
  void this.updateTyping(false);
916
1489
  return this.alive(generation) ? latest : null;
917
1490
  } catch (cause) {
@@ -949,7 +1522,7 @@ var ConversationStore = class {
949
1522
  startEditing = (messageId) => {
950
1523
  if (!this.alive() || !this.support.edit || this.ownRole() === "READ") return;
951
1524
  const row = this.ownRow(messageId);
952
- if (row) this.patch({ editingMessage: row });
1525
+ if (row) this.patch({ editingMessage: row, replyTarget: null });
953
1526
  };
954
1527
  /** Leave edit mode without a request; the draft is the view's to restore. */
955
1528
  cancelEditing = () => {
@@ -1114,6 +1687,15 @@ function useConversation(options) {
1114
1687
  editingMessage: field("editingMessage"),
1115
1688
  canEditMessages: field("canEditMessages"),
1116
1689
  canDeleteMessages: field("canDeleteMessages"),
1690
+ replyTarget: field("replyTarget"),
1691
+ replyPreviews: field("replyPreviews"),
1692
+ highlightedMessageId: field("highlightedMessageId"),
1693
+ jumpInFlight: field("jumpInFlight"),
1694
+ windowMode: field("windowMode"),
1695
+ hasNewerMessages: field("hasNewerMessages"),
1696
+ isLoadingNewer: field("isLoadingNewer"),
1697
+ canJumpToMessage: field("canJumpToMessage"),
1698
+ canResolveReplyPreviews: field("canResolveReplyPreviews"),
1117
1699
  readerIdsFor: (message) => store.readerIdsFor(message),
1118
1700
  loadInitial: () => store.loadInitial(),
1119
1701
  refresh: () => store.refresh(),
@@ -1123,6 +1705,11 @@ function useConversation(options) {
1123
1705
  cancelEditing: () => store.cancelEditing(),
1124
1706
  saveEdit: (text) => store.saveEdit(text),
1125
1707
  deleteMessage: (messageId) => store.deleteMessage(messageId),
1708
+ startReply: (messageId) => store.startReply(messageId),
1709
+ cancelReply: () => store.cancelReply(),
1710
+ jumpToMessage: (messageId) => store.jumpToMessage(messageId),
1711
+ loadNewerMessages: () => store.loadNewerMessages(),
1712
+ returnToLatest: () => store.returnToLatest(),
1126
1713
  markRead: () => store.markRead(),
1127
1714
  updateTyping: (isTyping) => store.updateTyping(isTyping),
1128
1715
  setVisible: (value) => {
@@ -1146,6 +1733,7 @@ import {
1146
1733
  MapPin,
1147
1734
  MessageCircle,
1148
1735
  Pencil,
1736
+ Reply,
1149
1737
  Trash2
1150
1738
  } from "@lucide/vue";
1151
1739
  import {
@@ -1212,6 +1800,15 @@ var MessageListView = defineComponent2({
1212
1800
  onDeleteMessage: { type: Function, default: void 0 },
1213
1801
  canEditMessage: { type: Function, default: void 0 },
1214
1802
  confirmDelete: { type: Function, default: void 0 },
1803
+ onReplyToMessage: { type: Function, default: void 0 },
1804
+ canReplyToMessage: { type: Function, default: void 0 },
1805
+ replyPreviewByMessageId: { type: Object, default: void 0 },
1806
+ onJumpToMessage: { type: Function, default: void 0 },
1807
+ highlightedMessageId: { type: String, default: null },
1808
+ jumpInFlight: { type: Boolean, default: false },
1809
+ hasNewerMessages: { type: Boolean, default: false },
1810
+ isLoadingNewer: { type: Boolean, default: false },
1811
+ onLoadNewer: { type: Function, default: void 0 },
1215
1812
  scrollElement: { type: Object, default: void 0 },
1216
1813
  paginationThreshold: { type: Number, default: 240 },
1217
1814
  reverse: { type: Boolean, default: true },
@@ -1219,13 +1816,18 @@ var MessageListView = defineComponent2({
1219
1816
  formatTime: { type: Function, default: formatMessageTime },
1220
1817
  imageLoading: { type: String, default: "lazy" }
1221
1818
  },
1222
- emits: ["load-older", "attachment-click", "edit-message", "delete-message"],
1819
+ emits: ["load-older", "load-newer", "attachment-click", "edit-message", "delete-message", "reply-to-message", "jump-to-message"],
1223
1820
  setup(props, { attrs, emit, slots }) {
1224
1821
  const internalElement = ref(null);
1225
1822
  const confirming = ref(null);
1823
+ const highlightCleared = ref(false);
1226
1824
  let requestInFlight = false;
1227
1825
  let lastRequestedLength = null;
1826
+ let newerInFlight = false;
1827
+ let lastNewerLength = null;
1228
1828
  let previousMessageCount = 0;
1829
+ let scrolledTo = null;
1830
+ let jumpArmed = false;
1229
1831
  const participants = computed2(() => new Map(props.conversation.participants.flatMap((participant) => [
1230
1832
  [participant.id, participant],
1231
1833
  [participant.appUserId, participant]
@@ -1248,13 +1850,26 @@ var MessageListView = defineComponent2({
1248
1850
  requestInFlight = false;
1249
1851
  }
1250
1852
  };
1251
- watch2(() => [props.messages.length, props.hasOlderMessages], async ([count, hasOlder]) => {
1853
+ const requestNewer = async () => {
1854
+ if (newerInFlight || lastNewerLength === props.messages.length || props.isLoadingNewer || !props.hasNewerMessages || !props.onLoadNewer) return;
1855
+ newerInFlight = true;
1856
+ lastNewerLength = props.messages.length;
1857
+ try {
1858
+ await props.onLoadNewer();
1859
+ } catch {
1860
+ lastNewerLength = null;
1861
+ } finally {
1862
+ newerInFlight = false;
1863
+ }
1864
+ };
1865
+ watch2(() => [props.messages.length, props.hasOlderMessages, props.hasNewerMessages], async ([count, hasOlder, hasNewer]) => {
1252
1866
  const previous = previousMessageCount;
1253
1867
  if (count !== previousMessageCount || !hasOlder) lastRequestedLength = null;
1868
+ if (count !== previousMessageCount || !hasNewer) lastNewerLength = null;
1254
1869
  const appended = count > previous;
1255
1870
  const element = internalElement.value;
1256
1871
  previousMessageCount = count;
1257
- if (element && props.reverse && props.stickToBottom && appended) {
1872
+ if (element && props.reverse && props.stickToBottom && appended && !props.jumpInFlight) {
1258
1873
  const distanceFromBottom = element.scrollHeight - element.scrollTop - element.clientHeight;
1259
1874
  if (previous === 0 || distanceFromBottom < 320) {
1260
1875
  await nextTick();
@@ -1262,11 +1877,43 @@ var MessageListView = defineComponent2({
1262
1877
  }
1263
1878
  }
1264
1879
  }, { flush: "post", immediate: true });
1880
+ watch2(() => [props.highlightedMessageId, props.messages, props.jumpInFlight], ([highlighted, , inFlight]) => {
1881
+ if (inFlight && !jumpArmed) scrolledTo = null;
1882
+ jumpArmed = !!inFlight;
1883
+ if (!highlighted) {
1884
+ scrolledTo = null;
1885
+ highlightCleared.value = false;
1886
+ return;
1887
+ }
1888
+ if (scrolledTo === highlighted) return;
1889
+ const root = internalElement.value;
1890
+ const row = root ? [...root.querySelectorAll("[data-message-id]")].find((node) => node.dataset.messageId === highlighted) : void 0;
1891
+ if (!row) return;
1892
+ scrolledTo = highlighted;
1893
+ highlightCleared.value = false;
1894
+ row.scrollIntoView({ block: "center", behavior: "instant" });
1895
+ if (props.onJumpToMessage) row.focus({ preventScroll: true });
1896
+ }, { flush: "post" });
1265
1897
  const viewerRole = computed2(() => props.conversation.membership?.role ?? props.conversation.participants.find((participant) => participant.appUserId === props.currentUserId || participant.id === props.currentUserId)?.role);
1266
1898
  const remove = async (message) => {
1267
1899
  if (props.confirmDelete && !await props.confirmDelete(message)) return false;
1268
1900
  return await props.onDeleteMessage?.(message) !== false;
1269
1901
  };
1902
+ const renderQuote = (parentId, preview, jump) => {
1903
+ const resolved = preview === void 0 || preview === "unavailable" ? void 0 : preview;
1904
+ const author = resolved ? participants.value.get(resolved.senderId)?.name || resolved.senderId : void 0;
1905
+ const attachments = resolved && resolved.mediaCount > 0 ? resolved.mediaCount === 1 ? "1 attachment" : `${resolved.mediaCount} attachments` : "";
1906
+ const body = resolved ? resolved.text?.trim() || attachments : preview === "unavailable" ? "Original message unavailable" : "";
1907
+ return h2(jump ? "button" : "div", {
1908
+ class: cx("ckui-message-quote", preview === "unavailable" && "ckui-message-quote--unavailable"),
1909
+ "data-reply-to": parentId,
1910
+ "aria-label": resolved ? `Quoted message from ${author}` : preview === "unavailable" ? "Original message unavailable" : "Quoted message",
1911
+ ...jump ? { type: "button", onClick: jump } : {}
1912
+ }, [
1913
+ ...author ? [h2("strong", { class: "ckui-message-quote__author" }, author)] : [],
1914
+ ...body ? [h2("span", { class: "ckui-message-quote__body" }, body)] : []
1915
+ ]);
1916
+ };
1270
1917
  const renderMessage = (message, index) => {
1271
1918
  const isCurrentUser = message.senderId === props.currentUserId;
1272
1919
  const sender = participants.value.get(message.senderId);
@@ -1276,9 +1923,19 @@ var MessageListView = defineComponent2({
1276
1923
  const eligible = !isPending && (props.canEditMessage ? props.canEditMessage(message) : isCurrentUser && viewerRole.value !== "READ");
1277
1924
  const canEdit = eligible && !!props.onEditMessage;
1278
1925
  const canDelete = eligible && !!props.onDeleteMessage;
1926
+ const replyEligible = !isPending && (props.canReplyToMessage ? props.canReplyToMessage(message) : viewerRole.value !== "READ");
1927
+ const canReply = replyEligible && !!props.onReplyToMessage;
1279
1928
  const edit = () => {
1280
1929
  props.onEditMessage?.(message);
1281
1930
  };
1931
+ const replyTo = () => {
1932
+ props.onReplyToMessage?.(message);
1933
+ };
1934
+ const parentId = message.replyToMessageId;
1935
+ const replyPreview = parentId ? props.replyPreviewByMessageId?.get(parentId) : void 0;
1936
+ const jumpToReplyTarget = parentId && props.onJumpToMessage ? () => {
1937
+ props.onJumpToMessage?.(parentId);
1938
+ } : void 0;
1282
1939
  const slotProps = {
1283
1940
  message,
1284
1941
  chronologicalIndex: index,
@@ -1288,11 +1945,27 @@ var MessageListView = defineComponent2({
1288
1945
  isEdited,
1289
1946
  canEdit,
1290
1947
  canDelete,
1948
+ canReply,
1291
1949
  ...canEdit ? { edit } : {},
1292
- ...canDelete ? { remove: () => remove(message) } : {}
1950
+ ...canDelete ? { remove: () => remove(message) } : {},
1951
+ ...canReply ? { reply: replyTo } : {},
1952
+ ...parentId && replyPreview !== void 0 ? { replyPreview } : {},
1953
+ ...jumpToReplyTarget ? { jumpToReplyTarget } : {}
1293
1954
  };
1955
+ const anchor = {
1956
+ "data-message-id": message.id,
1957
+ ...props.onJumpToMessage ? { tabindex: "-1" } : {}
1958
+ };
1959
+ const highlighted = !highlightCleared.value && props.highlightedMessageId === message.id;
1294
1960
  const custom = slots.message?.(slotProps);
1295
- if (custom) return h2("div", { key: message.id, role: "listitem" }, custom);
1961
+ if (custom) {
1962
+ return h2("div", {
1963
+ key: message.id,
1964
+ role: "listitem",
1965
+ ...anchor,
1966
+ ...highlighted ? { class: "ckui-message-highlight" } : {}
1967
+ }, custom);
1968
+ }
1296
1969
  const currentAppearance = appearance();
1297
1970
  const messagePart = isCurrentUser ? "outgoingMessage" : "incomingMessage";
1298
1971
  const iconButton = (label, onClick, icon) => h2("button", {
@@ -1302,7 +1975,8 @@ var MessageListView = defineComponent2({
1302
1975
  class: partClass("button", currentAppearance, "ckui-icon-button"),
1303
1976
  style: partStyle("button", currentAppearance)
1304
1977
  }, [icon]);
1305
- const actions = canEdit || canDelete ? h2("div", { class: "ckui-message-actions" }, [
1978
+ const actions = canReply || canEdit || canDelete ? h2("div", { class: "ckui-message-actions" }, [
1979
+ ...canReply ? [iconButton("Reply to message", replyTo, h2(Reply, { size: 16, "aria-hidden": "true" }))] : [],
1306
1980
  ...canEdit ? [iconButton("Edit message", edit, h2(Pencil, { size: 16, "aria-hidden": "true" }))] : [],
1307
1981
  ...canDelete ? [iconButton("Delete message", () => {
1308
1982
  if (props.confirmDelete) void remove(message);
@@ -1345,21 +2019,24 @@ var MessageListView = defineComponent2({
1345
2019
  }, slots.media?.(mediaSlotProps) ?? [defaultMedia(media, open, props.imageLoading)]);
1346
2020
  });
1347
2021
  const receiptSlotProps = { message, readerIds };
2022
+ const quote = parentId ? renderQuote(parentId, replyPreview, jumpToReplyTarget) : null;
1348
2023
  return h2("div", { key: message.id, role: "listitem" }, [
1349
2024
  h2("article", {
1350
2025
  class: cx(
1351
2026
  !props.unstyled && "ckui-message-row",
1352
2027
  isCurrentUser && !props.unstyled && "ckui-message-row--outgoing",
2028
+ highlighted && "ckui-message-highlight",
1353
2029
  props.classNames?.message,
1354
2030
  props.classNames?.[messagePart]
1355
2031
  ),
1356
2032
  style: [props.styles?.message, props.styles?.[messagePart]],
1357
- "data-message-id": message.id
2033
+ ...anchor
1358
2034
  }, [
1359
2035
  // Spread, not null: an absent action/label/prompt must not leave a comment node (0.7 markup stays byte-identical).
1360
2036
  ...actions ? [actions] : [],
1361
2037
  h2("div", { class: "ckui-message-bubble" }, [
1362
2038
  !isCurrentUser ? h2("strong", { class: "ckui-message-sender" }, sender?.name || message.senderId) : null,
2039
+ ...quote ? [quote] : [],
1363
2040
  message.text ? h2("div", { class: "ckui-message-text" }, message.text) : null,
1364
2041
  ...mediaNodes,
1365
2042
  h2("span", { class: "ckui-message-time" }, [
@@ -1407,6 +2084,13 @@ var MessageListView = defineComponent2({
1407
2084
  } else {
1408
2085
  children.push(...props.messages.map(renderMessage));
1409
2086
  }
2087
+ if (props.isLoadingNewer) {
2088
+ children.push(slots["loading-newer"]?.() ?? h2("div", {
2089
+ class: partClass("loading", currentAppearance, "ckui-inline-state"),
2090
+ style: partStyle("loading", currentAppearance),
2091
+ role: "status"
2092
+ }, [h2(LoaderCircle, { class: "ckui-spin", "aria-hidden": "true" }), " Loading newer messages\u2026"]));
2093
+ }
1410
2094
  return h2("div", {
1411
2095
  ...attrs,
1412
2096
  ref: (element) => {
@@ -1419,12 +2103,18 @@ var MessageListView = defineComponent2({
1419
2103
  role: "log",
1420
2104
  "aria-live": "polite",
1421
2105
  "aria-label": `Messages in ${props.conversation.displayTitle}`,
2106
+ // Omitted entirely while idle, never `aria-busy="false"`: the 0.7 markup of this element is pinned.
2107
+ ...props.jumpInFlight ? { "aria-busy": "true" } : {},
1422
2108
  onScroll: (event) => {
1423
2109
  const nativeHandler = attrs.onScroll;
1424
2110
  if (typeof nativeHandler === "function") nativeHandler(event);
2111
+ if (props.jumpInFlight) return;
2112
+ if (props.highlightedMessageId && !highlightCleared.value) highlightCleared.value = true;
1425
2113
  const element = event.currentTarget;
1426
2114
  const distanceFromOldest = props.reverse ? element.scrollTop : element.scrollHeight - element.scrollTop - element.clientHeight;
1427
2115
  if (distanceFromOldest <= props.paginationThreshold) void requestOlder();
2116
+ const distanceFromNewest = props.reverse ? element.scrollHeight - element.scrollTop - element.clientHeight : element.scrollTop;
2117
+ if (distanceFromNewest <= props.paginationThreshold) void requestNewer();
1428
2118
  }
1429
2119
  }, children);
1430
2120
  };
@@ -1464,6 +2154,18 @@ var viewProps = {
1464
2154
  onDeleteMessage: { type: Function, default: void 0 },
1465
2155
  canEditMessage: { type: Function, default: void 0 },
1466
2156
  confirmDelete: { type: Function, default: void 0 },
2157
+ replyTarget: { type: Object, default: null },
2158
+ onReplyToMessage: { type: Function, default: void 0 },
2159
+ onCancelReply: { type: Function, default: void 0 },
2160
+ canReplyToMessage: { type: Function, default: void 0 },
2161
+ replyPreviewByMessageId: { type: Object, default: void 0 },
2162
+ onJumpToMessage: { type: Function, default: void 0 },
2163
+ highlightedMessageId: { type: String, default: null },
2164
+ jumpInFlight: { type: Boolean, default: false },
2165
+ hasNewerMessages: { type: Boolean, default: false },
2166
+ isLoadingNewer: { type: Boolean, default: false },
2167
+ onLoadNewer: { type: Function, default: void 0 },
2168
+ onReturnToLatest: { type: Function, default: void 0 },
1467
2169
  isInitialLoading: { type: Boolean, default: false },
1468
2170
  isLoadingOlder: { type: Boolean, default: false },
1469
2171
  isSending: { type: Boolean, default: false },
@@ -1483,7 +2185,7 @@ var viewProps = {
1483
2185
  defaultDraft: { type: String, default: "" },
1484
2186
  onDraftChange: { type: Function, default: void 0 }
1485
2187
  };
1486
- function editingSummary(message) {
2188
+ function messageSummary(message) {
1487
2189
  return message.text?.trim() || (message.media.length === 1 ? "1 attachment" : `${message.media.length} attachments`);
1488
2190
  }
1489
2191
  function typingLabel(userIds, displayNameForUser) {
@@ -1509,7 +2211,12 @@ var ConversationView = defineComponent3({
1509
2211
  "edit-message",
1510
2212
  "save-edit",
1511
2213
  "cancel-edit",
1512
- "delete-message"
2214
+ "delete-message",
2215
+ "reply-to-message",
2216
+ "cancel-reply",
2217
+ "jump-to-message",
2218
+ "load-newer",
2219
+ "return-to-latest"
1513
2220
  ],
1514
2221
  setup(props, { attrs, emit, slots }) {
1515
2222
  const internalDraft = ref2(props.defaultDraft);
@@ -1552,6 +2259,10 @@ var ConversationView = defineComponent3({
1552
2259
  leaveEdit(editing, "always");
1553
2260
  props.onCancelEdit?.();
1554
2261
  };
2262
+ const cancelReply = () => {
2263
+ props.onCancelReply?.();
2264
+ };
2265
+ const returnToLatest = () => props.onReturnToLatest?.();
1555
2266
  const canSave = (editing) => draft().trim().length > 0 || editing.media.length > 0;
1556
2267
  const submit = async () => {
1557
2268
  const editing = props.editingMessage;
@@ -1596,6 +2307,7 @@ var ConversationView = defineComponent3({
1596
2307
  };
1597
2308
  const refresh = () => props.onRefresh?.();
1598
2309
  const loadOlder = () => props.onLoadOlder?.();
2310
+ const loadNewer = () => props.onLoadNewer?.();
1599
2311
  const addAttachment = () => {
1600
2312
  props.onAddAttachment?.();
1601
2313
  };
@@ -1642,6 +2354,7 @@ var ConversationView = defineComponent3({
1642
2354
  };
1643
2355
  const renderComposer = () => {
1644
2356
  const editing = props.editingMessage;
2357
+ const replying = editing ? null : props.replyTarget;
1645
2358
  const slotProps = {
1646
2359
  value: draft(),
1647
2360
  setValue: setDraft,
@@ -1650,11 +2363,16 @@ var ConversationView = defineComponent3({
1650
2363
  void submit();
1651
2364
  },
1652
2365
  ...props.onAddAttachment ? { addAttachment } : {},
1653
- ...editing ? { editing, cancelEdit } : {}
2366
+ ...editing ? { editing, cancelEdit } : {},
2367
+ ...replying ? { replying, cancelReply } : {}
1654
2368
  };
1655
2369
  const busy = props.isSending || submitting.value;
1656
2370
  return slots.composer?.(slotProps) ?? h3("form", {
1657
- class: cx(partClass("composer", appearance(), "ckui-composer"), editing && !props.unstyled && "ckui-composer--editing"),
2371
+ class: cx(
2372
+ partClass("composer", appearance(), "ckui-composer"),
2373
+ editing && !props.unstyled && "ckui-composer--editing",
2374
+ replying && !props.unstyled && "ckui-composer--replying"
2375
+ ),
1658
2376
  style: partStyle("composer", appearance()),
1659
2377
  onSubmit: (event) => {
1660
2378
  event.preventDefault();
@@ -1666,10 +2384,18 @@ var ConversationView = defineComponent3({
1666
2384
  h3(Pencil2, { size: 14, "aria-hidden": "true" }),
1667
2385
  h3("span", { class: "ckui-composer__editing-body" }, [
1668
2386
  h3("strong", "Editing message"),
1669
- h3("span", editingSummary(editing))
2387
+ h3("span", messageSummary(editing))
1670
2388
  ]),
1671
2389
  h3("button", { type: "button", class: "ckui-link-button", "aria-label": "Cancel editing", onClick: cancelEdit }, "Cancel")
1672
2390
  ])] : [],
2391
+ ...replying ? [h3("div", { class: "ckui-composer__replying", role: "status" }, [
2392
+ h3(Reply2, { size: 14, "aria-hidden": "true" }),
2393
+ h3("span", { class: "ckui-composer__replying-body" }, [
2394
+ h3("strong", `Replying to ${nameForUser(replying.senderId)}`),
2395
+ h3("span", messageSummary(replying))
2396
+ ]),
2397
+ h3("button", { type: "button", class: "ckui-link-button", "aria-label": "Cancel reply", onClick: cancelReply }, "Cancel")
2398
+ ])] : [],
1673
2399
  props.onAddAttachment ? h3("button", {
1674
2400
  type: "button",
1675
2401
  "aria-label": "Add attachment",
@@ -1701,6 +2427,9 @@ var ConversationView = defineComponent3({
1701
2427
  if (event.key === "Escape" && props.editingMessage) {
1702
2428
  event.preventDefault();
1703
2429
  cancelEdit();
2430
+ } else if (event.key === "Escape" && props.replyTarget) {
2431
+ event.preventDefault();
2432
+ cancelReply();
1704
2433
  }
1705
2434
  }
1706
2435
  }),
@@ -1743,6 +2472,7 @@ var ConversationView = defineComponent3({
1743
2472
  ...slots["read-receipt"] ? { "read-receipt": slots["read-receipt"] } : {},
1744
2473
  ...slots.empty ? { empty: slots.empty } : {},
1745
2474
  ...slots["loading-older"] ? { "loading-older": slots["loading-older"] } : {},
2475
+ ...slots["loading-newer"] ? { "loading-newer": slots["loading-newer"] } : {},
1746
2476
  ...slots["message-error"] ? { error: slots["message-error"] } : {}
1747
2477
  };
1748
2478
  children.push(h3(MessageListView, {
@@ -1765,6 +2495,19 @@ var ConversationView = defineComponent3({
1765
2495
  ...props.onDeleteMessage ? { onDeleteMessage: (message) => props.onDeleteMessage?.(message) } : {},
1766
2496
  ...props.canEditMessage ? { canEditMessage: props.canEditMessage } : {},
1767
2497
  ...props.confirmDelete ? { confirmDelete: props.confirmDelete } : {},
2498
+ ...props.onReplyToMessage ? { onReplyToMessage: (message) => {
2499
+ props.onReplyToMessage?.(message);
2500
+ } } : {},
2501
+ ...props.canReplyToMessage ? { canReplyToMessage: props.canReplyToMessage } : {},
2502
+ ...props.replyPreviewByMessageId ? { replyPreviewByMessageId: props.replyPreviewByMessageId } : {},
2503
+ ...props.onJumpToMessage ? { onJumpToMessage: (messageId) => {
2504
+ props.onJumpToMessage?.(messageId);
2505
+ } } : {},
2506
+ highlightedMessageId: props.highlightedMessageId,
2507
+ jumpInFlight: props.jumpInFlight,
2508
+ hasNewerMessages: props.hasNewerMessages,
2509
+ isLoadingNewer: props.isLoadingNewer,
2510
+ ...props.onLoadNewer ? { onLoadNewer: loadNewer } : {},
1768
2511
  reverse: props.reverseMessages,
1769
2512
  stickToBottom: props.stickToBottom,
1770
2513
  paginationThreshold: props.paginationThreshold,
@@ -1775,6 +2518,19 @@ var ConversationView = defineComponent3({
1775
2518
  density: props.density,
1776
2519
  unstyled: props.unstyled
1777
2520
  }, messageSlots));
2521
+ if (props.onReturnToLatest) {
2522
+ const jumpSlotProps = { returnToLatest };
2523
+ children.push(slots["jump-to-latest"]?.(jumpSlotProps) ?? h3("div", { class: "ckui-conversation-jump" }, [
2524
+ h3("button", {
2525
+ type: "button",
2526
+ class: "ckui-link-button",
2527
+ "aria-label": "Jump to latest messages",
2528
+ onClick: () => {
2529
+ void returnToLatest();
2530
+ }
2531
+ }, [h3(ArrowDown, { size: 14, "aria-hidden": "true" }), " Jump to latest"])
2532
+ ]));
2533
+ }
1778
2534
  children.push(renderTyping(), renderComposer());
1779
2535
  return h3("section", {
1780
2536
  ...attrs,
@@ -1800,6 +2556,10 @@ var Conversation = defineComponent3({
1800
2556
  onSaveEdit: { type: Function, default: void 0 },
1801
2557
  onCancelEdit: { type: Function, default: void 0 },
1802
2558
  onDeleteMessage: { type: Function, default: void 0 },
2559
+ replyTarget: { type: Object, default: void 0 },
2560
+ onReplyToMessage: { type: Function, default: void 0 },
2561
+ onCancelReply: { type: Function, default: void 0 },
2562
+ onJumpToMessage: { type: Function, default: void 0 },
1803
2563
  client: { type: Object, required: true },
1804
2564
  conversationId: { type: String, required: true },
1805
2565
  messagePageSize: { type: Number, default: 30 },
@@ -1822,7 +2582,12 @@ var Conversation = defineComponent3({
1822
2582
  "edit-message",
1823
2583
  "save-edit",
1824
2584
  "cancel-edit",
1825
- "delete-message"
2585
+ "delete-message",
2586
+ "reply-to-message",
2587
+ "cancel-reply",
2588
+ "jump-to-message",
2589
+ "load-newer",
2590
+ "return-to-latest"
1826
2591
  ],
1827
2592
  setup(props, { attrs, emit, expose, slots }) {
1828
2593
  const controller = useConversation({
@@ -1892,6 +2657,17 @@ var Conversation = defineComponent3({
1892
2657
  onSaveEdit: _onSaveEdit,
1893
2658
  onCancelEdit: _onCancelEdit,
1894
2659
  onDeleteMessage: _onDeleteMessage,
2660
+ replyTarget: _replyTarget,
2661
+ onReplyToMessage: _onReplyToMessage,
2662
+ onCancelReply: _onCancelReply,
2663
+ replyPreviewByMessageId: _replyPreviewByMessageId,
2664
+ onJumpToMessage: _onJumpToMessage,
2665
+ highlightedMessageId: _highlightedMessageId,
2666
+ jumpInFlight: _jumpInFlight,
2667
+ hasNewerMessages: _hasNewerMessages,
2668
+ isLoadingNewer: _isLoadingNewer,
2669
+ onLoadNewer: _onLoadNewer,
2670
+ onReturnToLatest: _onReturnToLatest,
1895
2671
  ...forwarded
1896
2672
  } = props;
1897
2673
  return h3(ConversationView, {
@@ -1937,6 +2713,39 @@ var Conversation = defineComponent3({
1937
2713
  return controller.deleteMessage(message.id);
1938
2714
  }
1939
2715
  } : {},
2716
+ // Quoted replies and jump windows are the store's (0.9.0). Replying needs no adapter member; the jump
2717
+ // affordances appear only while the adapter can fetch a context window, and disappear for the store's
2718
+ // life against a backend that does not serve the route.
2719
+ replyTarget: controller.replyTarget.value,
2720
+ onReplyToMessage: (message) => {
2721
+ emit("reply-to-message", message);
2722
+ controller.startReply(message.id);
2723
+ },
2724
+ onCancelReply: () => {
2725
+ emit("cancel-reply");
2726
+ controller.cancelReply();
2727
+ },
2728
+ replyPreviewByMessageId: controller.replyPreviews.value,
2729
+ highlightedMessageId: controller.highlightedMessageId.value,
2730
+ jumpInFlight: controller.jumpInFlight.value,
2731
+ hasNewerMessages: controller.hasNewerMessages.value,
2732
+ isLoadingNewer: controller.isLoadingNewer.value,
2733
+ ...controller.canJumpToMessage.value ? {
2734
+ onJumpToMessage: (messageId) => {
2735
+ emit("jump-to-message", messageId);
2736
+ void controller.jumpToMessage(messageId);
2737
+ }
2738
+ } : {},
2739
+ ...controller.windowMode.value === "jumped" ? {
2740
+ onLoadNewer: () => {
2741
+ emit("load-newer");
2742
+ return controller.loadNewerMessages();
2743
+ },
2744
+ onReturnToLatest: () => {
2745
+ emit("return-to-latest");
2746
+ return controller.returnToLatest();
2747
+ }
2748
+ } : {},
1940
2749
  "onUpdate:modelValue": (value) => emit("update:modelValue", value),
1941
2750
  ...props.onBack ? { onBack: () => {
1942
2751
  props.onBack?.();
@@ -2773,6 +3582,7 @@ var defaultConvoKitTheme = {
2773
3582
  outgoingBubble: "#18181b",
2774
3583
  outgoingText: "#fafafa",
2775
3584
  badge: "#18181b",
3585
+ highlight: "color-mix(in srgb, #18181b 14%, transparent)",
2776
3586
  radius: "10px",
2777
3587
  avatarSize: "40px",
2778
3588
  fontFamily: 'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'
@@ -2805,6 +3615,7 @@ var ConvoKitThemeProvider = defineComponent5({
2805
3615
  "--ckui-outgoing": theme.outgoingBubble,
2806
3616
  "--ckui-outgoing-text": theme.outgoingText,
2807
3617
  "--ckui-badge": theme.badge,
3618
+ "--ckui-highlight": theme.highlight,
2808
3619
  "--ckui-radius": theme.radius,
2809
3620
  "--ckui-avatar-size": theme.avatarSize,
2810
3621
  "--ckui-font": theme.fontFamily