@convokitapp/vue-ui 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -26,6 +26,10 @@ function createConvoKitUiClient(client) {
26
26
  getMessages: (options) => client.getMessages(options),
27
27
  getMessage: (id) => client.getMessage(id),
28
28
  sendMessage: (input) => client.sendMessage(input),
29
+ editMessage: (messageId, input) => client.editMessage(messageId, input),
30
+ deleteMessage: (messageId) => client.deleteMessage(messageId),
31
+ getReplyPreviews: (conversationId, messageIds) => client.getReplyPreviews(conversationId, messageIds),
32
+ getMessageContext: (conversationId, options) => client.getMessageContext(conversationId, options),
29
33
  markConversationRead: (conversationId, options) => client.markConversationRead(conversationId, options),
30
34
  markConversationUnread: (conversationId) => client.markConversationUnread(conversationId),
31
35
  clearConversationUnread: (conversationId, options) => client.clearConversationUnread(conversationId, options),
@@ -186,12 +190,13 @@ var ConvoKitAvatar = defineComponent({
186
190
  });
187
191
 
188
192
  // src/components/conversation.ts
189
- import { ArrowLeft, LoaderCircle as LoaderCircle2, Paperclip, 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";
190
194
  import {
191
195
  defineComponent as defineComponent3,
192
196
  h as h3,
193
197
  onBeforeUnmount,
194
198
  ref as ref2,
199
+ watch as watch3,
195
200
  watchEffect
196
201
  } from "vue";
197
202
 
@@ -206,9 +211,33 @@ function version(message) {
206
211
  function hasContent(message) {
207
212
  return !!message.text?.trim() || message.media.length > 0;
208
213
  }
214
+ function revisionOrder(left, right) {
215
+ const a = left.revision, b = right.revision;
216
+ if (typeof a !== "number" || typeof b !== "number" || a <= 0 && b <= 0) return void 0;
217
+ return a === b ? void 0 : a - b;
218
+ }
219
+ function older(candidate, reference) {
220
+ const byRevision = revisionOrder(candidate, reference);
221
+ return byRevision === void 0 ? version(candidate) < version(reference) : byRevision < 0;
222
+ }
209
223
  function newest(current, incoming, incomingComplete = true) {
224
+ const byRevision = revisionOrder(current, incoming);
225
+ if (byRevision !== void 0) return byRevision > 0 ? current : incoming;
210
226
  return version(current) > version(incoming) || !incomingComplete && version(current) === version(incoming) ? current : incoming;
211
227
  }
228
+ function isRevisionConflict(cause) {
229
+ if (typeof cause !== "object" || cause === null) return false;
230
+ const { code, status } = cause;
231
+ return code === "REVISION_CONFLICT" || code === void 0 && status === 409;
232
+ }
233
+ function isMessageMissing(cause) {
234
+ return typeof cause === "object" && cause !== null && cause.code === "MESSAGE_NOT_FOUND";
235
+ }
236
+ function localConflict() {
237
+ return Object.assign(new Error("Message was changed since it was loaded"), { code: "REVISION_CONFLICT" });
238
+ }
239
+ var JUMP_GUARD_MS = 150;
240
+ var HIGHLIGHT_MS = 2e3;
212
241
  var compare = compareMessageOrder;
213
242
  function positionCursor(position) {
214
243
  return { createdAt: position.createdAt, id: position.messageId };
@@ -228,7 +257,26 @@ function isTargetMiss(cause) {
228
257
  const { code, status } = cause;
229
258
  return code === "MESSAGE_NOT_FOUND" || code === void 0 && status === 404;
230
259
  }
231
- function blank(currentUserId = "") {
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 }) {
232
280
  return {
233
281
  conversation: null,
234
282
  messages: [],
@@ -242,7 +290,19 @@ function blank(currentUserId = "") {
242
290
  hasOlderMessages: true,
243
291
  hasLoaded: false,
244
292
  error: null,
245
- currentUserId
293
+ currentUserId,
294
+ editingMessage: null,
295
+ canEditMessages: support.edit,
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
246
306
  };
247
307
  }
248
308
  var ConversationStore = class {
@@ -261,7 +321,13 @@ var ConversationStore = class {
261
321
  }
262
322
  this.owner = this.client.sessionIdentity;
263
323
  this.user = this.owner ? this.client.currentUserId : "";
264
- this.state = blank(this.user);
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
+ };
330
+ this.state = blank(this.user, this.support);
265
331
  }
266
332
  options;
267
333
  client;
@@ -288,7 +354,34 @@ var ConversationStore = class {
288
354
  visible = true;
289
355
  sendRevision;
290
356
  activeSend;
357
+ /** Adapter support for author edits/deletes, decided once like `listInbox`. */
358
+ support;
359
+ /** The id whose `saveEdit` request is in flight: its outcome (success or 409) decides the edit, so newer rows for
360
+ * it arriving meanwhile (its own UPDATE image, typically) are not reported as a local conflict while it lasts;
361
+ * `settleEditing` re-evaluates them once the request has settled any other way.
362
+ */
363
+ activeEdit;
291
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;
292
385
  typingTimers = /* @__PURE__ */ new Map();
293
386
  ownTypingTimer;
294
387
  sentTyping = false;
@@ -306,9 +399,33 @@ var ConversationStore = class {
306
399
  for (const message of patch.messages) this.confirmSend(message);
307
400
  if (this.activeSend.confirmed) patch.messages = patch.messages.filter((message) => message.id !== this.activeSend.pending.id);
308
401
  }
402
+ if (patch.messages) patch = this.trackEditing(patch);
309
403
  this.state = { ...this.state, ...patch };
310
404
  for (const listener of this.listeners) listener();
311
405
  }
406
+ /** Edit mode follows the edited row wherever a message list reaches the state: the row leaving the list (deletion,
407
+ * reconcile tombstone, eviction) ends it, and a row for it with a higher revision than the snapshot (UPDATE image,
408
+ * hydration, reconcile, refresh) is the local conflict: the snapshot is replaced and `error` carries the conflict
409
+ * code, without a request. A save in flight owns its own outcome (`activeEdit`) and re-checks when it settles
410
+ * (`settleEditing`).
411
+ */
412
+ trackEditing(patch) {
413
+ const editing = patch.editingMessage === void 0 ? this.state.editingMessage : patch.editingMessage;
414
+ if (!editing || !patch.messages) return patch;
415
+ const live = patch.messages.find((message) => message.id === editing.id);
416
+ if (!live) return { ...patch, editingMessage: null };
417
+ if (this.activeEdit === editing.id || !(live.revision > editing.revision)) return patch;
418
+ return { ...patch, editingMessage: live, error: localConflict() };
419
+ }
420
+ /** After a save for `id` has settled without deciding the edit (a failure, or a 409 whose reload failed), a newer
421
+ * row for it that arrived during the request is the local conflict after all: the snapshot is replaced and
422
+ * `error` carries the conflict code, so the next save carries the fresh revision without another round trip.
423
+ */
424
+ settleEditing(id) {
425
+ const editing = this.state.editingMessage;
426
+ const live = editing?.id === id ? this.state.messages.find((message) => message.id === id) : void 0;
427
+ if (editing && live && live.revision > editing.revision) this.patch({ editingMessage: live, error: localConflict() });
428
+ }
312
429
  alive(generation = this.generation) {
313
430
  return !this.disposed && generation === this.generation && this.owner !== null && this.client.sessionIdentity === this.owner;
314
431
  }
@@ -352,7 +469,21 @@ var ConversationStore = class {
352
469
  this.captured = capture();
353
470
  this.sendRevision = void 0;
354
471
  this.activeSend = void 0;
472
+ this.activeEdit = void 0;
355
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;
356
487
  }
357
488
  dispose = () => {
358
489
  if (this.alive() && this.sentTyping) {
@@ -360,14 +491,14 @@ var ConversationStore = class {
360
491
  }
361
492
  this.disposed = true;
362
493
  this.clear();
363
- this.patch(blank());
494
+ this.patch(blank("", this.support));
364
495
  };
365
496
  fail(cause, generation, history = false) {
366
497
  if (!this.alive(generation)) return;
367
498
  const status = typeof cause === "object" && cause !== null && "status" in cause ? cause.status : void 0;
368
499
  if (history && (status === 401 || status === 403 || status === 404)) {
369
500
  this.clear();
370
- this.patch({ ...blank(this.user), error: cause, hasLoaded: true, hasOlderMessages: false });
501
+ this.patch({ ...blank(this.user, this.support), error: cause, hasLoaded: true, hasOlderMessages: false });
371
502
  } else this.patch({ error: cause });
372
503
  }
373
504
  attach(generation, data = true) {
@@ -382,8 +513,10 @@ var ConversationStore = class {
382
513
  add(() => this.client.onConnectionEvent({
383
514
  onEvent: ({ topic, status }) => {
384
515
  if (!data || !this.alive(generation) || topic !== `messages:${this.room}` && topic !== `conversation:${this.room}`) return;
385
- if (status === "SUBSCRIBED") this.queueRefresh();
386
- else this.clearTyping();
516
+ if (status === "SUBSCRIBED") {
517
+ this.markPreviewsStale();
518
+ this.queueRefresh();
519
+ } else this.clearTyping();
387
520
  },
388
521
  onSessionEnded: () => {
389
522
  if (this.disposed || generation !== this.generation) return;
@@ -439,9 +572,12 @@ var ConversationStore = class {
439
572
  if (!this.alive(generation) || type !== "insert" && type !== "update" || !this.validMessage(message) || this.deleted.has(message.id)) return;
440
573
  const existing = this.state.messages.find((item) => item.id === message.id);
441
574
  const known = existing ?? this.changes.get(message.id)?.message;
442
- if (known && version(message) < version(known)) return;
575
+ if (known && older(message, known)) return;
443
576
  const insert = type === "insert" || this.changes.get(message.id)?.insert === true;
444
- 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
+ }
445
581
  const revision = ++this.revision;
446
582
  const provisional = existing && !message.media.length ? { ...message, media: existing.media } : message;
447
583
  this.record(provisional, insert, revision, false);
@@ -452,14 +588,21 @@ var ConversationStore = class {
452
588
  }
453
589
  record(message, insert, revision, complete) {
454
590
  const existing = this.state.messages.find((item) => item.id === message.id);
455
- if (existing && version(existing) > version(message)) return;
591
+ if (existing && older(message, existing)) return;
456
592
  if (this.confirmSend(message) && !complete && !message.media.length) {
457
593
  message = { ...message, media: this.activeSend.pending.media };
458
594
  this.confirmSend(message);
459
595
  }
460
596
  this.changes.set(message.id, { revision, message, insert, complete });
461
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
+ }
462
603
  this.patch({ messages: mergeMessages(this.state.messages, [message]) });
604
+ this.notePreviewSource(message.id);
605
+ if (message.replyToMessageId) this.schedulePreviews();
463
606
  if (!existing && message.senderId !== this.user && (this.options.markReadOnReceive ?? true)) void this.acknowledge(true);
464
607
  }
465
608
  confirmSend(message) {
@@ -481,6 +624,9 @@ var ConversationStore = class {
481
624
  this.changes.delete(id);
482
625
  this.hydrations.delete(id);
483
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 });
484
630
  const ack = this.ack;
485
631
  if (ack.target !== id && ack.acknowledged?.id !== id) return;
486
632
  ack.unacknowledgeable.add(id);
@@ -503,7 +649,7 @@ var ConversationStore = class {
503
649
  if (!this.currentHydration(job)) return;
504
650
  const full = await this.client.getMessage(id);
505
651
  if (!this.currentHydration(job)) return;
506
- if (!this.validMessage(full) || full.id !== id || full.senderId !== job.message.senderId || version(full) < version(job.message)) {
652
+ if (!this.validMessage(full) || full.id !== id || full.senderId !== job.message.senderId || older(full, job.message)) {
507
653
  throw new Error("Complete message response does not match the observed resource/revision");
508
654
  }
509
655
  this.record(full, job.insert, job.revision, true);
@@ -549,6 +695,23 @@ var ConversationStore = class {
549
695
  previous = message;
550
696
  }
551
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
+ }
552
715
  fetchPage(before) {
553
716
  return this.client.getMessages({
554
717
  conversationId: this.room,
@@ -571,6 +734,148 @@ var ConversationStore = class {
571
734
  }
572
735
  return mergeMessages([], [...byId.values()]);
573
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
+ }
574
879
  prune(revision) {
575
880
  const safeRevision = Math.min(revision, this.sendRevision ?? Infinity);
576
881
  for (const [id, change] of this.changes) if (change.revision <= safeRevision) this.changes.delete(id);
@@ -583,7 +888,7 @@ var ConversationStore = class {
583
888
  if (!this.alive()) return;
584
889
  this.clear();
585
890
  const generation = this.generation;
586
- this.patch({ ...blank(this.user), isInitialLoading: true });
891
+ this.patch({ ...blank(this.user, this.support), isInitialLoading: true });
587
892
  const revision = this.revision;
588
893
  try {
589
894
  this.attach(generation);
@@ -597,6 +902,7 @@ var ConversationStore = class {
597
902
  this.captured = capture(conversation);
598
903
  this.mergeReads(conversation.participants.map(readEntry));
599
904
  this.prune(revision);
905
+ void this.resolvePreviews();
600
906
  if ((this.options.markReadOnLoad ?? true) || this.ack.suppressed) {
601
907
  this.ack.suppressed = false;
602
908
  await this.acknowledge(true);
@@ -612,19 +918,36 @@ var ConversationStore = class {
612
918
  };
613
919
  queueRefresh() {
614
920
  this.refreshQueued = true;
921
+ this.jumpedRefreshQueued = true;
615
922
  this.flushRefresh();
616
923
  }
617
924
  flushRefresh() {
618
925
  const generation = this.generation;
619
926
  void Promise.resolve().then(() => {
620
- 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
+ }
621
934
  this.refreshQueued = false;
935
+ this.jumpedRefreshQueued = false;
622
936
  void this.refresh();
623
937
  });
624
938
  }
625
939
  /** Re-fetch the entire viewed range atomically; a first-page-only refresh loses history. */
626
940
  refresh = async () => {
627
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
+ }
628
951
  if (this.state.isInitialLoading || this.state.isLoadingOlder || this.state.isReconciling) {
629
952
  this.refreshQueued = true;
630
953
  return;
@@ -662,6 +985,7 @@ var ConversationStore = class {
662
985
  if (opening) this.captured = capture(conversation);
663
986
  this.mergeReads(conversation.participants.map(readEntry));
664
987
  this.prune(revision);
988
+ void this.resolvePreviews(true);
665
989
  if (opening) void this.resumeAcknowledgement();
666
990
  } catch (cause) {
667
991
  this.fail(cause, generation, true);
@@ -673,7 +997,8 @@ var ConversationStore = class {
673
997
  }
674
998
  };
675
999
  loadOlderMessages = async () => {
676
- 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");
677
1002
  const generation = this.generation;
678
1003
  const revision = this.revision;
679
1004
  const cursor = this.cursor;
@@ -687,6 +1012,7 @@ var ConversationStore = class {
687
1012
  messages: this.overlay(mergeMessages(page, this.state.messages), revision),
688
1013
  hasOlderMessages: page.length === this.pageSize
689
1014
  });
1015
+ void this.resolvePreviews();
690
1016
  } catch (cause) {
691
1017
  this.fail(cause, generation, true);
692
1018
  } finally {
@@ -696,6 +1022,298 @@ var ConversationStore = class {
696
1022
  }
697
1023
  }
698
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
+ };
699
1317
  /** Acknowledge through the newest rendered row now, regardless of visibility; no acknowledgement without a
700
1318
  * target (a room opened with a marker that renders nothing clears the marker instead, once).
701
1319
  */
@@ -725,6 +1343,7 @@ var ConversationStore = class {
725
1343
  }
726
1344
  /** Resolves when the request this call issued or joined settles; a follow-up is issued, not awaited. */
727
1345
  acknowledge(automatic) {
1346
+ if (this.state.windowMode === "jumped") return Promise.resolve();
728
1347
  const ack = this.ack;
729
1348
  if (automatic && (!this.visible || this.state.conversation === null)) {
730
1349
  ack.suppressed = true;
@@ -737,6 +1356,10 @@ var ConversationStore = class {
737
1356
  return this.issue(ack, this.generation) ?? Promise.resolve();
738
1357
  }
739
1358
  issue(ack, generation) {
1359
+ if (this.state.windowMode === "jumped") {
1360
+ ack.followUp = true;
1361
+ return void 0;
1362
+ }
740
1363
  ack.followUp = false;
741
1364
  const target = this.ackTarget(ack);
742
1365
  const request = target ? this.send(ack, generation, target) : this.clearMarker(ack, generation);
@@ -813,11 +1436,14 @@ var ConversationStore = class {
813
1436
  sendMessage = async ({ text, media }) => {
814
1437
  const normalized = text?.trim();
815
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;
816
1441
  const generation = this.generation;
817
1442
  const revision = this.revision;
818
1443
  this.sendRevision = revision;
819
1444
  const clientMessageId = createClientMessageId();
820
1445
  const pendingId = `convokit-pending-${clientMessageId}`;
1446
+ const replyToMessageId = this.state.replyTarget?.id;
821
1447
  const pending = {
822
1448
  id: pendingId,
823
1449
  clientMessageId,
@@ -826,7 +1452,9 @@ var ConversationStore = class {
826
1452
  text: normalized || null,
827
1453
  media: media ?? [],
828
1454
  createdAt: /* @__PURE__ */ new Date(),
829
- updatedAt: null
1455
+ updatedAt: null,
1456
+ revision: 0,
1457
+ ...replyToMessageId ? { replyToMessageId } : {}
830
1458
  };
831
1459
  const send = { pending };
832
1460
  this.activeSend = send;
@@ -836,7 +1464,9 @@ var ConversationStore = class {
836
1464
  conversationId: this.room,
837
1465
  clientMessageId,
838
1466
  ...normalized ? { text: normalized } : {},
839
- ...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 } : {}
840
1470
  });
841
1471
  if (!this.alive(generation)) return null;
842
1472
  if (!this.validMessage(message) || message.senderId !== this.user) throw new Error("Send response belongs to a different room or sender");
@@ -846,10 +1476,15 @@ var ConversationStore = class {
846
1476
  let latest = existing ? newest(message, existing, live?.complete !== false) : message;
847
1477
  if (live && live.revision > revision) latest = newest(latest, live.message, live.complete);
848
1478
  if (!this.deleted.has(message.id)) this.changes.set(message.id, { revision: ++this.revision, message: latest, insert: true, complete: true });
849
- this.patch({ messages: mergeMessages(
850
- this.state.messages.filter((item) => item.id !== pendingId),
851
- this.deleted.has(message.id) ? [] : [latest]
852
- ) });
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();
853
1488
  void this.updateTyping(false);
854
1489
  return this.alive(generation) ? latest : null;
855
1490
  } catch (cause) {
@@ -870,6 +1505,132 @@ var ConversationStore = class {
870
1505
  }
871
1506
  }
872
1507
  };
1508
+ /** The caller's role in the open room when known (0.7 `membership`, else the caller's participant row). */
1509
+ ownRole() {
1510
+ const conversation = this.state.conversation;
1511
+ return conversation?.membership?.role ?? conversation?.participants.find((participant) => participant.appUserId === this.user || participant.id === this.user)?.role;
1512
+ }
1513
+ /** A rendered, confirmed row of the caller's own that is not known to be gone. */
1514
+ ownRow(messageId) {
1515
+ const row = this.state.messages.find((message) => message.id === messageId);
1516
+ return row && row.senderId === this.user && !isConvoKitPendingMessage(row) && !this.deleted.has(messageId) ? row : void 0;
1517
+ }
1518
+ /** Enter edit mode on one of the caller's own confirmed messages (0.8.0): the row as it stands now becomes the
1519
+ * snapshot whose `revision` every save sends. A no-op unless the adapter implements `editMessage`, the row is
1520
+ * rendered, own, confirmed, not tombstoned and the caller's role (when known) is not `READ`. Sends nothing.
1521
+ */
1522
+ startEditing = (messageId) => {
1523
+ if (!this.alive() || !this.support.edit || this.ownRole() === "READ") return;
1524
+ const row = this.ownRow(messageId);
1525
+ if (row) this.patch({ editingMessage: row, replyTarget: null });
1526
+ };
1527
+ /** Leave edit mode without a request; the draft is the view's to restore. */
1528
+ cancelEditing = () => {
1529
+ if (this.state.editingMessage) this.patch({ editingMessage: null });
1530
+ };
1531
+ /** Save the edit in progress with the snapshot's revision (never the live row's), trimming the text and sending
1532
+ * `null` for an empty caption. Resolves true when the server accepted the edit (the response is merged through the
1533
+ * tombstone and precedence guards and edit mode ends); false when nothing was saved: a stale revision (409
1534
+ * `REVISION_CONFLICT`) reloads the row once through `getMessage`, replaces the snapshot with it (the next save
1535
+ * carries the fresh revision) and reports the conflict through `error`, keeping edit mode; a coded 404
1536
+ * (`MESSAGE_NOT_FOUND`, on the save or on that reload) removes the row and ends edit mode; any other failure
1537
+ * (403, 500, network, an uncoded 404 from a 0.7 backend) is reported through `error` without evicting anything and
1538
+ * keeps edit mode; if a newer row for the message arrived during such a request, that row is then the local
1539
+ * conflict (`settleEditing`). A text-only message cannot be saved empty (no request). Rejects when the adapter
1540
+ * lacks `editMessage`.
1541
+ */
1542
+ saveEdit = async (text) => {
1543
+ const client = this.client;
1544
+ if (typeof client.editMessage !== "function") {
1545
+ throw new TypeError("This ConvoKitUiClient adapter does not implement editMessage (0.8)");
1546
+ }
1547
+ const snapshot = this.state.editingMessage;
1548
+ if (!this.alive() || !snapshot || this.activeEdit !== void 0) return false;
1549
+ const trimmed = text.trim();
1550
+ const normalized = trimmed === "" ? null : trimmed;
1551
+ if (normalized === null && snapshot.media.length === 0) return false;
1552
+ const generation = this.generation;
1553
+ const id = snapshot.id;
1554
+ this.activeEdit = id;
1555
+ this.patch({ error: null });
1556
+ try {
1557
+ const message = await client.editMessage(id, { text: normalized, revision: snapshot.revision });
1558
+ if (!this.alive(generation)) return false;
1559
+ if (!this.validMessage(message) || message.id !== id || message.senderId !== this.user) {
1560
+ throw new Error("Edit response belongs to a different message or sender");
1561
+ }
1562
+ if (this.state.editingMessage?.id === id) this.patch({ editingMessage: null });
1563
+ this.applyRow(message);
1564
+ return true;
1565
+ } catch (cause) {
1566
+ if (!this.alive(generation)) return false;
1567
+ if (isRevisionConflict(cause)) await this.reloadConflict(id, cause, generation);
1568
+ else if (isMessageMissing(cause)) {
1569
+ this.removeMessage(id);
1570
+ this.patch({ error: cause });
1571
+ } else this.fail(cause, generation);
1572
+ return false;
1573
+ } finally {
1574
+ if (this.alive(generation)) {
1575
+ this.activeEdit = void 0;
1576
+ this.settleEditing(id);
1577
+ }
1578
+ }
1579
+ };
1580
+ /** Merge a complete REST row for a known id through the live-row guards: a tombstoned id is dropped, and an older
1581
+ * revision (or timestamp) never overwrites the newer row already recorded. Recorded as a non-insert change, so a
1582
+ * reconcile keeps it only while the row is still in the fetched range.
1583
+ */
1584
+ applyRow(message) {
1585
+ if (this.deleted.has(message.id)) return;
1586
+ this.record(message, this.changes.get(message.id)?.insert === true, ++this.revision, true);
1587
+ }
1588
+ /** The 409 path: one `getMessage` shows the conflicting content. Its row is merged through the guards and becomes
1589
+ * the new snapshot; a `MESSAGE_NOT_FOUND` answer removes the row and ends edit mode; another failure keeps the
1590
+ * snapshot. `error` carries the conflict (or the reload failure).
1591
+ */
1592
+ async reloadConflict(id, conflict, generation) {
1593
+ try {
1594
+ const current = await this.client.getMessage(id);
1595
+ if (!this.alive(generation)) return;
1596
+ if (!this.validMessage(current) || current.id !== id) throw new Error("Complete message response does not match the edited message");
1597
+ this.applyRow(current);
1598
+ const row = this.state.messages.find((message) => message.id === id);
1599
+ const editing = this.state.editingMessage?.id === id && row ? { editingMessage: row } : {};
1600
+ this.patch({ ...editing, error: conflict });
1601
+ } catch (cause) {
1602
+ if (!this.alive(generation)) return;
1603
+ if (isTargetMiss(cause)) this.removeMessage(id);
1604
+ this.patch({ error: cause });
1605
+ }
1606
+ }
1607
+ /** Delete one of the caller's own confirmed messages (0.8.0). The row stays until the server answers: on success,
1608
+ * or when the server no longer knows it (`MESSAGE_NOT_FOUND`), it is tombstoned and removed (late responses, row
1609
+ * images and hydrations for it are dropped, the acknowledgement target is re-resolved and edit mode on it ends)
1610
+ * and the call resolves true; any other failure keeps the row, reports through `error` and resolves false. Rejects
1611
+ * when the adapter lacks `deleteMessage`.
1612
+ */
1613
+ deleteMessage = async (messageId) => {
1614
+ const client = this.client;
1615
+ if (typeof client.deleteMessage !== "function") {
1616
+ throw new TypeError("This ConvoKitUiClient adapter does not implement deleteMessage (0.8)");
1617
+ }
1618
+ if (!this.alive() || !this.ownRow(messageId)) return false;
1619
+ const generation = this.generation;
1620
+ this.patch({ error: null });
1621
+ try {
1622
+ await client.deleteMessage(messageId);
1623
+ } catch (cause) {
1624
+ if (!this.alive(generation)) return false;
1625
+ if (!isMessageMissing(cause)) {
1626
+ this.fail(cause, generation);
1627
+ return false;
1628
+ }
1629
+ }
1630
+ if (!this.alive(generation)) return false;
1631
+ this.removeMessage(messageId);
1632
+ return true;
1633
+ };
873
1634
  readerIdsFor = (message) => readerIdsFor(message, this.state.readAtByUserId, this.state.readPositionByUserId);
874
1635
  };
875
1636
 
@@ -923,11 +1684,32 @@ function useConversation(options) {
923
1684
  hasLoaded: field("hasLoaded"),
924
1685
  error: field("error"),
925
1686
  currentUserId: field("currentUserId"),
1687
+ editingMessage: field("editingMessage"),
1688
+ canEditMessages: field("canEditMessages"),
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"),
926
1699
  readerIdsFor: (message) => store.readerIdsFor(message),
927
1700
  loadInitial: () => store.loadInitial(),
928
1701
  refresh: () => store.refresh(),
929
1702
  loadOlderMessages: () => store.loadOlderMessages(),
930
1703
  sendMessage: (input) => store.sendMessage(input),
1704
+ startEditing: (messageId) => store.startEditing(messageId),
1705
+ cancelEditing: () => store.cancelEditing(),
1706
+ saveEdit: (text) => store.saveEdit(text),
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(),
931
1713
  markRead: () => store.markRead(),
932
1714
  updateTyping: (isTyping) => store.updateTyping(isTyping),
933
1715
  setVisible: (value) => {
@@ -939,6 +1721,7 @@ function useConversation(options) {
939
1721
  }
940
1722
 
941
1723
  // src/components/message-list.ts
1724
+ import { isEditedMessage } from "@convokitapp/sdk";
942
1725
  import {
943
1726
  Check,
944
1727
  CheckCheck,
@@ -948,7 +1731,10 @@ import {
948
1731
  ImageOff,
949
1732
  LoaderCircle,
950
1733
  MapPin,
951
- MessageCircle
1734
+ MessageCircle,
1735
+ Pencil,
1736
+ Reply,
1737
+ Trash2
952
1738
  } from "@lucide/vue";
953
1739
  import {
954
1740
  computed as computed2,
@@ -1010,6 +1796,19 @@ var MessageListView = defineComponent2({
1010
1796
  isLoadingOlder: { type: Boolean, default: false },
1011
1797
  error: { type: null, required: false },
1012
1798
  onAttachmentClick: { type: Function, default: void 0 },
1799
+ onEditMessage: { type: Function, default: void 0 },
1800
+ onDeleteMessage: { type: Function, default: void 0 },
1801
+ canEditMessage: { type: Function, default: void 0 },
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 },
1013
1812
  scrollElement: { type: Object, default: void 0 },
1014
1813
  paginationThreshold: { type: Number, default: 240 },
1015
1814
  reverse: { type: Boolean, default: true },
@@ -1017,12 +1816,18 @@ var MessageListView = defineComponent2({
1017
1816
  formatTime: { type: Function, default: formatMessageTime },
1018
1817
  imageLoading: { type: String, default: "lazy" }
1019
1818
  },
1020
- emits: ["load-older", "attachment-click"],
1819
+ emits: ["load-older", "load-newer", "attachment-click", "edit-message", "delete-message", "reply-to-message", "jump-to-message"],
1021
1820
  setup(props, { attrs, emit, slots }) {
1022
1821
  const internalElement = ref(null);
1822
+ const confirming = ref(null);
1823
+ const highlightCleared = ref(false);
1023
1824
  let requestInFlight = false;
1024
1825
  let lastRequestedLength = null;
1826
+ let newerInFlight = false;
1827
+ let lastNewerLength = null;
1025
1828
  let previousMessageCount = 0;
1829
+ let scrolledTo = null;
1830
+ let jumpArmed = false;
1026
1831
  const participants = computed2(() => new Map(props.conversation.participants.flatMap((participant) => [
1027
1832
  [participant.id, participant],
1028
1833
  [participant.appUserId, participant]
@@ -1045,13 +1850,26 @@ var MessageListView = defineComponent2({
1045
1850
  requestInFlight = false;
1046
1851
  }
1047
1852
  };
1048
- 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]) => {
1049
1866
  const previous = previousMessageCount;
1050
1867
  if (count !== previousMessageCount || !hasOlder) lastRequestedLength = null;
1868
+ if (count !== previousMessageCount || !hasNewer) lastNewerLength = null;
1051
1869
  const appended = count > previous;
1052
1870
  const element = internalElement.value;
1053
1871
  previousMessageCount = count;
1054
- if (element && props.reverse && props.stickToBottom && appended) {
1872
+ if (element && props.reverse && props.stickToBottom && appended && !props.jumpInFlight) {
1055
1873
  const distanceFromBottom = element.scrollHeight - element.scrollTop - element.clientHeight;
1056
1874
  if (previous === 0 || distanceFromBottom < 320) {
1057
1875
  await nextTick();
@@ -1059,16 +1877,136 @@ var MessageListView = defineComponent2({
1059
1877
  }
1060
1878
  }
1061
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" });
1897
+ const viewerRole = computed2(() => props.conversation.membership?.role ?? props.conversation.participants.find((participant) => participant.appUserId === props.currentUserId || participant.id === props.currentUserId)?.role);
1898
+ const remove = async (message) => {
1899
+ if (props.confirmDelete && !await props.confirmDelete(message)) return false;
1900
+ return await props.onDeleteMessage?.(message) !== false;
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
+ };
1062
1917
  const renderMessage = (message, index) => {
1063
1918
  const isCurrentUser = message.senderId === props.currentUserId;
1064
1919
  const sender = participants.value.get(message.senderId);
1065
1920
  const isPending = isConvoKitPendingMessage(message);
1066
1921
  const readerIds = isPending ? /* @__PURE__ */ new Set() : props.readersResolver ? props.readersResolver(message) : readerIdsFor(message, props.readAtByUserId, props.readPositionByUserId);
1067
- const slotProps = { message, chronologicalIndex: index, isCurrentUser, sender, readerIds };
1922
+ const isEdited = !isPending && isEditedMessage(message);
1923
+ const eligible = !isPending && (props.canEditMessage ? props.canEditMessage(message) : isCurrentUser && viewerRole.value !== "READ");
1924
+ const canEdit = eligible && !!props.onEditMessage;
1925
+ const canDelete = eligible && !!props.onDeleteMessage;
1926
+ const replyEligible = !isPending && (props.canReplyToMessage ? props.canReplyToMessage(message) : viewerRole.value !== "READ");
1927
+ const canReply = replyEligible && !!props.onReplyToMessage;
1928
+ const edit = () => {
1929
+ props.onEditMessage?.(message);
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;
1939
+ const slotProps = {
1940
+ message,
1941
+ chronologicalIndex: index,
1942
+ isCurrentUser,
1943
+ sender,
1944
+ readerIds,
1945
+ isEdited,
1946
+ canEdit,
1947
+ canDelete,
1948
+ canReply,
1949
+ ...canEdit ? { edit } : {},
1950
+ ...canDelete ? { remove: () => remove(message) } : {},
1951
+ ...canReply ? { reply: replyTo } : {},
1952
+ ...parentId && replyPreview !== void 0 ? { replyPreview } : {},
1953
+ ...jumpToReplyTarget ? { jumpToReplyTarget } : {}
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;
1068
1960
  const custom = slots.message?.(slotProps);
1069
- 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
+ }
1070
1969
  const currentAppearance = appearance();
1071
1970
  const messagePart = isCurrentUser ? "outgoingMessage" : "incomingMessage";
1971
+ const iconButton = (label, onClick, icon) => h2("button", {
1972
+ type: "button",
1973
+ "aria-label": label,
1974
+ onClick,
1975
+ class: partClass("button", currentAppearance, "ckui-icon-button"),
1976
+ style: partStyle("button", currentAppearance)
1977
+ }, [icon]);
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" }))] : [],
1980
+ ...canEdit ? [iconButton("Edit message", edit, h2(Pencil, { size: 16, "aria-hidden": "true" }))] : [],
1981
+ ...canDelete ? [iconButton("Delete message", () => {
1982
+ if (props.confirmDelete) void remove(message);
1983
+ else confirming.value = message.id;
1984
+ }, h2(Trash2, { size: 16, "aria-hidden": "true" }))] : []
1985
+ ]) : null;
1986
+ const confirm = canDelete && confirming.value === message.id ? h2("div", {
1987
+ class: "ckui-message-confirm",
1988
+ role: "group",
1989
+ "aria-label": "Delete this message?"
1990
+ }, [
1991
+ h2("span", "Delete this message?"),
1992
+ h2("button", {
1993
+ type: "button",
1994
+ class: "ckui-link-button",
1995
+ "aria-label": "Confirm delete",
1996
+ onClick: () => {
1997
+ confirming.value = null;
1998
+ void props.onDeleteMessage?.(message);
1999
+ }
2000
+ }, "Delete"),
2001
+ h2("button", {
2002
+ type: "button",
2003
+ class: "ckui-link-button",
2004
+ "aria-label": "Cancel delete",
2005
+ onClick: () => {
2006
+ confirming.value = null;
2007
+ }
2008
+ }, "Cancel")
2009
+ ]) : null;
1072
2010
  const mediaNodes = message.media.map((media, mediaIndex) => {
1073
2011
  const open = props.onAttachmentClick ? () => {
1074
2012
  props.onAttachmentClick?.(media, message);
@@ -1081,26 +2019,33 @@ var MessageListView = defineComponent2({
1081
2019
  }, slots.media?.(mediaSlotProps) ?? [defaultMedia(media, open, props.imageLoading)]);
1082
2020
  });
1083
2021
  const receiptSlotProps = { message, readerIds };
2022
+ const quote = parentId ? renderQuote(parentId, replyPreview, jumpToReplyTarget) : null;
1084
2023
  return h2("div", { key: message.id, role: "listitem" }, [
1085
2024
  h2("article", {
1086
2025
  class: cx(
1087
2026
  !props.unstyled && "ckui-message-row",
1088
2027
  isCurrentUser && !props.unstyled && "ckui-message-row--outgoing",
2028
+ highlighted && "ckui-message-highlight",
1089
2029
  props.classNames?.message,
1090
2030
  props.classNames?.[messagePart]
1091
2031
  ),
1092
2032
  style: [props.styles?.message, props.styles?.[messagePart]],
1093
- "data-message-id": message.id
2033
+ ...anchor
1094
2034
  }, [
2035
+ // Spread, not null: an absent action/label/prompt must not leave a comment node (0.7 markup stays byte-identical).
2036
+ ...actions ? [actions] : [],
1095
2037
  h2("div", { class: "ckui-message-bubble" }, [
1096
2038
  !isCurrentUser ? h2("strong", { class: "ckui-message-sender" }, sender?.name || message.senderId) : null,
2039
+ ...quote ? [quote] : [],
1097
2040
  message.text ? h2("div", { class: "ckui-message-text" }, message.text) : null,
1098
2041
  ...mediaNodes,
1099
2042
  h2("span", { class: "ckui-message-time" }, [
1100
2043
  isPending ? "Sending\u2026" : props.formatTime(message.createdAt),
2044
+ ...isEdited ? [h2("span", { class: "ckui-message-edited", "aria-label": "Edited" }, "Edited")] : [],
1101
2045
  isCurrentUser && !isPending ? readerIds.size > 0 ? h2(CheckCheck, { size: 14, "aria-label": "Read" }) : h2(Check, { size: 14, "aria-label": "Sent" }) : null
1102
2046
  ])
1103
2047
  ]),
2048
+ ...confirm ? [confirm] : [],
1104
2049
  isCurrentUser && !isPending ? slots["read-receipt"]?.(receiptSlotProps) ?? h2("div", {
1105
2050
  class: partClass("receipt", currentAppearance, "ckui-read-receipt"),
1106
2051
  style: partStyle("receipt", currentAppearance)
@@ -1139,6 +2084,13 @@ var MessageListView = defineComponent2({
1139
2084
  } else {
1140
2085
  children.push(...props.messages.map(renderMessage));
1141
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
+ }
1142
2094
  return h2("div", {
1143
2095
  ...attrs,
1144
2096
  ref: (element) => {
@@ -1151,12 +2103,18 @@ var MessageListView = defineComponent2({
1151
2103
  role: "log",
1152
2104
  "aria-live": "polite",
1153
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" } : {},
1154
2108
  onScroll: (event) => {
1155
2109
  const nativeHandler = attrs.onScroll;
1156
2110
  if (typeof nativeHandler === "function") nativeHandler(event);
2111
+ if (props.jumpInFlight) return;
2112
+ if (props.highlightedMessageId && !highlightCleared.value) highlightCleared.value = true;
1157
2113
  const element = event.currentTarget;
1158
2114
  const distanceFromOldest = props.reverse ? element.scrollTop : element.scrollHeight - element.scrollTop - element.clientHeight;
1159
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();
1160
2118
  }
1161
2119
  }, children);
1162
2120
  };
@@ -1189,6 +2147,25 @@ var viewProps = {
1189
2147
  onTypingChange: { type: Function, default: void 0 },
1190
2148
  onAddAttachment: { type: Function, default: void 0 },
1191
2149
  onAttachmentClick: { type: Function, default: void 0 },
2150
+ editingMessage: { type: Object, default: null },
2151
+ onEditMessage: { type: Function, default: void 0 },
2152
+ onSaveEdit: { type: Function, default: void 0 },
2153
+ onCancelEdit: { type: Function, default: void 0 },
2154
+ onDeleteMessage: { type: Function, default: void 0 },
2155
+ canEditMessage: { type: Function, default: void 0 },
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 },
1192
2169
  isInitialLoading: { type: Boolean, default: false },
1193
2170
  isLoadingOlder: { type: Boolean, default: false },
1194
2171
  isSending: { type: Boolean, default: false },
@@ -1208,6 +2185,9 @@ var viewProps = {
1208
2185
  defaultDraft: { type: String, default: "" },
1209
2186
  onDraftChange: { type: Function, default: void 0 }
1210
2187
  };
2188
+ function messageSummary(message) {
2189
+ return message.text?.trim() || (message.media.length === 1 ? "1 attachment" : `${message.media.length} attachments`);
2190
+ }
1211
2191
  function typingLabel(userIds, displayNameForUser) {
1212
2192
  const names = [...userIds].map(displayNameForUser);
1213
2193
  if (names.length === 0) return "";
@@ -1227,7 +2207,16 @@ var ConversationView = defineComponent3({
1227
2207
  "load-older",
1228
2208
  "add-attachment",
1229
2209
  "attachment-click",
1230
- "update:modelValue"
2210
+ "update:modelValue",
2211
+ "edit-message",
2212
+ "save-edit",
2213
+ "cancel-edit",
2214
+ "delete-message",
2215
+ "reply-to-message",
2216
+ "cancel-reply",
2217
+ "jump-to-message",
2218
+ "load-newer",
2219
+ "return-to-latest"
1231
2220
  ],
1232
2221
  setup(props, { attrs, emit, slots }) {
1233
2222
  const internalDraft = ref2(props.defaultDraft);
@@ -1240,15 +2229,61 @@ var ConversationView = defineComponent3({
1240
2229
  });
1241
2230
  const draft = () => props.modelValue ?? internalDraft.value;
1242
2231
  let latestDraft = draft();
1243
- const setDraft = (value) => {
2232
+ let stash;
2233
+ let saving = false;
2234
+ const setDraft = (value, typing = true) => {
1244
2235
  latestDraft = value;
1245
2236
  if (props.modelValue === void 0) internalDraft.value = value;
1246
2237
  props.onDraftChange?.(value);
1247
2238
  emit("update:modelValue", value);
1248
- const isTyping = value.trim().length > 0;
1249
- void props.onTypingChange?.(isTyping);
2239
+ if (typing) void props.onTypingChange?.(value.trim().length > 0);
2240
+ };
2241
+ const enterEdit = (message) => {
2242
+ if (stash === void 0) stash = draft();
2243
+ setDraft(message.text ?? "", false);
2244
+ };
2245
+ const leaveEdit = (message, restore) => {
2246
+ if (stash === void 0) return;
2247
+ const saved = stash;
2248
+ stash = void 0;
2249
+ const current = draft();
2250
+ if (restore === "always" || current.trim() === "" || current === (message.text ?? "")) setDraft(saved);
1250
2251
  };
2252
+ watch3(() => props.editingMessage, (next, previous) => {
2253
+ if (next && (!previous || previous.id !== next.id)) enterEdit(next);
2254
+ else if (!next && previous && !saving) leaveEdit(previous, "unchanged");
2255
+ }, { immediate: true });
2256
+ const cancelEdit = () => {
2257
+ const editing = props.editingMessage;
2258
+ if (!editing) return;
2259
+ leaveEdit(editing, "always");
2260
+ props.onCancelEdit?.();
2261
+ };
2262
+ const cancelReply = () => {
2263
+ props.onCancelReply?.();
2264
+ };
2265
+ const returnToLatest = () => props.onReturnToLatest?.();
2266
+ const canSave = (editing) => draft().trim().length > 0 || editing.media.length > 0;
1251
2267
  const submit = async () => {
2268
+ const editing = props.editingMessage;
2269
+ if (editing) {
2270
+ const text2 = draft().trim();
2271
+ if (!canSave(editing) || props.isSending || submitting.value) return;
2272
+ submitting.value = true;
2273
+ saving = true;
2274
+ try {
2275
+ const saved = await props.onSaveEdit?.(editing, text2);
2276
+ if (saved === false) {
2277
+ if (props.editingMessage === null) leaveEdit(editing, "unchanged");
2278
+ return;
2279
+ }
2280
+ leaveEdit(editing, "always");
2281
+ } finally {
2282
+ saving = false;
2283
+ submitting.value = false;
2284
+ }
2285
+ return;
2286
+ }
1252
2287
  const originalDraft = draft();
1253
2288
  const text = originalDraft.trim();
1254
2289
  if (!text || props.isSending || submitting.value) return;
@@ -1272,6 +2307,7 @@ var ConversationView = defineComponent3({
1272
2307
  };
1273
2308
  const refresh = () => props.onRefresh?.();
1274
2309
  const loadOlder = () => props.onLoadOlder?.();
2310
+ const loadNewer = () => props.onLoadNewer?.();
1275
2311
  const addAttachment = () => {
1276
2312
  props.onAddAttachment?.();
1277
2313
  };
@@ -1317,6 +2353,8 @@ var ConversationView = defineComponent3({
1317
2353
  }, typingLabel(props.typingUserIds, nameForUser));
1318
2354
  };
1319
2355
  const renderComposer = () => {
2356
+ const editing = props.editingMessage;
2357
+ const replying = editing ? null : props.replyTarget;
1320
2358
  const slotProps = {
1321
2359
  value: draft(),
1322
2360
  setValue: setDraft,
@@ -1324,16 +2362,40 @@ var ConversationView = defineComponent3({
1324
2362
  send: () => {
1325
2363
  void submit();
1326
2364
  },
1327
- ...props.onAddAttachment ? { addAttachment } : {}
2365
+ ...props.onAddAttachment ? { addAttachment } : {},
2366
+ ...editing ? { editing, cancelEdit } : {},
2367
+ ...replying ? { replying, cancelReply } : {}
1328
2368
  };
2369
+ const busy = props.isSending || submitting.value;
1329
2370
  return slots.composer?.(slotProps) ?? h3("form", {
1330
- class: partClass("composer", appearance(), "ckui-composer"),
2371
+ class: cx(
2372
+ partClass("composer", appearance(), "ckui-composer"),
2373
+ editing && !props.unstyled && "ckui-composer--editing",
2374
+ replying && !props.unstyled && "ckui-composer--replying"
2375
+ ),
1331
2376
  style: partStyle("composer", appearance()),
1332
2377
  onSubmit: (event) => {
1333
2378
  event.preventDefault();
1334
2379
  void submit();
1335
2380
  }
1336
2381
  }, [
2382
+ // Spread, not null: outside edit mode the composer markup stays byte-identical to 0.7.
2383
+ ...editing ? [h3("div", { class: "ckui-composer__editing", role: "status" }, [
2384
+ h3(Pencil2, { size: 14, "aria-hidden": "true" }),
2385
+ h3("span", { class: "ckui-composer__editing-body" }, [
2386
+ h3("strong", "Editing message"),
2387
+ h3("span", messageSummary(editing))
2388
+ ]),
2389
+ h3("button", { type: "button", class: "ckui-link-button", "aria-label": "Cancel editing", onClick: cancelEdit }, "Cancel")
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
+ ])] : [],
1337
2399
  props.onAddAttachment ? h3("button", {
1338
2400
  type: "button",
1339
2401
  "aria-label": "Add attachment",
@@ -1362,15 +2424,22 @@ var ConversationView = defineComponent3({
1362
2424
  event.preventDefault();
1363
2425
  void submit();
1364
2426
  }
2427
+ if (event.key === "Escape" && props.editingMessage) {
2428
+ event.preventDefault();
2429
+ cancelEdit();
2430
+ } else if (event.key === "Escape" && props.replyTarget) {
2431
+ event.preventDefault();
2432
+ cancelReply();
2433
+ }
1365
2434
  }
1366
2435
  }),
1367
2436
  h3("button", {
1368
2437
  type: "submit",
1369
- "aria-label": "Send message",
1370
- disabled: !draft().trim() || props.isSending || submitting.value,
2438
+ "aria-label": editing ? "Save message" : "Send message",
2439
+ disabled: (editing ? !canSave(editing) : !draft().trim()) || busy,
1371
2440
  class: partClass("button", appearance(), "ckui-send-button"),
1372
2441
  style: partStyle("button", appearance())
1373
- }, [props.isSending || submitting.value ? h3(LoaderCircle2, { class: "ckui-spin", size: 18, "aria-hidden": "true" }) : h3(Send, { size: 18, "aria-hidden": "true" })])
2442
+ }, [busy ? h3(LoaderCircle2, { class: "ckui-spin", size: 18, "aria-hidden": "true" }) : h3(editing ? Check2 : Send, { size: 18, "aria-hidden": "true" })])
1374
2443
  ]);
1375
2444
  };
1376
2445
  return () => {
@@ -1403,6 +2472,7 @@ var ConversationView = defineComponent3({
1403
2472
  ...slots["read-receipt"] ? { "read-receipt": slots["read-receipt"] } : {},
1404
2473
  ...slots.empty ? { empty: slots.empty } : {},
1405
2474
  ...slots["loading-older"] ? { "loading-older": slots["loading-older"] } : {},
2475
+ ...slots["loading-newer"] ? { "loading-newer": slots["loading-newer"] } : {},
1406
2476
  ...slots["message-error"] ? { error: slots["message-error"] } : {}
1407
2477
  };
1408
2478
  children.push(h3(MessageListView, {
@@ -1419,6 +2489,25 @@ var ConversationView = defineComponent3({
1419
2489
  ...props.onAttachmentClick ? { onAttachmentClick: (media, message) => {
1420
2490
  props.onAttachmentClick?.(media, message);
1421
2491
  } } : {},
2492
+ ...props.onEditMessage ? { onEditMessage: (message) => {
2493
+ props.onEditMessage?.(message);
2494
+ } } : {},
2495
+ ...props.onDeleteMessage ? { onDeleteMessage: (message) => props.onDeleteMessage?.(message) } : {},
2496
+ ...props.canEditMessage ? { canEditMessage: props.canEditMessage } : {},
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 } : {},
1422
2511
  reverse: props.reverseMessages,
1423
2512
  stickToBottom: props.stickToBottom,
1424
2513
  paginationThreshold: props.paginationThreshold,
@@ -1429,6 +2518,19 @@ var ConversationView = defineComponent3({
1429
2518
  density: props.density,
1430
2519
  unstyled: props.unstyled
1431
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
+ }
1432
2534
  children.push(renderTyping(), renderComposer());
1433
2535
  return h3("section", {
1434
2536
  ...attrs,
@@ -1449,6 +2551,15 @@ var Conversation = defineComponent3({
1449
2551
  messages: { type: Array, default: () => [] },
1450
2552
  currentUserId: { type: String, default: "" },
1451
2553
  onSendMessage: { type: Function, default: void 0 },
2554
+ editingMessage: { type: Object, default: void 0 },
2555
+ onEditMessage: { type: Function, default: void 0 },
2556
+ onSaveEdit: { type: Function, default: void 0 },
2557
+ onCancelEdit: { type: Function, default: void 0 },
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 },
1452
2563
  client: { type: Object, required: true },
1453
2564
  conversationId: { type: String, required: true },
1454
2565
  messagePageSize: { type: Number, default: 30 },
@@ -1458,7 +2569,26 @@ var Conversation = defineComponent3({
1458
2569
  autoLoad: { type: Boolean, default: true },
1459
2570
  onControllerChange: { type: Function, default: void 0 }
1460
2571
  },
1461
- emits: ["controller-change", "send-message", "typing-change", "back", "refresh", "load-older", "add-attachment", "attachment-click", "update:modelValue"],
2572
+ emits: [
2573
+ "controller-change",
2574
+ "send-message",
2575
+ "typing-change",
2576
+ "back",
2577
+ "refresh",
2578
+ "load-older",
2579
+ "add-attachment",
2580
+ "attachment-click",
2581
+ "update:modelValue",
2582
+ "edit-message",
2583
+ "save-edit",
2584
+ "cancel-edit",
2585
+ "delete-message",
2586
+ "reply-to-message",
2587
+ "cancel-reply",
2588
+ "jump-to-message",
2589
+ "load-newer",
2590
+ "return-to-latest"
2591
+ ],
1462
2592
  setup(props, { attrs, emit, expose, slots }) {
1463
2593
  const controller = useConversation({
1464
2594
  client: () => props.client,
@@ -1522,6 +2652,22 @@ var Conversation = defineComponent3({
1522
2652
  isSending: _isSending,
1523
2653
  hasOlderMessages: _hasOlderMessages,
1524
2654
  error: _error,
2655
+ editingMessage: _editingMessage,
2656
+ onEditMessage: _onEditMessage,
2657
+ onSaveEdit: _onSaveEdit,
2658
+ onCancelEdit: _onCancelEdit,
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,
1525
2671
  ...forwarded
1526
2672
  } = props;
1527
2673
  return h3(ConversationView, {
@@ -1545,6 +2691,61 @@ var Conversation = defineComponent3({
1545
2691
  isSending: controller.isSending.value,
1546
2692
  hasOlderMessages: controller.hasOlderMessages.value,
1547
2693
  ...controller.error.value == null ? {} : { error: controller.error.value },
2694
+ // Edit mode is the store's; the actions render only while the adapter supports them (0.8.0).
2695
+ editingMessage: controller.editingMessage.value,
2696
+ ...controller.canEditMessages.value ? {
2697
+ onEditMessage: (message) => {
2698
+ emit("edit-message", message);
2699
+ controller.startEditing(message.id);
2700
+ },
2701
+ onSaveEdit: async (message, text) => {
2702
+ emit("save-edit", message, text);
2703
+ return controller.saveEdit(text);
2704
+ },
2705
+ onCancelEdit: () => {
2706
+ emit("cancel-edit");
2707
+ controller.cancelEditing();
2708
+ }
2709
+ } : {},
2710
+ ...controller.canDeleteMessages.value ? {
2711
+ onDeleteMessage: async (message) => {
2712
+ emit("delete-message", message);
2713
+ return controller.deleteMessage(message.id);
2714
+ }
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
+ } : {},
1548
2749
  "onUpdate:modelValue": (value) => emit("update:modelValue", value),
1549
2750
  ...props.onBack ? { onBack: () => {
1550
2751
  props.onBack?.();
@@ -1573,7 +2774,7 @@ import {
1573
2774
  } from "vue";
1574
2775
 
1575
2776
  // src/composables/use-conversation-list.ts
1576
- import { computed as computed3, getCurrentScope as getCurrentScope2, onScopeDispose as onScopeDispose2, shallowRef as shallowRef2, toValue as toValue2, watch as watch3 } from "vue";
2777
+ import { computed as computed3, getCurrentScope as getCurrentScope2, onScopeDispose as onScopeDispose2, shallowRef as shallowRef2, toValue as toValue2, watch as watch4 } from "vue";
1577
2778
 
1578
2779
  // src/conversation-list-store.ts
1579
2780
  var defaultActivityRefreshWindowMs = 500;
@@ -2031,7 +3232,7 @@ function useConversationList(options) {
2031
3232
  let store = createStore();
2032
3233
  const snapshot = shallowRef2(store.getSnapshot());
2033
3234
  let unsubscribe;
2034
- const stop = watch3(
3235
+ const stop = watch4(
2035
3236
  () => [toValue2(options.client), toValue2(options.client).sessionIdentity],
2036
3237
  () => {
2037
3238
  store.dispose();
@@ -2381,6 +3582,7 @@ var defaultConvoKitTheme = {
2381
3582
  outgoingBubble: "#18181b",
2382
3583
  outgoingText: "#fafafa",
2383
3584
  badge: "#18181b",
3585
+ highlight: "color-mix(in srgb, #18181b 14%, transparent)",
2384
3586
  radius: "10px",
2385
3587
  avatarSize: "40px",
2386
3588
  fontFamily: 'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'
@@ -2413,6 +3615,7 @@ var ConvoKitThemeProvider = defineComponent5({
2413
3615
  "--ckui-outgoing": theme.outgoingBubble,
2414
3616
  "--ckui-outgoing-text": theme.outgoingText,
2415
3617
  "--ckui-badge": theme.badge,
3618
+ "--ckui-highlight": theme.highlight,
2416
3619
  "--ckui-radius": theme.radius,
2417
3620
  "--ckui-avatar-size": theme.avatarSize,
2418
3621
  "--ckui-font": theme.fontFamily