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