@convokitapp/vue-ui 0.7.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +225 -0
- package/PARITY.md +46 -0
- package/README.md +244 -5
- package/dist/index.cjs +1235 -36
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +167 -0
- package/dist/index.css.map +1 -1
- package/dist/index.d.cts +723 -7
- package/dist/index.d.ts +723 -7
- package/dist/index.js +1243 -40
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
package/dist/index.cjs
CHANGED
|
@@ -72,6 +72,10 @@ function createConvoKitUiClient(client) {
|
|
|
72
72
|
getMessages: (options) => client.getMessages(options),
|
|
73
73
|
getMessage: (id) => client.getMessage(id),
|
|
74
74
|
sendMessage: (input) => client.sendMessage(input),
|
|
75
|
+
editMessage: (messageId, input) => client.editMessage(messageId, input),
|
|
76
|
+
deleteMessage: (messageId) => client.deleteMessage(messageId),
|
|
77
|
+
getReplyPreviews: (conversationId, messageIds) => client.getReplyPreviews(conversationId, messageIds),
|
|
78
|
+
getMessageContext: (conversationId, options) => client.getMessageContext(conversationId, options),
|
|
75
79
|
markConversationRead: (conversationId, options) => client.markConversationRead(conversationId, options),
|
|
76
80
|
markConversationUnread: (conversationId) => client.markConversationUnread(conversationId),
|
|
77
81
|
clearConversationUnread: (conversationId, options) => client.clearConversationUnread(conversationId, options),
|
|
@@ -246,9 +250,33 @@ function version(message) {
|
|
|
246
250
|
function hasContent(message) {
|
|
247
251
|
return !!message.text?.trim() || message.media.length > 0;
|
|
248
252
|
}
|
|
253
|
+
function revisionOrder(left, right) {
|
|
254
|
+
const a = left.revision, b = right.revision;
|
|
255
|
+
if (typeof a !== "number" || typeof b !== "number" || a <= 0 && b <= 0) return void 0;
|
|
256
|
+
return a === b ? void 0 : a - b;
|
|
257
|
+
}
|
|
258
|
+
function older(candidate, reference) {
|
|
259
|
+
const byRevision = revisionOrder(candidate, reference);
|
|
260
|
+
return byRevision === void 0 ? version(candidate) < version(reference) : byRevision < 0;
|
|
261
|
+
}
|
|
249
262
|
function newest(current, incoming, incomingComplete = true) {
|
|
263
|
+
const byRevision = revisionOrder(current, incoming);
|
|
264
|
+
if (byRevision !== void 0) return byRevision > 0 ? current : incoming;
|
|
250
265
|
return version(current) > version(incoming) || !incomingComplete && version(current) === version(incoming) ? current : incoming;
|
|
251
266
|
}
|
|
267
|
+
function isRevisionConflict(cause) {
|
|
268
|
+
if (typeof cause !== "object" || cause === null) return false;
|
|
269
|
+
const { code, status } = cause;
|
|
270
|
+
return code === "REVISION_CONFLICT" || code === void 0 && status === 409;
|
|
271
|
+
}
|
|
272
|
+
function isMessageMissing(cause) {
|
|
273
|
+
return typeof cause === "object" && cause !== null && cause.code === "MESSAGE_NOT_FOUND";
|
|
274
|
+
}
|
|
275
|
+
function localConflict() {
|
|
276
|
+
return Object.assign(new Error("Message was changed since it was loaded"), { code: "REVISION_CONFLICT" });
|
|
277
|
+
}
|
|
278
|
+
var JUMP_GUARD_MS = 150;
|
|
279
|
+
var HIGHLIGHT_MS = 2e3;
|
|
252
280
|
var compare = compareMessageOrder;
|
|
253
281
|
function positionCursor(position) {
|
|
254
282
|
return { createdAt: position.createdAt, id: position.messageId };
|
|
@@ -268,7 +296,26 @@ function isTargetMiss(cause) {
|
|
|
268
296
|
const { code, status } = cause;
|
|
269
297
|
return code === "MESSAGE_NOT_FOUND" || code === void 0 && status === 404;
|
|
270
298
|
}
|
|
271
|
-
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 }) {
|
|
272
319
|
return {
|
|
273
320
|
conversation: null,
|
|
274
321
|
messages: [],
|
|
@@ -282,7 +329,19 @@ function blank(currentUserId = "") {
|
|
|
282
329
|
hasOlderMessages: true,
|
|
283
330
|
hasLoaded: false,
|
|
284
331
|
error: null,
|
|
285
|
-
currentUserId
|
|
332
|
+
currentUserId,
|
|
333
|
+
editingMessage: null,
|
|
334
|
+
canEditMessages: support.edit,
|
|
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
|
|
286
345
|
};
|
|
287
346
|
}
|
|
288
347
|
var ConversationStore = class {
|
|
@@ -301,7 +360,13 @@ var ConversationStore = class {
|
|
|
301
360
|
}
|
|
302
361
|
this.owner = this.client.sessionIdentity;
|
|
303
362
|
this.user = this.owner ? this.client.currentUserId : "";
|
|
304
|
-
this.
|
|
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
|
+
};
|
|
369
|
+
this.state = blank(this.user, this.support);
|
|
305
370
|
}
|
|
306
371
|
options;
|
|
307
372
|
client;
|
|
@@ -328,7 +393,34 @@ var ConversationStore = class {
|
|
|
328
393
|
visible = true;
|
|
329
394
|
sendRevision;
|
|
330
395
|
activeSend;
|
|
396
|
+
/** Adapter support for author edits/deletes, decided once like `listInbox`. */
|
|
397
|
+
support;
|
|
398
|
+
/** The id whose `saveEdit` request is in flight: its outcome (success or 409) decides the edit, so newer rows for
|
|
399
|
+
* it arriving meanwhile (its own UPDATE image, typically) are not reported as a local conflict while it lasts;
|
|
400
|
+
* `settleEditing` re-evaluates them once the request has settled any other way.
|
|
401
|
+
*/
|
|
402
|
+
activeEdit;
|
|
331
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;
|
|
332
424
|
typingTimers = /* @__PURE__ */ new Map();
|
|
333
425
|
ownTypingTimer;
|
|
334
426
|
sentTyping = false;
|
|
@@ -346,9 +438,33 @@ var ConversationStore = class {
|
|
|
346
438
|
for (const message of patch.messages) this.confirmSend(message);
|
|
347
439
|
if (this.activeSend.confirmed) patch.messages = patch.messages.filter((message) => message.id !== this.activeSend.pending.id);
|
|
348
440
|
}
|
|
441
|
+
if (patch.messages) patch = this.trackEditing(patch);
|
|
349
442
|
this.state = { ...this.state, ...patch };
|
|
350
443
|
for (const listener of this.listeners) listener();
|
|
351
444
|
}
|
|
445
|
+
/** Edit mode follows the edited row wherever a message list reaches the state: the row leaving the list (deletion,
|
|
446
|
+
* reconcile tombstone, eviction) ends it, and a row for it with a higher revision than the snapshot (UPDATE image,
|
|
447
|
+
* hydration, reconcile, refresh) is the local conflict: the snapshot is replaced and `error` carries the conflict
|
|
448
|
+
* code, without a request. A save in flight owns its own outcome (`activeEdit`) and re-checks when it settles
|
|
449
|
+
* (`settleEditing`).
|
|
450
|
+
*/
|
|
451
|
+
trackEditing(patch) {
|
|
452
|
+
const editing = patch.editingMessage === void 0 ? this.state.editingMessage : patch.editingMessage;
|
|
453
|
+
if (!editing || !patch.messages) return patch;
|
|
454
|
+
const live = patch.messages.find((message) => message.id === editing.id);
|
|
455
|
+
if (!live) return { ...patch, editingMessage: null };
|
|
456
|
+
if (this.activeEdit === editing.id || !(live.revision > editing.revision)) return patch;
|
|
457
|
+
return { ...patch, editingMessage: live, error: localConflict() };
|
|
458
|
+
}
|
|
459
|
+
/** After a save for `id` has settled without deciding the edit (a failure, or a 409 whose reload failed), a newer
|
|
460
|
+
* row for it that arrived during the request is the local conflict after all: the snapshot is replaced and
|
|
461
|
+
* `error` carries the conflict code, so the next save carries the fresh revision without another round trip.
|
|
462
|
+
*/
|
|
463
|
+
settleEditing(id) {
|
|
464
|
+
const editing = this.state.editingMessage;
|
|
465
|
+
const live = editing?.id === id ? this.state.messages.find((message) => message.id === id) : void 0;
|
|
466
|
+
if (editing && live && live.revision > editing.revision) this.patch({ editingMessage: live, error: localConflict() });
|
|
467
|
+
}
|
|
352
468
|
alive(generation = this.generation) {
|
|
353
469
|
return !this.disposed && generation === this.generation && this.owner !== null && this.client.sessionIdentity === this.owner;
|
|
354
470
|
}
|
|
@@ -392,7 +508,21 @@ var ConversationStore = class {
|
|
|
392
508
|
this.captured = capture();
|
|
393
509
|
this.sendRevision = void 0;
|
|
394
510
|
this.activeSend = void 0;
|
|
511
|
+
this.activeEdit = void 0;
|
|
395
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;
|
|
396
526
|
}
|
|
397
527
|
dispose = () => {
|
|
398
528
|
if (this.alive() && this.sentTyping) {
|
|
@@ -400,14 +530,14 @@ var ConversationStore = class {
|
|
|
400
530
|
}
|
|
401
531
|
this.disposed = true;
|
|
402
532
|
this.clear();
|
|
403
|
-
this.patch(blank());
|
|
533
|
+
this.patch(blank("", this.support));
|
|
404
534
|
};
|
|
405
535
|
fail(cause, generation, history = false) {
|
|
406
536
|
if (!this.alive(generation)) return;
|
|
407
537
|
const status = typeof cause === "object" && cause !== null && "status" in cause ? cause.status : void 0;
|
|
408
538
|
if (history && (status === 401 || status === 403 || status === 404)) {
|
|
409
539
|
this.clear();
|
|
410
|
-
this.patch({ ...blank(this.user), error: cause, hasLoaded: true, hasOlderMessages: false });
|
|
540
|
+
this.patch({ ...blank(this.user, this.support), error: cause, hasLoaded: true, hasOlderMessages: false });
|
|
411
541
|
} else this.patch({ error: cause });
|
|
412
542
|
}
|
|
413
543
|
attach(generation, data = true) {
|
|
@@ -422,8 +552,10 @@ var ConversationStore = class {
|
|
|
422
552
|
add(() => this.client.onConnectionEvent({
|
|
423
553
|
onEvent: ({ topic, status }) => {
|
|
424
554
|
if (!data || !this.alive(generation) || topic !== `messages:${this.room}` && topic !== `conversation:${this.room}`) return;
|
|
425
|
-
if (status === "SUBSCRIBED")
|
|
426
|
-
|
|
555
|
+
if (status === "SUBSCRIBED") {
|
|
556
|
+
this.markPreviewsStale();
|
|
557
|
+
this.queueRefresh();
|
|
558
|
+
} else this.clearTyping();
|
|
427
559
|
},
|
|
428
560
|
onSessionEnded: () => {
|
|
429
561
|
if (this.disposed || generation !== this.generation) return;
|
|
@@ -479,9 +611,12 @@ var ConversationStore = class {
|
|
|
479
611
|
if (!this.alive(generation) || type !== "insert" && type !== "update" || !this.validMessage(message) || this.deleted.has(message.id)) return;
|
|
480
612
|
const existing = this.state.messages.find((item) => item.id === message.id);
|
|
481
613
|
const known = existing ?? this.changes.get(message.id)?.message;
|
|
482
|
-
if (known &&
|
|
614
|
+
if (known && older(message, known)) return;
|
|
483
615
|
const insert = type === "insert" || this.changes.get(message.id)?.insert === true;
|
|
484
|
-
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
|
+
}
|
|
485
620
|
const revision = ++this.revision;
|
|
486
621
|
const provisional = existing && !message.media.length ? { ...message, media: existing.media } : message;
|
|
487
622
|
this.record(provisional, insert, revision, false);
|
|
@@ -492,14 +627,21 @@ var ConversationStore = class {
|
|
|
492
627
|
}
|
|
493
628
|
record(message, insert, revision, complete) {
|
|
494
629
|
const existing = this.state.messages.find((item) => item.id === message.id);
|
|
495
|
-
if (existing &&
|
|
630
|
+
if (existing && older(message, existing)) return;
|
|
496
631
|
if (this.confirmSend(message) && !complete && !message.media.length) {
|
|
497
632
|
message = { ...message, media: this.activeSend.pending.media };
|
|
498
633
|
this.confirmSend(message);
|
|
499
634
|
}
|
|
500
635
|
this.changes.set(message.id, { revision, message, insert, complete });
|
|
501
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
|
+
}
|
|
502
642
|
this.patch({ messages: mergeMessages(this.state.messages, [message]) });
|
|
643
|
+
this.notePreviewSource(message.id);
|
|
644
|
+
if (message.replyToMessageId) this.schedulePreviews();
|
|
503
645
|
if (!existing && message.senderId !== this.user && (this.options.markReadOnReceive ?? true)) void this.acknowledge(true);
|
|
504
646
|
}
|
|
505
647
|
confirmSend(message) {
|
|
@@ -521,6 +663,9 @@ var ConversationStore = class {
|
|
|
521
663
|
this.changes.delete(id);
|
|
522
664
|
this.hydrations.delete(id);
|
|
523
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 });
|
|
524
669
|
const ack = this.ack;
|
|
525
670
|
if (ack.target !== id && ack.acknowledged?.id !== id) return;
|
|
526
671
|
ack.unacknowledgeable.add(id);
|
|
@@ -543,7 +688,7 @@ var ConversationStore = class {
|
|
|
543
688
|
if (!this.currentHydration(job)) return;
|
|
544
689
|
const full = await this.client.getMessage(id);
|
|
545
690
|
if (!this.currentHydration(job)) return;
|
|
546
|
-
if (!this.validMessage(full) || full.id !== id || full.senderId !== job.message.senderId ||
|
|
691
|
+
if (!this.validMessage(full) || full.id !== id || full.senderId !== job.message.senderId || older(full, job.message)) {
|
|
547
692
|
throw new Error("Complete message response does not match the observed resource/revision");
|
|
548
693
|
}
|
|
549
694
|
this.record(full, job.insert, job.revision, true);
|
|
@@ -589,6 +734,23 @@ var ConversationStore = class {
|
|
|
589
734
|
previous = message;
|
|
590
735
|
}
|
|
591
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
|
+
}
|
|
592
754
|
fetchPage(before) {
|
|
593
755
|
return this.client.getMessages({
|
|
594
756
|
conversationId: this.room,
|
|
@@ -611,6 +773,148 @@ var ConversationStore = class {
|
|
|
611
773
|
}
|
|
612
774
|
return mergeMessages([], [...byId.values()]);
|
|
613
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
|
+
}
|
|
614
918
|
prune(revision) {
|
|
615
919
|
const safeRevision = Math.min(revision, this.sendRevision ?? Infinity);
|
|
616
920
|
for (const [id, change] of this.changes) if (change.revision <= safeRevision) this.changes.delete(id);
|
|
@@ -623,7 +927,7 @@ var ConversationStore = class {
|
|
|
623
927
|
if (!this.alive()) return;
|
|
624
928
|
this.clear();
|
|
625
929
|
const generation = this.generation;
|
|
626
|
-
this.patch({ ...blank(this.user), isInitialLoading: true });
|
|
930
|
+
this.patch({ ...blank(this.user, this.support), isInitialLoading: true });
|
|
627
931
|
const revision = this.revision;
|
|
628
932
|
try {
|
|
629
933
|
this.attach(generation);
|
|
@@ -637,6 +941,7 @@ var ConversationStore = class {
|
|
|
637
941
|
this.captured = capture(conversation);
|
|
638
942
|
this.mergeReads(conversation.participants.map(readEntry));
|
|
639
943
|
this.prune(revision);
|
|
944
|
+
void this.resolvePreviews();
|
|
640
945
|
if ((this.options.markReadOnLoad ?? true) || this.ack.suppressed) {
|
|
641
946
|
this.ack.suppressed = false;
|
|
642
947
|
await this.acknowledge(true);
|
|
@@ -652,19 +957,36 @@ var ConversationStore = class {
|
|
|
652
957
|
};
|
|
653
958
|
queueRefresh() {
|
|
654
959
|
this.refreshQueued = true;
|
|
960
|
+
this.jumpedRefreshQueued = true;
|
|
655
961
|
this.flushRefresh();
|
|
656
962
|
}
|
|
657
963
|
flushRefresh() {
|
|
658
964
|
const generation = this.generation;
|
|
659
965
|
void Promise.resolve().then(() => {
|
|
660
|
-
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
|
+
}
|
|
661
973
|
this.refreshQueued = false;
|
|
974
|
+
this.jumpedRefreshQueued = false;
|
|
662
975
|
void this.refresh();
|
|
663
976
|
});
|
|
664
977
|
}
|
|
665
978
|
/** Re-fetch the entire viewed range atomically; a first-page-only refresh loses history. */
|
|
666
979
|
refresh = async () => {
|
|
667
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
|
+
}
|
|
668
990
|
if (this.state.isInitialLoading || this.state.isLoadingOlder || this.state.isReconciling) {
|
|
669
991
|
this.refreshQueued = true;
|
|
670
992
|
return;
|
|
@@ -702,6 +1024,7 @@ var ConversationStore = class {
|
|
|
702
1024
|
if (opening) this.captured = capture(conversation);
|
|
703
1025
|
this.mergeReads(conversation.participants.map(readEntry));
|
|
704
1026
|
this.prune(revision);
|
|
1027
|
+
void this.resolvePreviews(true);
|
|
705
1028
|
if (opening) void this.resumeAcknowledgement();
|
|
706
1029
|
} catch (cause) {
|
|
707
1030
|
this.fail(cause, generation, true);
|
|
@@ -713,7 +1036,8 @@ var ConversationStore = class {
|
|
|
713
1036
|
}
|
|
714
1037
|
};
|
|
715
1038
|
loadOlderMessages = async () => {
|
|
716
|
-
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");
|
|
717
1041
|
const generation = this.generation;
|
|
718
1042
|
const revision = this.revision;
|
|
719
1043
|
const cursor = this.cursor;
|
|
@@ -727,6 +1051,7 @@ var ConversationStore = class {
|
|
|
727
1051
|
messages: this.overlay(mergeMessages(page, this.state.messages), revision),
|
|
728
1052
|
hasOlderMessages: page.length === this.pageSize
|
|
729
1053
|
});
|
|
1054
|
+
void this.resolvePreviews();
|
|
730
1055
|
} catch (cause) {
|
|
731
1056
|
this.fail(cause, generation, true);
|
|
732
1057
|
} finally {
|
|
@@ -736,6 +1061,298 @@ var ConversationStore = class {
|
|
|
736
1061
|
}
|
|
737
1062
|
}
|
|
738
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
|
+
};
|
|
739
1356
|
/** Acknowledge through the newest rendered row now, regardless of visibility; no acknowledgement without a
|
|
740
1357
|
* target (a room opened with a marker that renders nothing clears the marker instead, once).
|
|
741
1358
|
*/
|
|
@@ -765,6 +1382,7 @@ var ConversationStore = class {
|
|
|
765
1382
|
}
|
|
766
1383
|
/** Resolves when the request this call issued or joined settles; a follow-up is issued, not awaited. */
|
|
767
1384
|
acknowledge(automatic) {
|
|
1385
|
+
if (this.state.windowMode === "jumped") return Promise.resolve();
|
|
768
1386
|
const ack = this.ack;
|
|
769
1387
|
if (automatic && (!this.visible || this.state.conversation === null)) {
|
|
770
1388
|
ack.suppressed = true;
|
|
@@ -777,6 +1395,10 @@ var ConversationStore = class {
|
|
|
777
1395
|
return this.issue(ack, this.generation) ?? Promise.resolve();
|
|
778
1396
|
}
|
|
779
1397
|
issue(ack, generation) {
|
|
1398
|
+
if (this.state.windowMode === "jumped") {
|
|
1399
|
+
ack.followUp = true;
|
|
1400
|
+
return void 0;
|
|
1401
|
+
}
|
|
780
1402
|
ack.followUp = false;
|
|
781
1403
|
const target = this.ackTarget(ack);
|
|
782
1404
|
const request = target ? this.send(ack, generation, target) : this.clearMarker(ack, generation);
|
|
@@ -853,11 +1475,14 @@ var ConversationStore = class {
|
|
|
853
1475
|
sendMessage = async ({ text, media }) => {
|
|
854
1476
|
const normalized = text?.trim();
|
|
855
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;
|
|
856
1480
|
const generation = this.generation;
|
|
857
1481
|
const revision = this.revision;
|
|
858
1482
|
this.sendRevision = revision;
|
|
859
1483
|
const clientMessageId = (0, import_sdk2.createClientMessageId)();
|
|
860
1484
|
const pendingId = `convokit-pending-${clientMessageId}`;
|
|
1485
|
+
const replyToMessageId = this.state.replyTarget?.id;
|
|
861
1486
|
const pending = {
|
|
862
1487
|
id: pendingId,
|
|
863
1488
|
clientMessageId,
|
|
@@ -866,7 +1491,9 @@ var ConversationStore = class {
|
|
|
866
1491
|
text: normalized || null,
|
|
867
1492
|
media: media ?? [],
|
|
868
1493
|
createdAt: /* @__PURE__ */ new Date(),
|
|
869
|
-
updatedAt: null
|
|
1494
|
+
updatedAt: null,
|
|
1495
|
+
revision: 0,
|
|
1496
|
+
...replyToMessageId ? { replyToMessageId } : {}
|
|
870
1497
|
};
|
|
871
1498
|
const send = { pending };
|
|
872
1499
|
this.activeSend = send;
|
|
@@ -876,7 +1503,9 @@ var ConversationStore = class {
|
|
|
876
1503
|
conversationId: this.room,
|
|
877
1504
|
clientMessageId,
|
|
878
1505
|
...normalized ? { text: normalized } : {},
|
|
879
|
-
...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 } : {}
|
|
880
1509
|
});
|
|
881
1510
|
if (!this.alive(generation)) return null;
|
|
882
1511
|
if (!this.validMessage(message) || message.senderId !== this.user) throw new Error("Send response belongs to a different room or sender");
|
|
@@ -886,10 +1515,15 @@ var ConversationStore = class {
|
|
|
886
1515
|
let latest = existing ? newest(message, existing, live?.complete !== false) : message;
|
|
887
1516
|
if (live && live.revision > revision) latest = newest(latest, live.message, live.complete);
|
|
888
1517
|
if (!this.deleted.has(message.id)) this.changes.set(message.id, { revision: ++this.revision, message: latest, insert: true, complete: true });
|
|
889
|
-
this.patch({
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
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();
|
|
893
1527
|
void this.updateTyping(false);
|
|
894
1528
|
return this.alive(generation) ? latest : null;
|
|
895
1529
|
} catch (cause) {
|
|
@@ -910,6 +1544,132 @@ var ConversationStore = class {
|
|
|
910
1544
|
}
|
|
911
1545
|
}
|
|
912
1546
|
};
|
|
1547
|
+
/** The caller's role in the open room when known (0.7 `membership`, else the caller's participant row). */
|
|
1548
|
+
ownRole() {
|
|
1549
|
+
const conversation = this.state.conversation;
|
|
1550
|
+
return conversation?.membership?.role ?? conversation?.participants.find((participant) => participant.appUserId === this.user || participant.id === this.user)?.role;
|
|
1551
|
+
}
|
|
1552
|
+
/** A rendered, confirmed row of the caller's own that is not known to be gone. */
|
|
1553
|
+
ownRow(messageId) {
|
|
1554
|
+
const row = this.state.messages.find((message) => message.id === messageId);
|
|
1555
|
+
return row && row.senderId === this.user && !isConvoKitPendingMessage(row) && !this.deleted.has(messageId) ? row : void 0;
|
|
1556
|
+
}
|
|
1557
|
+
/** Enter edit mode on one of the caller's own confirmed messages (0.8.0): the row as it stands now becomes the
|
|
1558
|
+
* snapshot whose `revision` every save sends. A no-op unless the adapter implements `editMessage`, the row is
|
|
1559
|
+
* rendered, own, confirmed, not tombstoned and the caller's role (when known) is not `READ`. Sends nothing.
|
|
1560
|
+
*/
|
|
1561
|
+
startEditing = (messageId) => {
|
|
1562
|
+
if (!this.alive() || !this.support.edit || this.ownRole() === "READ") return;
|
|
1563
|
+
const row = this.ownRow(messageId);
|
|
1564
|
+
if (row) this.patch({ editingMessage: row, replyTarget: null });
|
|
1565
|
+
};
|
|
1566
|
+
/** Leave edit mode without a request; the draft is the view's to restore. */
|
|
1567
|
+
cancelEditing = () => {
|
|
1568
|
+
if (this.state.editingMessage) this.patch({ editingMessage: null });
|
|
1569
|
+
};
|
|
1570
|
+
/** Save the edit in progress with the snapshot's revision (never the live row's), trimming the text and sending
|
|
1571
|
+
* `null` for an empty caption. Resolves true when the server accepted the edit (the response is merged through the
|
|
1572
|
+
* tombstone and precedence guards and edit mode ends); false when nothing was saved: a stale revision (409
|
|
1573
|
+
* `REVISION_CONFLICT`) reloads the row once through `getMessage`, replaces the snapshot with it (the next save
|
|
1574
|
+
* carries the fresh revision) and reports the conflict through `error`, keeping edit mode; a coded 404
|
|
1575
|
+
* (`MESSAGE_NOT_FOUND`, on the save or on that reload) removes the row and ends edit mode; any other failure
|
|
1576
|
+
* (403, 500, network, an uncoded 404 from a 0.7 backend) is reported through `error` without evicting anything and
|
|
1577
|
+
* keeps edit mode; if a newer row for the message arrived during such a request, that row is then the local
|
|
1578
|
+
* conflict (`settleEditing`). A text-only message cannot be saved empty (no request). Rejects when the adapter
|
|
1579
|
+
* lacks `editMessage`.
|
|
1580
|
+
*/
|
|
1581
|
+
saveEdit = async (text) => {
|
|
1582
|
+
const client = this.client;
|
|
1583
|
+
if (typeof client.editMessage !== "function") {
|
|
1584
|
+
throw new TypeError("This ConvoKitUiClient adapter does not implement editMessage (0.8)");
|
|
1585
|
+
}
|
|
1586
|
+
const snapshot = this.state.editingMessage;
|
|
1587
|
+
if (!this.alive() || !snapshot || this.activeEdit !== void 0) return false;
|
|
1588
|
+
const trimmed = text.trim();
|
|
1589
|
+
const normalized = trimmed === "" ? null : trimmed;
|
|
1590
|
+
if (normalized === null && snapshot.media.length === 0) return false;
|
|
1591
|
+
const generation = this.generation;
|
|
1592
|
+
const id = snapshot.id;
|
|
1593
|
+
this.activeEdit = id;
|
|
1594
|
+
this.patch({ error: null });
|
|
1595
|
+
try {
|
|
1596
|
+
const message = await client.editMessage(id, { text: normalized, revision: snapshot.revision });
|
|
1597
|
+
if (!this.alive(generation)) return false;
|
|
1598
|
+
if (!this.validMessage(message) || message.id !== id || message.senderId !== this.user) {
|
|
1599
|
+
throw new Error("Edit response belongs to a different message or sender");
|
|
1600
|
+
}
|
|
1601
|
+
if (this.state.editingMessage?.id === id) this.patch({ editingMessage: null });
|
|
1602
|
+
this.applyRow(message);
|
|
1603
|
+
return true;
|
|
1604
|
+
} catch (cause) {
|
|
1605
|
+
if (!this.alive(generation)) return false;
|
|
1606
|
+
if (isRevisionConflict(cause)) await this.reloadConflict(id, cause, generation);
|
|
1607
|
+
else if (isMessageMissing(cause)) {
|
|
1608
|
+
this.removeMessage(id);
|
|
1609
|
+
this.patch({ error: cause });
|
|
1610
|
+
} else this.fail(cause, generation);
|
|
1611
|
+
return false;
|
|
1612
|
+
} finally {
|
|
1613
|
+
if (this.alive(generation)) {
|
|
1614
|
+
this.activeEdit = void 0;
|
|
1615
|
+
this.settleEditing(id);
|
|
1616
|
+
}
|
|
1617
|
+
}
|
|
1618
|
+
};
|
|
1619
|
+
/** Merge a complete REST row for a known id through the live-row guards: a tombstoned id is dropped, and an older
|
|
1620
|
+
* revision (or timestamp) never overwrites the newer row already recorded. Recorded as a non-insert change, so a
|
|
1621
|
+
* reconcile keeps it only while the row is still in the fetched range.
|
|
1622
|
+
*/
|
|
1623
|
+
applyRow(message) {
|
|
1624
|
+
if (this.deleted.has(message.id)) return;
|
|
1625
|
+
this.record(message, this.changes.get(message.id)?.insert === true, ++this.revision, true);
|
|
1626
|
+
}
|
|
1627
|
+
/** The 409 path: one `getMessage` shows the conflicting content. Its row is merged through the guards and becomes
|
|
1628
|
+
* the new snapshot; a `MESSAGE_NOT_FOUND` answer removes the row and ends edit mode; another failure keeps the
|
|
1629
|
+
* snapshot. `error` carries the conflict (or the reload failure).
|
|
1630
|
+
*/
|
|
1631
|
+
async reloadConflict(id, conflict, generation) {
|
|
1632
|
+
try {
|
|
1633
|
+
const current = await this.client.getMessage(id);
|
|
1634
|
+
if (!this.alive(generation)) return;
|
|
1635
|
+
if (!this.validMessage(current) || current.id !== id) throw new Error("Complete message response does not match the edited message");
|
|
1636
|
+
this.applyRow(current);
|
|
1637
|
+
const row = this.state.messages.find((message) => message.id === id);
|
|
1638
|
+
const editing = this.state.editingMessage?.id === id && row ? { editingMessage: row } : {};
|
|
1639
|
+
this.patch({ ...editing, error: conflict });
|
|
1640
|
+
} catch (cause) {
|
|
1641
|
+
if (!this.alive(generation)) return;
|
|
1642
|
+
if (isTargetMiss(cause)) this.removeMessage(id);
|
|
1643
|
+
this.patch({ error: cause });
|
|
1644
|
+
}
|
|
1645
|
+
}
|
|
1646
|
+
/** Delete one of the caller's own confirmed messages (0.8.0). The row stays until the server answers: on success,
|
|
1647
|
+
* or when the server no longer knows it (`MESSAGE_NOT_FOUND`), it is tombstoned and removed (late responses, row
|
|
1648
|
+
* images and hydrations for it are dropped, the acknowledgement target is re-resolved and edit mode on it ends)
|
|
1649
|
+
* and the call resolves true; any other failure keeps the row, reports through `error` and resolves false. Rejects
|
|
1650
|
+
* when the adapter lacks `deleteMessage`.
|
|
1651
|
+
*/
|
|
1652
|
+
deleteMessage = async (messageId) => {
|
|
1653
|
+
const client = this.client;
|
|
1654
|
+
if (typeof client.deleteMessage !== "function") {
|
|
1655
|
+
throw new TypeError("This ConvoKitUiClient adapter does not implement deleteMessage (0.8)");
|
|
1656
|
+
}
|
|
1657
|
+
if (!this.alive() || !this.ownRow(messageId)) return false;
|
|
1658
|
+
const generation = this.generation;
|
|
1659
|
+
this.patch({ error: null });
|
|
1660
|
+
try {
|
|
1661
|
+
await client.deleteMessage(messageId);
|
|
1662
|
+
} catch (cause) {
|
|
1663
|
+
if (!this.alive(generation)) return false;
|
|
1664
|
+
if (!isMessageMissing(cause)) {
|
|
1665
|
+
this.fail(cause, generation);
|
|
1666
|
+
return false;
|
|
1667
|
+
}
|
|
1668
|
+
}
|
|
1669
|
+
if (!this.alive(generation)) return false;
|
|
1670
|
+
this.removeMessage(messageId);
|
|
1671
|
+
return true;
|
|
1672
|
+
};
|
|
913
1673
|
readerIdsFor = (message) => readerIdsFor(message, this.state.readAtByUserId, this.state.readPositionByUserId);
|
|
914
1674
|
};
|
|
915
1675
|
|
|
@@ -963,11 +1723,32 @@ function useConversation(options) {
|
|
|
963
1723
|
hasLoaded: field("hasLoaded"),
|
|
964
1724
|
error: field("error"),
|
|
965
1725
|
currentUserId: field("currentUserId"),
|
|
1726
|
+
editingMessage: field("editingMessage"),
|
|
1727
|
+
canEditMessages: field("canEditMessages"),
|
|
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"),
|
|
966
1738
|
readerIdsFor: (message) => store.readerIdsFor(message),
|
|
967
1739
|
loadInitial: () => store.loadInitial(),
|
|
968
1740
|
refresh: () => store.refresh(),
|
|
969
1741
|
loadOlderMessages: () => store.loadOlderMessages(),
|
|
970
1742
|
sendMessage: (input) => store.sendMessage(input),
|
|
1743
|
+
startEditing: (messageId) => store.startEditing(messageId),
|
|
1744
|
+
cancelEditing: () => store.cancelEditing(),
|
|
1745
|
+
saveEdit: (text) => store.saveEdit(text),
|
|
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(),
|
|
971
1752
|
markRead: () => store.markRead(),
|
|
972
1753
|
updateTyping: (isTyping) => store.updateTyping(isTyping),
|
|
973
1754
|
setVisible: (value) => {
|
|
@@ -979,6 +1760,7 @@ function useConversation(options) {
|
|
|
979
1760
|
}
|
|
980
1761
|
|
|
981
1762
|
// src/components/message-list.ts
|
|
1763
|
+
var import_sdk3 = require("@convokitapp/sdk");
|
|
982
1764
|
var import_vue4 = require("@lucide/vue");
|
|
983
1765
|
var import_vue5 = require("vue");
|
|
984
1766
|
var appearanceProps = {
|
|
@@ -1033,6 +1815,19 @@ var MessageListView = (0, import_vue5.defineComponent)({
|
|
|
1033
1815
|
isLoadingOlder: { type: Boolean, default: false },
|
|
1034
1816
|
error: { type: null, required: false },
|
|
1035
1817
|
onAttachmentClick: { type: Function, default: void 0 },
|
|
1818
|
+
onEditMessage: { type: Function, default: void 0 },
|
|
1819
|
+
onDeleteMessage: { type: Function, default: void 0 },
|
|
1820
|
+
canEditMessage: { type: Function, default: void 0 },
|
|
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 },
|
|
1036
1831
|
scrollElement: { type: Object, default: void 0 },
|
|
1037
1832
|
paginationThreshold: { type: Number, default: 240 },
|
|
1038
1833
|
reverse: { type: Boolean, default: true },
|
|
@@ -1040,12 +1835,18 @@ var MessageListView = (0, import_vue5.defineComponent)({
|
|
|
1040
1835
|
formatTime: { type: Function, default: formatMessageTime },
|
|
1041
1836
|
imageLoading: { type: String, default: "lazy" }
|
|
1042
1837
|
},
|
|
1043
|
-
emits: ["load-older", "attachment-click"],
|
|
1838
|
+
emits: ["load-older", "load-newer", "attachment-click", "edit-message", "delete-message", "reply-to-message", "jump-to-message"],
|
|
1044
1839
|
setup(props, { attrs, emit, slots }) {
|
|
1045
1840
|
const internalElement = (0, import_vue5.ref)(null);
|
|
1841
|
+
const confirming = (0, import_vue5.ref)(null);
|
|
1842
|
+
const highlightCleared = (0, import_vue5.ref)(false);
|
|
1046
1843
|
let requestInFlight = false;
|
|
1047
1844
|
let lastRequestedLength = null;
|
|
1845
|
+
let newerInFlight = false;
|
|
1846
|
+
let lastNewerLength = null;
|
|
1048
1847
|
let previousMessageCount = 0;
|
|
1848
|
+
let scrolledTo = null;
|
|
1849
|
+
let jumpArmed = false;
|
|
1049
1850
|
const participants = (0, import_vue5.computed)(() => new Map(props.conversation.participants.flatMap((participant) => [
|
|
1050
1851
|
[participant.id, participant],
|
|
1051
1852
|
[participant.appUserId, participant]
|
|
@@ -1068,13 +1869,26 @@ var MessageListView = (0, import_vue5.defineComponent)({
|
|
|
1068
1869
|
requestInFlight = false;
|
|
1069
1870
|
}
|
|
1070
1871
|
};
|
|
1071
|
-
|
|
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]) => {
|
|
1072
1885
|
const previous = previousMessageCount;
|
|
1073
1886
|
if (count !== previousMessageCount || !hasOlder) lastRequestedLength = null;
|
|
1887
|
+
if (count !== previousMessageCount || !hasNewer) lastNewerLength = null;
|
|
1074
1888
|
const appended = count > previous;
|
|
1075
1889
|
const element = internalElement.value;
|
|
1076
1890
|
previousMessageCount = count;
|
|
1077
|
-
if (element && props.reverse && props.stickToBottom && appended) {
|
|
1891
|
+
if (element && props.reverse && props.stickToBottom && appended && !props.jumpInFlight) {
|
|
1078
1892
|
const distanceFromBottom = element.scrollHeight - element.scrollTop - element.clientHeight;
|
|
1079
1893
|
if (previous === 0 || distanceFromBottom < 320) {
|
|
1080
1894
|
await (0, import_vue5.nextTick)();
|
|
@@ -1082,16 +1896,136 @@ var MessageListView = (0, import_vue5.defineComponent)({
|
|
|
1082
1896
|
}
|
|
1083
1897
|
}
|
|
1084
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" });
|
|
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);
|
|
1917
|
+
const remove = async (message) => {
|
|
1918
|
+
if (props.confirmDelete && !await props.confirmDelete(message)) return false;
|
|
1919
|
+
return await props.onDeleteMessage?.(message) !== false;
|
|
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
|
+
};
|
|
1085
1936
|
const renderMessage = (message, index) => {
|
|
1086
1937
|
const isCurrentUser = message.senderId === props.currentUserId;
|
|
1087
1938
|
const sender = participants.value.get(message.senderId);
|
|
1088
1939
|
const isPending = isConvoKitPendingMessage(message);
|
|
1089
1940
|
const readerIds = isPending ? /* @__PURE__ */ new Set() : props.readersResolver ? props.readersResolver(message) : readerIdsFor(message, props.readAtByUserId, props.readPositionByUserId);
|
|
1090
|
-
const
|
|
1941
|
+
const isEdited = !isPending && (0, import_sdk3.isEditedMessage)(message);
|
|
1942
|
+
const eligible = !isPending && (props.canEditMessage ? props.canEditMessage(message) : isCurrentUser && viewerRole.value !== "READ");
|
|
1943
|
+
const canEdit = eligible && !!props.onEditMessage;
|
|
1944
|
+
const canDelete = eligible && !!props.onDeleteMessage;
|
|
1945
|
+
const replyEligible = !isPending && (props.canReplyToMessage ? props.canReplyToMessage(message) : viewerRole.value !== "READ");
|
|
1946
|
+
const canReply = replyEligible && !!props.onReplyToMessage;
|
|
1947
|
+
const edit = () => {
|
|
1948
|
+
props.onEditMessage?.(message);
|
|
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;
|
|
1958
|
+
const slotProps = {
|
|
1959
|
+
message,
|
|
1960
|
+
chronologicalIndex: index,
|
|
1961
|
+
isCurrentUser,
|
|
1962
|
+
sender,
|
|
1963
|
+
readerIds,
|
|
1964
|
+
isEdited,
|
|
1965
|
+
canEdit,
|
|
1966
|
+
canDelete,
|
|
1967
|
+
canReply,
|
|
1968
|
+
...canEdit ? { edit } : {},
|
|
1969
|
+
...canDelete ? { remove: () => remove(message) } : {},
|
|
1970
|
+
...canReply ? { reply: replyTo } : {},
|
|
1971
|
+
...parentId && replyPreview !== void 0 ? { replyPreview } : {},
|
|
1972
|
+
...jumpToReplyTarget ? { jumpToReplyTarget } : {}
|
|
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;
|
|
1091
1979
|
const custom = slots.message?.(slotProps);
|
|
1092
|
-
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
|
+
}
|
|
1093
1988
|
const currentAppearance = appearance();
|
|
1094
1989
|
const messagePart = isCurrentUser ? "outgoingMessage" : "incomingMessage";
|
|
1990
|
+
const iconButton = (label, onClick, icon) => (0, import_vue5.h)("button", {
|
|
1991
|
+
type: "button",
|
|
1992
|
+
"aria-label": label,
|
|
1993
|
+
onClick,
|
|
1994
|
+
class: partClass("button", currentAppearance, "ckui-icon-button"),
|
|
1995
|
+
style: partStyle("button", currentAppearance)
|
|
1996
|
+
}, [icon]);
|
|
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" }))] : [],
|
|
1999
|
+
...canEdit ? [iconButton("Edit message", edit, (0, import_vue5.h)(import_vue4.Pencil, { size: 16, "aria-hidden": "true" }))] : [],
|
|
2000
|
+
...canDelete ? [iconButton("Delete message", () => {
|
|
2001
|
+
if (props.confirmDelete) void remove(message);
|
|
2002
|
+
else confirming.value = message.id;
|
|
2003
|
+
}, (0, import_vue5.h)(import_vue4.Trash2, { size: 16, "aria-hidden": "true" }))] : []
|
|
2004
|
+
]) : null;
|
|
2005
|
+
const confirm = canDelete && confirming.value === message.id ? (0, import_vue5.h)("div", {
|
|
2006
|
+
class: "ckui-message-confirm",
|
|
2007
|
+
role: "group",
|
|
2008
|
+
"aria-label": "Delete this message?"
|
|
2009
|
+
}, [
|
|
2010
|
+
(0, import_vue5.h)("span", "Delete this message?"),
|
|
2011
|
+
(0, import_vue5.h)("button", {
|
|
2012
|
+
type: "button",
|
|
2013
|
+
class: "ckui-link-button",
|
|
2014
|
+
"aria-label": "Confirm delete",
|
|
2015
|
+
onClick: () => {
|
|
2016
|
+
confirming.value = null;
|
|
2017
|
+
void props.onDeleteMessage?.(message);
|
|
2018
|
+
}
|
|
2019
|
+
}, "Delete"),
|
|
2020
|
+
(0, import_vue5.h)("button", {
|
|
2021
|
+
type: "button",
|
|
2022
|
+
class: "ckui-link-button",
|
|
2023
|
+
"aria-label": "Cancel delete",
|
|
2024
|
+
onClick: () => {
|
|
2025
|
+
confirming.value = null;
|
|
2026
|
+
}
|
|
2027
|
+
}, "Cancel")
|
|
2028
|
+
]) : null;
|
|
1095
2029
|
const mediaNodes = message.media.map((media, mediaIndex) => {
|
|
1096
2030
|
const open = props.onAttachmentClick ? () => {
|
|
1097
2031
|
props.onAttachmentClick?.(media, message);
|
|
@@ -1104,26 +2038,33 @@ var MessageListView = (0, import_vue5.defineComponent)({
|
|
|
1104
2038
|
}, slots.media?.(mediaSlotProps) ?? [defaultMedia(media, open, props.imageLoading)]);
|
|
1105
2039
|
});
|
|
1106
2040
|
const receiptSlotProps = { message, readerIds };
|
|
2041
|
+
const quote = parentId ? renderQuote(parentId, replyPreview, jumpToReplyTarget) : null;
|
|
1107
2042
|
return (0, import_vue5.h)("div", { key: message.id, role: "listitem" }, [
|
|
1108
2043
|
(0, import_vue5.h)("article", {
|
|
1109
2044
|
class: cx(
|
|
1110
2045
|
!props.unstyled && "ckui-message-row",
|
|
1111
2046
|
isCurrentUser && !props.unstyled && "ckui-message-row--outgoing",
|
|
2047
|
+
highlighted && "ckui-message-highlight",
|
|
1112
2048
|
props.classNames?.message,
|
|
1113
2049
|
props.classNames?.[messagePart]
|
|
1114
2050
|
),
|
|
1115
2051
|
style: [props.styles?.message, props.styles?.[messagePart]],
|
|
1116
|
-
|
|
2052
|
+
...anchor
|
|
1117
2053
|
}, [
|
|
2054
|
+
// Spread, not null: an absent action/label/prompt must not leave a comment node (0.7 markup stays byte-identical).
|
|
2055
|
+
...actions ? [actions] : [],
|
|
1118
2056
|
(0, import_vue5.h)("div", { class: "ckui-message-bubble" }, [
|
|
1119
2057
|
!isCurrentUser ? (0, import_vue5.h)("strong", { class: "ckui-message-sender" }, sender?.name || message.senderId) : null,
|
|
2058
|
+
...quote ? [quote] : [],
|
|
1120
2059
|
message.text ? (0, import_vue5.h)("div", { class: "ckui-message-text" }, message.text) : null,
|
|
1121
2060
|
...mediaNodes,
|
|
1122
2061
|
(0, import_vue5.h)("span", { class: "ckui-message-time" }, [
|
|
1123
2062
|
isPending ? "Sending\u2026" : props.formatTime(message.createdAt),
|
|
2063
|
+
...isEdited ? [(0, import_vue5.h)("span", { class: "ckui-message-edited", "aria-label": "Edited" }, "Edited")] : [],
|
|
1124
2064
|
isCurrentUser && !isPending ? readerIds.size > 0 ? (0, import_vue5.h)(import_vue4.CheckCheck, { size: 14, "aria-label": "Read" }) : (0, import_vue5.h)(import_vue4.Check, { size: 14, "aria-label": "Sent" }) : null
|
|
1125
2065
|
])
|
|
1126
2066
|
]),
|
|
2067
|
+
...confirm ? [confirm] : [],
|
|
1127
2068
|
isCurrentUser && !isPending ? slots["read-receipt"]?.(receiptSlotProps) ?? (0, import_vue5.h)("div", {
|
|
1128
2069
|
class: partClass("receipt", currentAppearance, "ckui-read-receipt"),
|
|
1129
2070
|
style: partStyle("receipt", currentAppearance)
|
|
@@ -1162,6 +2103,13 @@ var MessageListView = (0, import_vue5.defineComponent)({
|
|
|
1162
2103
|
} else {
|
|
1163
2104
|
children.push(...props.messages.map(renderMessage));
|
|
1164
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
|
+
}
|
|
1165
2113
|
return (0, import_vue5.h)("div", {
|
|
1166
2114
|
...attrs,
|
|
1167
2115
|
ref: (element) => {
|
|
@@ -1174,12 +2122,18 @@ var MessageListView = (0, import_vue5.defineComponent)({
|
|
|
1174
2122
|
role: "log",
|
|
1175
2123
|
"aria-live": "polite",
|
|
1176
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" } : {},
|
|
1177
2127
|
onScroll: (event) => {
|
|
1178
2128
|
const nativeHandler = attrs.onScroll;
|
|
1179
2129
|
if (typeof nativeHandler === "function") nativeHandler(event);
|
|
2130
|
+
if (props.jumpInFlight) return;
|
|
2131
|
+
if (props.highlightedMessageId && !highlightCleared.value) highlightCleared.value = true;
|
|
1180
2132
|
const element = event.currentTarget;
|
|
1181
2133
|
const distanceFromOldest = props.reverse ? element.scrollTop : element.scrollHeight - element.scrollTop - element.clientHeight;
|
|
1182
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();
|
|
1183
2137
|
}
|
|
1184
2138
|
}, children);
|
|
1185
2139
|
};
|
|
@@ -1212,6 +2166,25 @@ var viewProps = {
|
|
|
1212
2166
|
onTypingChange: { type: Function, default: void 0 },
|
|
1213
2167
|
onAddAttachment: { type: Function, default: void 0 },
|
|
1214
2168
|
onAttachmentClick: { type: Function, default: void 0 },
|
|
2169
|
+
editingMessage: { type: Object, default: null },
|
|
2170
|
+
onEditMessage: { type: Function, default: void 0 },
|
|
2171
|
+
onSaveEdit: { type: Function, default: void 0 },
|
|
2172
|
+
onCancelEdit: { type: Function, default: void 0 },
|
|
2173
|
+
onDeleteMessage: { type: Function, default: void 0 },
|
|
2174
|
+
canEditMessage: { type: Function, default: void 0 },
|
|
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 },
|
|
1215
2188
|
isInitialLoading: { type: Boolean, default: false },
|
|
1216
2189
|
isLoadingOlder: { type: Boolean, default: false },
|
|
1217
2190
|
isSending: { type: Boolean, default: false },
|
|
@@ -1231,6 +2204,9 @@ var viewProps = {
|
|
|
1231
2204
|
defaultDraft: { type: String, default: "" },
|
|
1232
2205
|
onDraftChange: { type: Function, default: void 0 }
|
|
1233
2206
|
};
|
|
2207
|
+
function messageSummary(message) {
|
|
2208
|
+
return message.text?.trim() || (message.media.length === 1 ? "1 attachment" : `${message.media.length} attachments`);
|
|
2209
|
+
}
|
|
1234
2210
|
function typingLabel(userIds, displayNameForUser) {
|
|
1235
2211
|
const names = [...userIds].map(displayNameForUser);
|
|
1236
2212
|
if (names.length === 0) return "";
|
|
@@ -1250,7 +2226,16 @@ var ConversationView = (0, import_vue7.defineComponent)({
|
|
|
1250
2226
|
"load-older",
|
|
1251
2227
|
"add-attachment",
|
|
1252
2228
|
"attachment-click",
|
|
1253
|
-
"update:modelValue"
|
|
2229
|
+
"update:modelValue",
|
|
2230
|
+
"edit-message",
|
|
2231
|
+
"save-edit",
|
|
2232
|
+
"cancel-edit",
|
|
2233
|
+
"delete-message",
|
|
2234
|
+
"reply-to-message",
|
|
2235
|
+
"cancel-reply",
|
|
2236
|
+
"jump-to-message",
|
|
2237
|
+
"load-newer",
|
|
2238
|
+
"return-to-latest"
|
|
1254
2239
|
],
|
|
1255
2240
|
setup(props, { attrs, emit, slots }) {
|
|
1256
2241
|
const internalDraft = (0, import_vue7.ref)(props.defaultDraft);
|
|
@@ -1263,15 +2248,61 @@ var ConversationView = (0, import_vue7.defineComponent)({
|
|
|
1263
2248
|
});
|
|
1264
2249
|
const draft = () => props.modelValue ?? internalDraft.value;
|
|
1265
2250
|
let latestDraft = draft();
|
|
1266
|
-
|
|
2251
|
+
let stash;
|
|
2252
|
+
let saving = false;
|
|
2253
|
+
const setDraft = (value, typing = true) => {
|
|
1267
2254
|
latestDraft = value;
|
|
1268
2255
|
if (props.modelValue === void 0) internalDraft.value = value;
|
|
1269
2256
|
props.onDraftChange?.(value);
|
|
1270
2257
|
emit("update:modelValue", value);
|
|
1271
|
-
|
|
1272
|
-
|
|
2258
|
+
if (typing) void props.onTypingChange?.(value.trim().length > 0);
|
|
2259
|
+
};
|
|
2260
|
+
const enterEdit = (message) => {
|
|
2261
|
+
if (stash === void 0) stash = draft();
|
|
2262
|
+
setDraft(message.text ?? "", false);
|
|
2263
|
+
};
|
|
2264
|
+
const leaveEdit = (message, restore) => {
|
|
2265
|
+
if (stash === void 0) return;
|
|
2266
|
+
const saved = stash;
|
|
2267
|
+
stash = void 0;
|
|
2268
|
+
const current = draft();
|
|
2269
|
+
if (restore === "always" || current.trim() === "" || current === (message.text ?? "")) setDraft(saved);
|
|
1273
2270
|
};
|
|
2271
|
+
(0, import_vue7.watch)(() => props.editingMessage, (next, previous) => {
|
|
2272
|
+
if (next && (!previous || previous.id !== next.id)) enterEdit(next);
|
|
2273
|
+
else if (!next && previous && !saving) leaveEdit(previous, "unchanged");
|
|
2274
|
+
}, { immediate: true });
|
|
2275
|
+
const cancelEdit = () => {
|
|
2276
|
+
const editing = props.editingMessage;
|
|
2277
|
+
if (!editing) return;
|
|
2278
|
+
leaveEdit(editing, "always");
|
|
2279
|
+
props.onCancelEdit?.();
|
|
2280
|
+
};
|
|
2281
|
+
const cancelReply = () => {
|
|
2282
|
+
props.onCancelReply?.();
|
|
2283
|
+
};
|
|
2284
|
+
const returnToLatest = () => props.onReturnToLatest?.();
|
|
2285
|
+
const canSave = (editing) => draft().trim().length > 0 || editing.media.length > 0;
|
|
1274
2286
|
const submit = async () => {
|
|
2287
|
+
const editing = props.editingMessage;
|
|
2288
|
+
if (editing) {
|
|
2289
|
+
const text2 = draft().trim();
|
|
2290
|
+
if (!canSave(editing) || props.isSending || submitting.value) return;
|
|
2291
|
+
submitting.value = true;
|
|
2292
|
+
saving = true;
|
|
2293
|
+
try {
|
|
2294
|
+
const saved = await props.onSaveEdit?.(editing, text2);
|
|
2295
|
+
if (saved === false) {
|
|
2296
|
+
if (props.editingMessage === null) leaveEdit(editing, "unchanged");
|
|
2297
|
+
return;
|
|
2298
|
+
}
|
|
2299
|
+
leaveEdit(editing, "always");
|
|
2300
|
+
} finally {
|
|
2301
|
+
saving = false;
|
|
2302
|
+
submitting.value = false;
|
|
2303
|
+
}
|
|
2304
|
+
return;
|
|
2305
|
+
}
|
|
1275
2306
|
const originalDraft = draft();
|
|
1276
2307
|
const text = originalDraft.trim();
|
|
1277
2308
|
if (!text || props.isSending || submitting.value) return;
|
|
@@ -1295,6 +2326,7 @@ var ConversationView = (0, import_vue7.defineComponent)({
|
|
|
1295
2326
|
};
|
|
1296
2327
|
const refresh = () => props.onRefresh?.();
|
|
1297
2328
|
const loadOlder = () => props.onLoadOlder?.();
|
|
2329
|
+
const loadNewer = () => props.onLoadNewer?.();
|
|
1298
2330
|
const addAttachment = () => {
|
|
1299
2331
|
props.onAddAttachment?.();
|
|
1300
2332
|
};
|
|
@@ -1340,6 +2372,8 @@ var ConversationView = (0, import_vue7.defineComponent)({
|
|
|
1340
2372
|
}, typingLabel(props.typingUserIds, nameForUser));
|
|
1341
2373
|
};
|
|
1342
2374
|
const renderComposer = () => {
|
|
2375
|
+
const editing = props.editingMessage;
|
|
2376
|
+
const replying = editing ? null : props.replyTarget;
|
|
1343
2377
|
const slotProps = {
|
|
1344
2378
|
value: draft(),
|
|
1345
2379
|
setValue: setDraft,
|
|
@@ -1347,16 +2381,40 @@ var ConversationView = (0, import_vue7.defineComponent)({
|
|
|
1347
2381
|
send: () => {
|
|
1348
2382
|
void submit();
|
|
1349
2383
|
},
|
|
1350
|
-
...props.onAddAttachment ? { addAttachment } : {}
|
|
2384
|
+
...props.onAddAttachment ? { addAttachment } : {},
|
|
2385
|
+
...editing ? { editing, cancelEdit } : {},
|
|
2386
|
+
...replying ? { replying, cancelReply } : {}
|
|
1351
2387
|
};
|
|
2388
|
+
const busy = props.isSending || submitting.value;
|
|
1352
2389
|
return slots.composer?.(slotProps) ?? (0, import_vue7.h)("form", {
|
|
1353
|
-
class:
|
|
2390
|
+
class: cx(
|
|
2391
|
+
partClass("composer", appearance(), "ckui-composer"),
|
|
2392
|
+
editing && !props.unstyled && "ckui-composer--editing",
|
|
2393
|
+
replying && !props.unstyled && "ckui-composer--replying"
|
|
2394
|
+
),
|
|
1354
2395
|
style: partStyle("composer", appearance()),
|
|
1355
2396
|
onSubmit: (event) => {
|
|
1356
2397
|
event.preventDefault();
|
|
1357
2398
|
void submit();
|
|
1358
2399
|
}
|
|
1359
2400
|
}, [
|
|
2401
|
+
// Spread, not null: outside edit mode the composer markup stays byte-identical to 0.7.
|
|
2402
|
+
...editing ? [(0, import_vue7.h)("div", { class: "ckui-composer__editing", role: "status" }, [
|
|
2403
|
+
(0, import_vue7.h)(import_vue6.Pencil, { size: 14, "aria-hidden": "true" }),
|
|
2404
|
+
(0, import_vue7.h)("span", { class: "ckui-composer__editing-body" }, [
|
|
2405
|
+
(0, import_vue7.h)("strong", "Editing message"),
|
|
2406
|
+
(0, import_vue7.h)("span", messageSummary(editing))
|
|
2407
|
+
]),
|
|
2408
|
+
(0, import_vue7.h)("button", { type: "button", class: "ckui-link-button", "aria-label": "Cancel editing", onClick: cancelEdit }, "Cancel")
|
|
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
|
+
])] : [],
|
|
1360
2418
|
props.onAddAttachment ? (0, import_vue7.h)("button", {
|
|
1361
2419
|
type: "button",
|
|
1362
2420
|
"aria-label": "Add attachment",
|
|
@@ -1385,15 +2443,22 @@ var ConversationView = (0, import_vue7.defineComponent)({
|
|
|
1385
2443
|
event.preventDefault();
|
|
1386
2444
|
void submit();
|
|
1387
2445
|
}
|
|
2446
|
+
if (event.key === "Escape" && props.editingMessage) {
|
|
2447
|
+
event.preventDefault();
|
|
2448
|
+
cancelEdit();
|
|
2449
|
+
} else if (event.key === "Escape" && props.replyTarget) {
|
|
2450
|
+
event.preventDefault();
|
|
2451
|
+
cancelReply();
|
|
2452
|
+
}
|
|
1388
2453
|
}
|
|
1389
2454
|
}),
|
|
1390
2455
|
(0, import_vue7.h)("button", {
|
|
1391
2456
|
type: "submit",
|
|
1392
|
-
"aria-label": "Send message",
|
|
1393
|
-
disabled: !draft().trim() ||
|
|
2457
|
+
"aria-label": editing ? "Save message" : "Send message",
|
|
2458
|
+
disabled: (editing ? !canSave(editing) : !draft().trim()) || busy,
|
|
1394
2459
|
class: partClass("button", appearance(), "ckui-send-button"),
|
|
1395
2460
|
style: partStyle("button", appearance())
|
|
1396
|
-
}, [
|
|
2461
|
+
}, [busy ? (0, import_vue7.h)(import_vue6.LoaderCircle, { class: "ckui-spin", size: 18, "aria-hidden": "true" }) : (0, import_vue7.h)(editing ? import_vue6.Check : import_vue6.Send, { size: 18, "aria-hidden": "true" })])
|
|
1397
2462
|
]);
|
|
1398
2463
|
};
|
|
1399
2464
|
return () => {
|
|
@@ -1426,6 +2491,7 @@ var ConversationView = (0, import_vue7.defineComponent)({
|
|
|
1426
2491
|
...slots["read-receipt"] ? { "read-receipt": slots["read-receipt"] } : {},
|
|
1427
2492
|
...slots.empty ? { empty: slots.empty } : {},
|
|
1428
2493
|
...slots["loading-older"] ? { "loading-older": slots["loading-older"] } : {},
|
|
2494
|
+
...slots["loading-newer"] ? { "loading-newer": slots["loading-newer"] } : {},
|
|
1429
2495
|
...slots["message-error"] ? { error: slots["message-error"] } : {}
|
|
1430
2496
|
};
|
|
1431
2497
|
children.push((0, import_vue7.h)(MessageListView, {
|
|
@@ -1442,6 +2508,25 @@ var ConversationView = (0, import_vue7.defineComponent)({
|
|
|
1442
2508
|
...props.onAttachmentClick ? { onAttachmentClick: (media, message) => {
|
|
1443
2509
|
props.onAttachmentClick?.(media, message);
|
|
1444
2510
|
} } : {},
|
|
2511
|
+
...props.onEditMessage ? { onEditMessage: (message) => {
|
|
2512
|
+
props.onEditMessage?.(message);
|
|
2513
|
+
} } : {},
|
|
2514
|
+
...props.onDeleteMessage ? { onDeleteMessage: (message) => props.onDeleteMessage?.(message) } : {},
|
|
2515
|
+
...props.canEditMessage ? { canEditMessage: props.canEditMessage } : {},
|
|
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 } : {},
|
|
1445
2530
|
reverse: props.reverseMessages,
|
|
1446
2531
|
stickToBottom: props.stickToBottom,
|
|
1447
2532
|
paginationThreshold: props.paginationThreshold,
|
|
@@ -1452,6 +2537,19 @@ var ConversationView = (0, import_vue7.defineComponent)({
|
|
|
1452
2537
|
density: props.density,
|
|
1453
2538
|
unstyled: props.unstyled
|
|
1454
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
|
+
}
|
|
1455
2553
|
children.push(renderTyping(), renderComposer());
|
|
1456
2554
|
return (0, import_vue7.h)("section", {
|
|
1457
2555
|
...attrs,
|
|
@@ -1472,6 +2570,15 @@ var Conversation = (0, import_vue7.defineComponent)({
|
|
|
1472
2570
|
messages: { type: Array, default: () => [] },
|
|
1473
2571
|
currentUserId: { type: String, default: "" },
|
|
1474
2572
|
onSendMessage: { type: Function, default: void 0 },
|
|
2573
|
+
editingMessage: { type: Object, default: void 0 },
|
|
2574
|
+
onEditMessage: { type: Function, default: void 0 },
|
|
2575
|
+
onSaveEdit: { type: Function, default: void 0 },
|
|
2576
|
+
onCancelEdit: { type: Function, default: void 0 },
|
|
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 },
|
|
1475
2582
|
client: { type: Object, required: true },
|
|
1476
2583
|
conversationId: { type: String, required: true },
|
|
1477
2584
|
messagePageSize: { type: Number, default: 30 },
|
|
@@ -1481,7 +2588,26 @@ var Conversation = (0, import_vue7.defineComponent)({
|
|
|
1481
2588
|
autoLoad: { type: Boolean, default: true },
|
|
1482
2589
|
onControllerChange: { type: Function, default: void 0 }
|
|
1483
2590
|
},
|
|
1484
|
-
emits: [
|
|
2591
|
+
emits: [
|
|
2592
|
+
"controller-change",
|
|
2593
|
+
"send-message",
|
|
2594
|
+
"typing-change",
|
|
2595
|
+
"back",
|
|
2596
|
+
"refresh",
|
|
2597
|
+
"load-older",
|
|
2598
|
+
"add-attachment",
|
|
2599
|
+
"attachment-click",
|
|
2600
|
+
"update:modelValue",
|
|
2601
|
+
"edit-message",
|
|
2602
|
+
"save-edit",
|
|
2603
|
+
"cancel-edit",
|
|
2604
|
+
"delete-message",
|
|
2605
|
+
"reply-to-message",
|
|
2606
|
+
"cancel-reply",
|
|
2607
|
+
"jump-to-message",
|
|
2608
|
+
"load-newer",
|
|
2609
|
+
"return-to-latest"
|
|
2610
|
+
],
|
|
1485
2611
|
setup(props, { attrs, emit, expose, slots }) {
|
|
1486
2612
|
const controller = useConversation({
|
|
1487
2613
|
client: () => props.client,
|
|
@@ -1545,6 +2671,22 @@ var Conversation = (0, import_vue7.defineComponent)({
|
|
|
1545
2671
|
isSending: _isSending,
|
|
1546
2672
|
hasOlderMessages: _hasOlderMessages,
|
|
1547
2673
|
error: _error,
|
|
2674
|
+
editingMessage: _editingMessage,
|
|
2675
|
+
onEditMessage: _onEditMessage,
|
|
2676
|
+
onSaveEdit: _onSaveEdit,
|
|
2677
|
+
onCancelEdit: _onCancelEdit,
|
|
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,
|
|
1548
2690
|
...forwarded
|
|
1549
2691
|
} = props;
|
|
1550
2692
|
return (0, import_vue7.h)(ConversationView, {
|
|
@@ -1568,6 +2710,61 @@ var Conversation = (0, import_vue7.defineComponent)({
|
|
|
1568
2710
|
isSending: controller.isSending.value,
|
|
1569
2711
|
hasOlderMessages: controller.hasOlderMessages.value,
|
|
1570
2712
|
...controller.error.value == null ? {} : { error: controller.error.value },
|
|
2713
|
+
// Edit mode is the store's; the actions render only while the adapter supports them (0.8.0).
|
|
2714
|
+
editingMessage: controller.editingMessage.value,
|
|
2715
|
+
...controller.canEditMessages.value ? {
|
|
2716
|
+
onEditMessage: (message) => {
|
|
2717
|
+
emit("edit-message", message);
|
|
2718
|
+
controller.startEditing(message.id);
|
|
2719
|
+
},
|
|
2720
|
+
onSaveEdit: async (message, text) => {
|
|
2721
|
+
emit("save-edit", message, text);
|
|
2722
|
+
return controller.saveEdit(text);
|
|
2723
|
+
},
|
|
2724
|
+
onCancelEdit: () => {
|
|
2725
|
+
emit("cancel-edit");
|
|
2726
|
+
controller.cancelEditing();
|
|
2727
|
+
}
|
|
2728
|
+
} : {},
|
|
2729
|
+
...controller.canDeleteMessages.value ? {
|
|
2730
|
+
onDeleteMessage: async (message) => {
|
|
2731
|
+
emit("delete-message", message);
|
|
2732
|
+
return controller.deleteMessage(message.id);
|
|
2733
|
+
}
|
|
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
|
+
} : {},
|
|
1571
2768
|
"onUpdate:modelValue": (value) => emit("update:modelValue", value),
|
|
1572
2769
|
...props.onBack ? { onBack: () => {
|
|
1573
2770
|
props.onBack?.();
|
|
@@ -2393,6 +3590,7 @@ var defaultConvoKitTheme = {
|
|
|
2393
3590
|
outgoingBubble: "#18181b",
|
|
2394
3591
|
outgoingText: "#fafafa",
|
|
2395
3592
|
badge: "#18181b",
|
|
3593
|
+
highlight: "color-mix(in srgb, #18181b 14%, transparent)",
|
|
2396
3594
|
radius: "10px",
|
|
2397
3595
|
avatarSize: "40px",
|
|
2398
3596
|
fontFamily: 'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'
|
|
@@ -2425,6 +3623,7 @@ var ConvoKitThemeProvider = (0, import_vue11.defineComponent)({
|
|
|
2425
3623
|
"--ckui-outgoing": theme.outgoingBubble,
|
|
2426
3624
|
"--ckui-outgoing-text": theme.outgoingText,
|
|
2427
3625
|
"--ckui-badge": theme.badge,
|
|
3626
|
+
"--ckui-highlight": theme.highlight,
|
|
2428
3627
|
"--ckui-radius": theme.radius,
|
|
2429
3628
|
"--ckui-avatar-size": theme.avatarSize,
|
|
2430
3629
|
"--ckui-font": theme.fontFamily
|