@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/CHANGELOG.md +125 -0
- package/PARITY.md +9 -0
- package/README.md +126 -1
- package/dist/index.cjs +838 -28
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +79 -5
- package/dist/index.css.map +1 -1
- package/dist/index.d.cts +463 -7
- package/dist/index.d.ts +463 -7
- package/dist/index.js +840 -29
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
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
|
|
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 = {
|
|
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")
|
|
486
|
-
|
|
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"
|
|
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({
|
|
951
|
-
|
|
952
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
|
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(
|
|
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",
|
|
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
|