@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.cjs CHANGED
@@ -74,6 +74,8 @@ function createConvoKitUiClient(client) {
74
74
  sendMessage: (input) => client.sendMessage(input),
75
75
  editMessage: (messageId, input) => client.editMessage(messageId, input),
76
76
  deleteMessage: (messageId) => client.deleteMessage(messageId),
77
+ getReplyPreviews: (conversationId, messageIds) => client.getReplyPreviews(conversationId, messageIds),
78
+ getMessageContext: (conversationId, options) => client.getMessageContext(conversationId, options),
77
79
  markConversationRead: (conversationId, options) => client.markConversationRead(conversationId, options),
78
80
  markConversationUnread: (conversationId) => client.markConversationUnread(conversationId),
79
81
  clearConversationUnread: (conversationId, options) => client.clearConversationUnread(conversationId, options),
@@ -273,6 +275,8 @@ function isMessageMissing(cause) {
273
275
  function localConflict() {
274
276
  return Object.assign(new Error("Message was changed since it was loaded"), { code: "REVISION_CONFLICT" });
275
277
  }
278
+ var JUMP_GUARD_MS = 150;
279
+ var HIGHLIGHT_MS = 2e3;
276
280
  var compare = compareMessageOrder;
277
281
  function positionCursor(position) {
278
282
  return { createdAt: position.createdAt, id: position.messageId };
@@ -292,7 +296,26 @@ function isTargetMiss(cause) {
292
296
  const { code, status } = cause;
293
297
  return code === "MESSAGE_NOT_FOUND" || code === void 0 && status === 404;
294
298
  }
295
- function blank(currentUserId = "", support = { edit: false, delete: false }) {
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 }) {
296
319
  return {
297
320
  conversation: null,
298
321
  messages: [],
@@ -309,7 +332,16 @@ function blank(currentUserId = "", support = { edit: false, delete: false }) {
309
332
  currentUserId,
310
333
  editingMessage: null,
311
334
  canEditMessages: support.edit,
312
- canDeleteMessages: support.delete
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
313
345
  };
314
346
  }
315
347
  var ConversationStore = class {
@@ -328,7 +360,12 @@ var ConversationStore = class {
328
360
  }
329
361
  this.owner = this.client.sessionIdentity;
330
362
  this.user = this.owner ? this.client.currentUserId : "";
331
- this.support = { edit: typeof this.client.editMessage === "function", delete: typeof this.client.deleteMessage === "function" };
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
+ };
332
369
  this.state = blank(this.user, this.support);
333
370
  }
334
371
  options;
@@ -364,6 +401,26 @@ var ConversationStore = class {
364
401
  */
365
402
  activeEdit;
366
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;
367
424
  typingTimers = /* @__PURE__ */ new Map();
368
425
  ownTypingTimer;
369
426
  sentTyping = false;
@@ -453,6 +510,19 @@ var ConversationStore = class {
453
510
  this.activeSend = void 0;
454
511
  this.activeEdit = void 0;
455
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;
456
526
  }
457
527
  dispose = () => {
458
528
  if (this.alive() && this.sentTyping) {
@@ -482,8 +552,10 @@ var ConversationStore = class {
482
552
  add(() => this.client.onConnectionEvent({
483
553
  onEvent: ({ topic, status }) => {
484
554
  if (!data || !this.alive(generation) || topic !== `messages:${this.room}` && topic !== `conversation:${this.room}`) return;
485
- if (status === "SUBSCRIBED") this.queueRefresh();
486
- else this.clearTyping();
555
+ if (status === "SUBSCRIBED") {
556
+ this.markPreviewsStale();
557
+ this.queueRefresh();
558
+ } else this.clearTyping();
487
559
  },
488
560
  onSessionEnded: () => {
489
561
  if (this.disposed || generation !== this.generation) return;
@@ -541,7 +613,10 @@ var ConversationStore = class {
541
613
  const known = existing ?? this.changes.get(message.id)?.message;
542
614
  if (known && older(message, known)) return;
543
615
  const insert = type === "insert" || this.changes.get(message.id)?.insert === true;
544
- 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
+ }
545
620
  const revision = ++this.revision;
546
621
  const provisional = existing && !message.media.length ? { ...message, media: existing.media } : message;
547
622
  this.record(provisional, insert, revision, false);
@@ -559,7 +634,14 @@ var ConversationStore = class {
559
634
  }
560
635
  this.changes.set(message.id, { revision, message, insert, complete });
561
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
+ }
562
642
  this.patch({ messages: mergeMessages(this.state.messages, [message]) });
643
+ this.notePreviewSource(message.id);
644
+ if (message.replyToMessageId) this.schedulePreviews();
563
645
  if (!existing && message.senderId !== this.user && (this.options.markReadOnReceive ?? true)) void this.acknowledge(true);
564
646
  }
565
647
  confirmSend(message) {
@@ -581,6 +663,9 @@ var ConversationStore = class {
581
663
  this.changes.delete(id);
582
664
  this.hydrations.delete(id);
583
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 });
584
669
  const ack = this.ack;
585
670
  if (ack.target !== id && ack.acknowledged?.id !== id) return;
586
671
  ack.unacknowledgeable.add(id);
@@ -649,6 +734,23 @@ var ConversationStore = class {
649
734
  previous = message;
650
735
  }
651
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
+ }
652
754
  fetchPage(before) {
653
755
  return this.client.getMessages({
654
756
  conversationId: this.room,
@@ -671,6 +773,148 @@ var ConversationStore = class {
671
773
  }
672
774
  return mergeMessages([], [...byId.values()]);
673
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
+ }
674
918
  prune(revision) {
675
919
  const safeRevision = Math.min(revision, this.sendRevision ?? Infinity);
676
920
  for (const [id, change] of this.changes) if (change.revision <= safeRevision) this.changes.delete(id);
@@ -697,6 +941,7 @@ var ConversationStore = class {
697
941
  this.captured = capture(conversation);
698
942
  this.mergeReads(conversation.participants.map(readEntry));
699
943
  this.prune(revision);
944
+ void this.resolvePreviews();
700
945
  if ((this.options.markReadOnLoad ?? true) || this.ack.suppressed) {
701
946
  this.ack.suppressed = false;
702
947
  await this.acknowledge(true);
@@ -712,19 +957,36 @@ var ConversationStore = class {
712
957
  };
713
958
  queueRefresh() {
714
959
  this.refreshQueued = true;
960
+ this.jumpedRefreshQueued = true;
715
961
  this.flushRefresh();
716
962
  }
717
963
  flushRefresh() {
718
964
  const generation = this.generation;
719
965
  void Promise.resolve().then(() => {
720
- 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
+ }
721
973
  this.refreshQueued = false;
974
+ this.jumpedRefreshQueued = false;
722
975
  void this.refresh();
723
976
  });
724
977
  }
725
978
  /** Re-fetch the entire viewed range atomically; a first-page-only refresh loses history. */
726
979
  refresh = async () => {
727
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
+ }
728
990
  if (this.state.isInitialLoading || this.state.isLoadingOlder || this.state.isReconciling) {
729
991
  this.refreshQueued = true;
730
992
  return;
@@ -762,6 +1024,7 @@ var ConversationStore = class {
762
1024
  if (opening) this.captured = capture(conversation);
763
1025
  this.mergeReads(conversation.participants.map(readEntry));
764
1026
  this.prune(revision);
1027
+ void this.resolvePreviews(true);
765
1028
  if (opening) void this.resumeAcknowledgement();
766
1029
  } catch (cause) {
767
1030
  this.fail(cause, generation, true);
@@ -773,7 +1036,8 @@ var ConversationStore = class {
773
1036
  }
774
1037
  };
775
1038
  loadOlderMessages = async () => {
776
- 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");
777
1041
  const generation = this.generation;
778
1042
  const revision = this.revision;
779
1043
  const cursor = this.cursor;
@@ -787,6 +1051,7 @@ var ConversationStore = class {
787
1051
  messages: this.overlay(mergeMessages(page, this.state.messages), revision),
788
1052
  hasOlderMessages: page.length === this.pageSize
789
1053
  });
1054
+ void this.resolvePreviews();
790
1055
  } catch (cause) {
791
1056
  this.fail(cause, generation, true);
792
1057
  } finally {
@@ -796,6 +1061,298 @@ var ConversationStore = class {
796
1061
  }
797
1062
  }
798
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
+ };
799
1356
  /** Acknowledge through the newest rendered row now, regardless of visibility; no acknowledgement without a
800
1357
  * target (a room opened with a marker that renders nothing clears the marker instead, once).
801
1358
  */
@@ -825,6 +1382,7 @@ var ConversationStore = class {
825
1382
  }
826
1383
  /** Resolves when the request this call issued or joined settles; a follow-up is issued, not awaited. */
827
1384
  acknowledge(automatic) {
1385
+ if (this.state.windowMode === "jumped") return Promise.resolve();
828
1386
  const ack = this.ack;
829
1387
  if (automatic && (!this.visible || this.state.conversation === null)) {
830
1388
  ack.suppressed = true;
@@ -837,6 +1395,10 @@ var ConversationStore = class {
837
1395
  return this.issue(ack, this.generation) ?? Promise.resolve();
838
1396
  }
839
1397
  issue(ack, generation) {
1398
+ if (this.state.windowMode === "jumped") {
1399
+ ack.followUp = true;
1400
+ return void 0;
1401
+ }
840
1402
  ack.followUp = false;
841
1403
  const target = this.ackTarget(ack);
842
1404
  const request = target ? this.send(ack, generation, target) : this.clearMarker(ack, generation);
@@ -913,11 +1475,14 @@ var ConversationStore = class {
913
1475
  sendMessage = async ({ text, media }) => {
914
1476
  const normalized = text?.trim();
915
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;
916
1480
  const generation = this.generation;
917
1481
  const revision = this.revision;
918
1482
  this.sendRevision = revision;
919
1483
  const clientMessageId = (0, import_sdk2.createClientMessageId)();
920
1484
  const pendingId = `convokit-pending-${clientMessageId}`;
1485
+ const replyToMessageId = this.state.replyTarget?.id;
921
1486
  const pending = {
922
1487
  id: pendingId,
923
1488
  clientMessageId,
@@ -927,7 +1492,8 @@ var ConversationStore = class {
927
1492
  media: media ?? [],
928
1493
  createdAt: /* @__PURE__ */ new Date(),
929
1494
  updatedAt: null,
930
- revision: 0
1495
+ revision: 0,
1496
+ ...replyToMessageId ? { replyToMessageId } : {}
931
1497
  };
932
1498
  const send = { pending };
933
1499
  this.activeSend = send;
@@ -937,7 +1503,9 @@ var ConversationStore = class {
937
1503
  conversationId: this.room,
938
1504
  clientMessageId,
939
1505
  ...normalized ? { text: normalized } : {},
940
- ...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 } : {}
941
1509
  });
942
1510
  if (!this.alive(generation)) return null;
943
1511
  if (!this.validMessage(message) || message.senderId !== this.user) throw new Error("Send response belongs to a different room or sender");
@@ -947,10 +1515,15 @@ var ConversationStore = class {
947
1515
  let latest = existing ? newest(message, existing, live?.complete !== false) : message;
948
1516
  if (live && live.revision > revision) latest = newest(latest, live.message, live.complete);
949
1517
  if (!this.deleted.has(message.id)) this.changes.set(message.id, { revision: ++this.revision, message: latest, insert: true, complete: true });
950
- this.patch({ messages: mergeMessages(
951
- this.state.messages.filter((item) => item.id !== pendingId),
952
- this.deleted.has(message.id) ? [] : [latest]
953
- ) });
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();
954
1527
  void this.updateTyping(false);
955
1528
  return this.alive(generation) ? latest : null;
956
1529
  } catch (cause) {
@@ -988,7 +1561,7 @@ var ConversationStore = class {
988
1561
  startEditing = (messageId) => {
989
1562
  if (!this.alive() || !this.support.edit || this.ownRole() === "READ") return;
990
1563
  const row = this.ownRow(messageId);
991
- if (row) this.patch({ editingMessage: row });
1564
+ if (row) this.patch({ editingMessage: row, replyTarget: null });
992
1565
  };
993
1566
  /** Leave edit mode without a request; the draft is the view's to restore. */
994
1567
  cancelEditing = () => {
@@ -1153,6 +1726,15 @@ function useConversation(options) {
1153
1726
  editingMessage: field("editingMessage"),
1154
1727
  canEditMessages: field("canEditMessages"),
1155
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"),
1156
1738
  readerIdsFor: (message) => store.readerIdsFor(message),
1157
1739
  loadInitial: () => store.loadInitial(),
1158
1740
  refresh: () => store.refresh(),
@@ -1162,6 +1744,11 @@ function useConversation(options) {
1162
1744
  cancelEditing: () => store.cancelEditing(),
1163
1745
  saveEdit: (text) => store.saveEdit(text),
1164
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(),
1165
1752
  markRead: () => store.markRead(),
1166
1753
  updateTyping: (isTyping) => store.updateTyping(isTyping),
1167
1754
  setVisible: (value) => {
@@ -1232,6 +1819,15 @@ var MessageListView = (0, import_vue5.defineComponent)({
1232
1819
  onDeleteMessage: { type: Function, default: void 0 },
1233
1820
  canEditMessage: { type: Function, default: void 0 },
1234
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 },
1235
1831
  scrollElement: { type: Object, default: void 0 },
1236
1832
  paginationThreshold: { type: Number, default: 240 },
1237
1833
  reverse: { type: Boolean, default: true },
@@ -1239,13 +1835,18 @@ var MessageListView = (0, import_vue5.defineComponent)({
1239
1835
  formatTime: { type: Function, default: formatMessageTime },
1240
1836
  imageLoading: { type: String, default: "lazy" }
1241
1837
  },
1242
- emits: ["load-older", "attachment-click", "edit-message", "delete-message"],
1838
+ emits: ["load-older", "load-newer", "attachment-click", "edit-message", "delete-message", "reply-to-message", "jump-to-message"],
1243
1839
  setup(props, { attrs, emit, slots }) {
1244
1840
  const internalElement = (0, import_vue5.ref)(null);
1245
1841
  const confirming = (0, import_vue5.ref)(null);
1842
+ const highlightCleared = (0, import_vue5.ref)(false);
1246
1843
  let requestInFlight = false;
1247
1844
  let lastRequestedLength = null;
1845
+ let newerInFlight = false;
1846
+ let lastNewerLength = null;
1248
1847
  let previousMessageCount = 0;
1848
+ let scrolledTo = null;
1849
+ let jumpArmed = false;
1249
1850
  const participants = (0, import_vue5.computed)(() => new Map(props.conversation.participants.flatMap((participant) => [
1250
1851
  [participant.id, participant],
1251
1852
  [participant.appUserId, participant]
@@ -1268,13 +1869,26 @@ var MessageListView = (0, import_vue5.defineComponent)({
1268
1869
  requestInFlight = false;
1269
1870
  }
1270
1871
  };
1271
- (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]) => {
1272
1885
  const previous = previousMessageCount;
1273
1886
  if (count !== previousMessageCount || !hasOlder) lastRequestedLength = null;
1887
+ if (count !== previousMessageCount || !hasNewer) lastNewerLength = null;
1274
1888
  const appended = count > previous;
1275
1889
  const element = internalElement.value;
1276
1890
  previousMessageCount = count;
1277
- if (element && props.reverse && props.stickToBottom && appended) {
1891
+ if (element && props.reverse && props.stickToBottom && appended && !props.jumpInFlight) {
1278
1892
  const distanceFromBottom = element.scrollHeight - element.scrollTop - element.clientHeight;
1279
1893
  if (previous === 0 || distanceFromBottom < 320) {
1280
1894
  await (0, import_vue5.nextTick)();
@@ -1282,11 +1896,43 @@ var MessageListView = (0, import_vue5.defineComponent)({
1282
1896
  }
1283
1897
  }
1284
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" });
1285
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);
1286
1917
  const remove = async (message) => {
1287
1918
  if (props.confirmDelete && !await props.confirmDelete(message)) return false;
1288
1919
  return await props.onDeleteMessage?.(message) !== false;
1289
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
+ };
1290
1936
  const renderMessage = (message, index) => {
1291
1937
  const isCurrentUser = message.senderId === props.currentUserId;
1292
1938
  const sender = participants.value.get(message.senderId);
@@ -1296,9 +1942,19 @@ var MessageListView = (0, import_vue5.defineComponent)({
1296
1942
  const eligible = !isPending && (props.canEditMessage ? props.canEditMessage(message) : isCurrentUser && viewerRole.value !== "READ");
1297
1943
  const canEdit = eligible && !!props.onEditMessage;
1298
1944
  const canDelete = eligible && !!props.onDeleteMessage;
1945
+ const replyEligible = !isPending && (props.canReplyToMessage ? props.canReplyToMessage(message) : viewerRole.value !== "READ");
1946
+ const canReply = replyEligible && !!props.onReplyToMessage;
1299
1947
  const edit = () => {
1300
1948
  props.onEditMessage?.(message);
1301
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;
1302
1958
  const slotProps = {
1303
1959
  message,
1304
1960
  chronologicalIndex: index,
@@ -1308,11 +1964,27 @@ var MessageListView = (0, import_vue5.defineComponent)({
1308
1964
  isEdited,
1309
1965
  canEdit,
1310
1966
  canDelete,
1967
+ canReply,
1311
1968
  ...canEdit ? { edit } : {},
1312
- ...canDelete ? { remove: () => remove(message) } : {}
1969
+ ...canDelete ? { remove: () => remove(message) } : {},
1970
+ ...canReply ? { reply: replyTo } : {},
1971
+ ...parentId && replyPreview !== void 0 ? { replyPreview } : {},
1972
+ ...jumpToReplyTarget ? { jumpToReplyTarget } : {}
1313
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;
1314
1979
  const custom = slots.message?.(slotProps);
1315
- 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
+ }
1316
1988
  const currentAppearance = appearance();
1317
1989
  const messagePart = isCurrentUser ? "outgoingMessage" : "incomingMessage";
1318
1990
  const iconButton = (label, onClick, icon) => (0, import_vue5.h)("button", {
@@ -1322,7 +1994,8 @@ var MessageListView = (0, import_vue5.defineComponent)({
1322
1994
  class: partClass("button", currentAppearance, "ckui-icon-button"),
1323
1995
  style: partStyle("button", currentAppearance)
1324
1996
  }, [icon]);
1325
- const actions = canEdit || canDelete ? (0, import_vue5.h)("div", { class: "ckui-message-actions" }, [
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" }))] : [],
1326
1999
  ...canEdit ? [iconButton("Edit message", edit, (0, import_vue5.h)(import_vue4.Pencil, { size: 16, "aria-hidden": "true" }))] : [],
1327
2000
  ...canDelete ? [iconButton("Delete message", () => {
1328
2001
  if (props.confirmDelete) void remove(message);
@@ -1365,21 +2038,24 @@ var MessageListView = (0, import_vue5.defineComponent)({
1365
2038
  }, slots.media?.(mediaSlotProps) ?? [defaultMedia(media, open, props.imageLoading)]);
1366
2039
  });
1367
2040
  const receiptSlotProps = { message, readerIds };
2041
+ const quote = parentId ? renderQuote(parentId, replyPreview, jumpToReplyTarget) : null;
1368
2042
  return (0, import_vue5.h)("div", { key: message.id, role: "listitem" }, [
1369
2043
  (0, import_vue5.h)("article", {
1370
2044
  class: cx(
1371
2045
  !props.unstyled && "ckui-message-row",
1372
2046
  isCurrentUser && !props.unstyled && "ckui-message-row--outgoing",
2047
+ highlighted && "ckui-message-highlight",
1373
2048
  props.classNames?.message,
1374
2049
  props.classNames?.[messagePart]
1375
2050
  ),
1376
2051
  style: [props.styles?.message, props.styles?.[messagePart]],
1377
- "data-message-id": message.id
2052
+ ...anchor
1378
2053
  }, [
1379
2054
  // Spread, not null: an absent action/label/prompt must not leave a comment node (0.7 markup stays byte-identical).
1380
2055
  ...actions ? [actions] : [],
1381
2056
  (0, import_vue5.h)("div", { class: "ckui-message-bubble" }, [
1382
2057
  !isCurrentUser ? (0, import_vue5.h)("strong", { class: "ckui-message-sender" }, sender?.name || message.senderId) : null,
2058
+ ...quote ? [quote] : [],
1383
2059
  message.text ? (0, import_vue5.h)("div", { class: "ckui-message-text" }, message.text) : null,
1384
2060
  ...mediaNodes,
1385
2061
  (0, import_vue5.h)("span", { class: "ckui-message-time" }, [
@@ -1427,6 +2103,13 @@ var MessageListView = (0, import_vue5.defineComponent)({
1427
2103
  } else {
1428
2104
  children.push(...props.messages.map(renderMessage));
1429
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
+ }
1430
2113
  return (0, import_vue5.h)("div", {
1431
2114
  ...attrs,
1432
2115
  ref: (element) => {
@@ -1439,12 +2122,18 @@ var MessageListView = (0, import_vue5.defineComponent)({
1439
2122
  role: "log",
1440
2123
  "aria-live": "polite",
1441
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" } : {},
1442
2127
  onScroll: (event) => {
1443
2128
  const nativeHandler = attrs.onScroll;
1444
2129
  if (typeof nativeHandler === "function") nativeHandler(event);
2130
+ if (props.jumpInFlight) return;
2131
+ if (props.highlightedMessageId && !highlightCleared.value) highlightCleared.value = true;
1445
2132
  const element = event.currentTarget;
1446
2133
  const distanceFromOldest = props.reverse ? element.scrollTop : element.scrollHeight - element.scrollTop - element.clientHeight;
1447
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();
1448
2137
  }
1449
2138
  }, children);
1450
2139
  };
@@ -1484,6 +2173,18 @@ var viewProps = {
1484
2173
  onDeleteMessage: { type: Function, default: void 0 },
1485
2174
  canEditMessage: { type: Function, default: void 0 },
1486
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 },
1487
2188
  isInitialLoading: { type: Boolean, default: false },
1488
2189
  isLoadingOlder: { type: Boolean, default: false },
1489
2190
  isSending: { type: Boolean, default: false },
@@ -1503,7 +2204,7 @@ var viewProps = {
1503
2204
  defaultDraft: { type: String, default: "" },
1504
2205
  onDraftChange: { type: Function, default: void 0 }
1505
2206
  };
1506
- function editingSummary(message) {
2207
+ function messageSummary(message) {
1507
2208
  return message.text?.trim() || (message.media.length === 1 ? "1 attachment" : `${message.media.length} attachments`);
1508
2209
  }
1509
2210
  function typingLabel(userIds, displayNameForUser) {
@@ -1529,7 +2230,12 @@ var ConversationView = (0, import_vue7.defineComponent)({
1529
2230
  "edit-message",
1530
2231
  "save-edit",
1531
2232
  "cancel-edit",
1532
- "delete-message"
2233
+ "delete-message",
2234
+ "reply-to-message",
2235
+ "cancel-reply",
2236
+ "jump-to-message",
2237
+ "load-newer",
2238
+ "return-to-latest"
1533
2239
  ],
1534
2240
  setup(props, { attrs, emit, slots }) {
1535
2241
  const internalDraft = (0, import_vue7.ref)(props.defaultDraft);
@@ -1572,6 +2278,10 @@ var ConversationView = (0, import_vue7.defineComponent)({
1572
2278
  leaveEdit(editing, "always");
1573
2279
  props.onCancelEdit?.();
1574
2280
  };
2281
+ const cancelReply = () => {
2282
+ props.onCancelReply?.();
2283
+ };
2284
+ const returnToLatest = () => props.onReturnToLatest?.();
1575
2285
  const canSave = (editing) => draft().trim().length > 0 || editing.media.length > 0;
1576
2286
  const submit = async () => {
1577
2287
  const editing = props.editingMessage;
@@ -1616,6 +2326,7 @@ var ConversationView = (0, import_vue7.defineComponent)({
1616
2326
  };
1617
2327
  const refresh = () => props.onRefresh?.();
1618
2328
  const loadOlder = () => props.onLoadOlder?.();
2329
+ const loadNewer = () => props.onLoadNewer?.();
1619
2330
  const addAttachment = () => {
1620
2331
  props.onAddAttachment?.();
1621
2332
  };
@@ -1662,6 +2373,7 @@ var ConversationView = (0, import_vue7.defineComponent)({
1662
2373
  };
1663
2374
  const renderComposer = () => {
1664
2375
  const editing = props.editingMessage;
2376
+ const replying = editing ? null : props.replyTarget;
1665
2377
  const slotProps = {
1666
2378
  value: draft(),
1667
2379
  setValue: setDraft,
@@ -1670,11 +2382,16 @@ var ConversationView = (0, import_vue7.defineComponent)({
1670
2382
  void submit();
1671
2383
  },
1672
2384
  ...props.onAddAttachment ? { addAttachment } : {},
1673
- ...editing ? { editing, cancelEdit } : {}
2385
+ ...editing ? { editing, cancelEdit } : {},
2386
+ ...replying ? { replying, cancelReply } : {}
1674
2387
  };
1675
2388
  const busy = props.isSending || submitting.value;
1676
2389
  return slots.composer?.(slotProps) ?? (0, import_vue7.h)("form", {
1677
- class: cx(partClass("composer", appearance(), "ckui-composer"), editing && !props.unstyled && "ckui-composer--editing"),
2390
+ class: cx(
2391
+ partClass("composer", appearance(), "ckui-composer"),
2392
+ editing && !props.unstyled && "ckui-composer--editing",
2393
+ replying && !props.unstyled && "ckui-composer--replying"
2394
+ ),
1678
2395
  style: partStyle("composer", appearance()),
1679
2396
  onSubmit: (event) => {
1680
2397
  event.preventDefault();
@@ -1686,10 +2403,18 @@ var ConversationView = (0, import_vue7.defineComponent)({
1686
2403
  (0, import_vue7.h)(import_vue6.Pencil, { size: 14, "aria-hidden": "true" }),
1687
2404
  (0, import_vue7.h)("span", { class: "ckui-composer__editing-body" }, [
1688
2405
  (0, import_vue7.h)("strong", "Editing message"),
1689
- (0, import_vue7.h)("span", editingSummary(editing))
2406
+ (0, import_vue7.h)("span", messageSummary(editing))
1690
2407
  ]),
1691
2408
  (0, import_vue7.h)("button", { type: "button", class: "ckui-link-button", "aria-label": "Cancel editing", onClick: cancelEdit }, "Cancel")
1692
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
+ ])] : [],
1693
2418
  props.onAddAttachment ? (0, import_vue7.h)("button", {
1694
2419
  type: "button",
1695
2420
  "aria-label": "Add attachment",
@@ -1721,6 +2446,9 @@ var ConversationView = (0, import_vue7.defineComponent)({
1721
2446
  if (event.key === "Escape" && props.editingMessage) {
1722
2447
  event.preventDefault();
1723
2448
  cancelEdit();
2449
+ } else if (event.key === "Escape" && props.replyTarget) {
2450
+ event.preventDefault();
2451
+ cancelReply();
1724
2452
  }
1725
2453
  }
1726
2454
  }),
@@ -1763,6 +2491,7 @@ var ConversationView = (0, import_vue7.defineComponent)({
1763
2491
  ...slots["read-receipt"] ? { "read-receipt": slots["read-receipt"] } : {},
1764
2492
  ...slots.empty ? { empty: slots.empty } : {},
1765
2493
  ...slots["loading-older"] ? { "loading-older": slots["loading-older"] } : {},
2494
+ ...slots["loading-newer"] ? { "loading-newer": slots["loading-newer"] } : {},
1766
2495
  ...slots["message-error"] ? { error: slots["message-error"] } : {}
1767
2496
  };
1768
2497
  children.push((0, import_vue7.h)(MessageListView, {
@@ -1785,6 +2514,19 @@ var ConversationView = (0, import_vue7.defineComponent)({
1785
2514
  ...props.onDeleteMessage ? { onDeleteMessage: (message) => props.onDeleteMessage?.(message) } : {},
1786
2515
  ...props.canEditMessage ? { canEditMessage: props.canEditMessage } : {},
1787
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 } : {},
1788
2530
  reverse: props.reverseMessages,
1789
2531
  stickToBottom: props.stickToBottom,
1790
2532
  paginationThreshold: props.paginationThreshold,
@@ -1795,6 +2537,19 @@ var ConversationView = (0, import_vue7.defineComponent)({
1795
2537
  density: props.density,
1796
2538
  unstyled: props.unstyled
1797
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
+ }
1798
2553
  children.push(renderTyping(), renderComposer());
1799
2554
  return (0, import_vue7.h)("section", {
1800
2555
  ...attrs,
@@ -1820,6 +2575,10 @@ var Conversation = (0, import_vue7.defineComponent)({
1820
2575
  onSaveEdit: { type: Function, default: void 0 },
1821
2576
  onCancelEdit: { type: Function, default: void 0 },
1822
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 },
1823
2582
  client: { type: Object, required: true },
1824
2583
  conversationId: { type: String, required: true },
1825
2584
  messagePageSize: { type: Number, default: 30 },
@@ -1842,7 +2601,12 @@ var Conversation = (0, import_vue7.defineComponent)({
1842
2601
  "edit-message",
1843
2602
  "save-edit",
1844
2603
  "cancel-edit",
1845
- "delete-message"
2604
+ "delete-message",
2605
+ "reply-to-message",
2606
+ "cancel-reply",
2607
+ "jump-to-message",
2608
+ "load-newer",
2609
+ "return-to-latest"
1846
2610
  ],
1847
2611
  setup(props, { attrs, emit, expose, slots }) {
1848
2612
  const controller = useConversation({
@@ -1912,6 +2676,17 @@ var Conversation = (0, import_vue7.defineComponent)({
1912
2676
  onSaveEdit: _onSaveEdit,
1913
2677
  onCancelEdit: _onCancelEdit,
1914
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,
1915
2690
  ...forwarded
1916
2691
  } = props;
1917
2692
  return (0, import_vue7.h)(ConversationView, {
@@ -1957,6 +2732,39 @@ var Conversation = (0, import_vue7.defineComponent)({
1957
2732
  return controller.deleteMessage(message.id);
1958
2733
  }
1959
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
+ } : {},
1960
2768
  "onUpdate:modelValue": (value) => emit("update:modelValue", value),
1961
2769
  ...props.onBack ? { onBack: () => {
1962
2770
  props.onBack?.();
@@ -2782,6 +3590,7 @@ var defaultConvoKitTheme = {
2782
3590
  outgoingBubble: "#18181b",
2783
3591
  outgoingText: "#fafafa",
2784
3592
  badge: "#18181b",
3593
+ highlight: "color-mix(in srgb, #18181b 14%, transparent)",
2785
3594
  radius: "10px",
2786
3595
  avatarSize: "40px",
2787
3596
  fontFamily: 'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'
@@ -2814,6 +3623,7 @@ var ConvoKitThemeProvider = (0, import_vue11.defineComponent)({
2814
3623
  "--ckui-outgoing": theme.outgoingBubble,
2815
3624
  "--ckui-outgoing-text": theme.outgoingText,
2816
3625
  "--ckui-badge": theme.badge,
3626
+ "--ckui-highlight": theme.highlight,
2817
3627
  "--ckui-radius": theme.radius,
2818
3628
  "--ckui-avatar-size": theme.avatarSize,
2819
3629
  "--ckui-font": theme.fontFamily