@convokitapp/vue-ui 0.6.0 → 0.8.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 +158 -0
- package/PARITY.md +60 -0
- package/README.md +185 -7
- package/dist/index.cjs +540 -41
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +103 -0
- package/dist/index.css.map +1 -1
- package/dist/index.d.cts +302 -14
- package/dist/index.d.ts +302 -14
- package/dist/index.js +547 -45
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
package/dist/index.cjs
CHANGED
|
@@ -72,7 +72,11 @@ 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),
|
|
75
77
|
markConversationRead: (conversationId, options) => client.markConversationRead(conversationId, options),
|
|
78
|
+
markConversationUnread: (conversationId) => client.markConversationUnread(conversationId),
|
|
79
|
+
clearConversationUnread: (conversationId, options) => client.clearConversationUnread(conversationId, options),
|
|
76
80
|
sendTyping: (input) => client.sendTyping(input),
|
|
77
81
|
onMessage: (conversationId, handler, onError) => client.realtime.onMessage(conversationId, {
|
|
78
82
|
onEvent: handler,
|
|
@@ -244,9 +248,31 @@ function version(message) {
|
|
|
244
248
|
function hasContent(message) {
|
|
245
249
|
return !!message.text?.trim() || message.media.length > 0;
|
|
246
250
|
}
|
|
251
|
+
function revisionOrder(left, right) {
|
|
252
|
+
const a = left.revision, b = right.revision;
|
|
253
|
+
if (typeof a !== "number" || typeof b !== "number" || a <= 0 && b <= 0) return void 0;
|
|
254
|
+
return a === b ? void 0 : a - b;
|
|
255
|
+
}
|
|
256
|
+
function older(candidate, reference) {
|
|
257
|
+
const byRevision = revisionOrder(candidate, reference);
|
|
258
|
+
return byRevision === void 0 ? version(candidate) < version(reference) : byRevision < 0;
|
|
259
|
+
}
|
|
247
260
|
function newest(current, incoming, incomingComplete = true) {
|
|
261
|
+
const byRevision = revisionOrder(current, incoming);
|
|
262
|
+
if (byRevision !== void 0) return byRevision > 0 ? current : incoming;
|
|
248
263
|
return version(current) > version(incoming) || !incomingComplete && version(current) === version(incoming) ? current : incoming;
|
|
249
264
|
}
|
|
265
|
+
function isRevisionConflict(cause) {
|
|
266
|
+
if (typeof cause !== "object" || cause === null) return false;
|
|
267
|
+
const { code, status } = cause;
|
|
268
|
+
return code === "REVISION_CONFLICT" || code === void 0 && status === 409;
|
|
269
|
+
}
|
|
270
|
+
function isMessageMissing(cause) {
|
|
271
|
+
return typeof cause === "object" && cause !== null && cause.code === "MESSAGE_NOT_FOUND";
|
|
272
|
+
}
|
|
273
|
+
function localConflict() {
|
|
274
|
+
return Object.assign(new Error("Message was changed since it was loaded"), { code: "REVISION_CONFLICT" });
|
|
275
|
+
}
|
|
250
276
|
var compare = compareMessageOrder;
|
|
251
277
|
function positionCursor(position) {
|
|
252
278
|
return { createdAt: position.createdAt, id: position.messageId };
|
|
@@ -257,12 +283,16 @@ function readEntry(participant) {
|
|
|
257
283
|
function acknowledgement() {
|
|
258
284
|
return { inFlight: void 0, followUp: false, suppressed: false, target: void 0, acknowledged: void 0, unacknowledgeable: /* @__PURE__ */ new Set() };
|
|
259
285
|
}
|
|
286
|
+
function capture(conversation) {
|
|
287
|
+
const membership = conversation?.membership;
|
|
288
|
+
return { version: membership?.privateStateVersion, clearPending: membership?.unreadMarkedAt != null };
|
|
289
|
+
}
|
|
260
290
|
function isTargetMiss(cause) {
|
|
261
291
|
if (typeof cause !== "object" || cause === null) return false;
|
|
262
292
|
const { code, status } = cause;
|
|
263
293
|
return code === "MESSAGE_NOT_FOUND" || code === void 0 && status === 404;
|
|
264
294
|
}
|
|
265
|
-
function blank(currentUserId = "") {
|
|
295
|
+
function blank(currentUserId = "", support = { edit: false, delete: false }) {
|
|
266
296
|
return {
|
|
267
297
|
conversation: null,
|
|
268
298
|
messages: [],
|
|
@@ -276,7 +306,10 @@ function blank(currentUserId = "") {
|
|
|
276
306
|
hasOlderMessages: true,
|
|
277
307
|
hasLoaded: false,
|
|
278
308
|
error: null,
|
|
279
|
-
currentUserId
|
|
309
|
+
currentUserId,
|
|
310
|
+
editingMessage: null,
|
|
311
|
+
canEditMessages: support.edit,
|
|
312
|
+
canDeleteMessages: support.delete
|
|
280
313
|
};
|
|
281
314
|
}
|
|
282
315
|
var ConversationStore = class {
|
|
@@ -295,7 +328,8 @@ var ConversationStore = class {
|
|
|
295
328
|
}
|
|
296
329
|
this.owner = this.client.sessionIdentity;
|
|
297
330
|
this.user = this.owner ? this.client.currentUserId : "";
|
|
298
|
-
this.
|
|
331
|
+
this.support = { edit: typeof this.client.editMessage === "function", delete: typeof this.client.deleteMessage === "function" };
|
|
332
|
+
this.state = blank(this.user, this.support);
|
|
299
333
|
}
|
|
300
334
|
options;
|
|
301
335
|
client;
|
|
@@ -317,10 +351,18 @@ var ConversationStore = class {
|
|
|
317
351
|
// Keep tombstones until an explicit reload/session change, including across refreshes.
|
|
318
352
|
deleted = /* @__PURE__ */ new Set();
|
|
319
353
|
ack = acknowledgement();
|
|
354
|
+
captured = capture();
|
|
320
355
|
// Visible until the platform reports otherwise; unknown/prerender/no document count as visible.
|
|
321
356
|
visible = true;
|
|
322
357
|
sendRevision;
|
|
323
358
|
activeSend;
|
|
359
|
+
/** Adapter support for author edits/deletes, decided once like `listInbox`. */
|
|
360
|
+
support;
|
|
361
|
+
/** The id whose `saveEdit` request is in flight: its outcome (success or 409) decides the edit, so newer rows for
|
|
362
|
+
* it arriving meanwhile (its own UPDATE image, typically) are not reported as a local conflict while it lasts;
|
|
363
|
+
* `settleEditing` re-evaluates them once the request has settled any other way.
|
|
364
|
+
*/
|
|
365
|
+
activeEdit;
|
|
324
366
|
refreshQueued = false;
|
|
325
367
|
typingTimers = /* @__PURE__ */ new Map();
|
|
326
368
|
ownTypingTimer;
|
|
@@ -339,9 +381,33 @@ var ConversationStore = class {
|
|
|
339
381
|
for (const message of patch.messages) this.confirmSend(message);
|
|
340
382
|
if (this.activeSend.confirmed) patch.messages = patch.messages.filter((message) => message.id !== this.activeSend.pending.id);
|
|
341
383
|
}
|
|
384
|
+
if (patch.messages) patch = this.trackEditing(patch);
|
|
342
385
|
this.state = { ...this.state, ...patch };
|
|
343
386
|
for (const listener of this.listeners) listener();
|
|
344
387
|
}
|
|
388
|
+
/** Edit mode follows the edited row wherever a message list reaches the state: the row leaving the list (deletion,
|
|
389
|
+
* reconcile tombstone, eviction) ends it, and a row for it with a higher revision than the snapshot (UPDATE image,
|
|
390
|
+
* hydration, reconcile, refresh) is the local conflict: the snapshot is replaced and `error` carries the conflict
|
|
391
|
+
* code, without a request. A save in flight owns its own outcome (`activeEdit`) and re-checks when it settles
|
|
392
|
+
* (`settleEditing`).
|
|
393
|
+
*/
|
|
394
|
+
trackEditing(patch) {
|
|
395
|
+
const editing = patch.editingMessage === void 0 ? this.state.editingMessage : patch.editingMessage;
|
|
396
|
+
if (!editing || !patch.messages) return patch;
|
|
397
|
+
const live = patch.messages.find((message) => message.id === editing.id);
|
|
398
|
+
if (!live) return { ...patch, editingMessage: null };
|
|
399
|
+
if (this.activeEdit === editing.id || !(live.revision > editing.revision)) return patch;
|
|
400
|
+
return { ...patch, editingMessage: live, error: localConflict() };
|
|
401
|
+
}
|
|
402
|
+
/** After a save for `id` has settled without deciding the edit (a failure, or a 409 whose reload failed), a newer
|
|
403
|
+
* row for it that arrived during the request is the local conflict after all: the snapshot is replaced and
|
|
404
|
+
* `error` carries the conflict code, so the next save carries the fresh revision without another round trip.
|
|
405
|
+
*/
|
|
406
|
+
settleEditing(id) {
|
|
407
|
+
const editing = this.state.editingMessage;
|
|
408
|
+
const live = editing?.id === id ? this.state.messages.find((message) => message.id === id) : void 0;
|
|
409
|
+
if (editing && live && live.revision > editing.revision) this.patch({ editingMessage: live, error: localConflict() });
|
|
410
|
+
}
|
|
345
411
|
alive(generation = this.generation) {
|
|
346
412
|
return !this.disposed && generation === this.generation && this.owner !== null && this.client.sessionIdentity === this.owner;
|
|
347
413
|
}
|
|
@@ -382,8 +448,10 @@ var ConversationStore = class {
|
|
|
382
448
|
this.hydrationPool.queued.clear();
|
|
383
449
|
this.deleted.clear();
|
|
384
450
|
this.ack = acknowledgement();
|
|
451
|
+
this.captured = capture();
|
|
385
452
|
this.sendRevision = void 0;
|
|
386
453
|
this.activeSend = void 0;
|
|
454
|
+
this.activeEdit = void 0;
|
|
387
455
|
this.refreshQueued = false;
|
|
388
456
|
}
|
|
389
457
|
dispose = () => {
|
|
@@ -392,14 +460,14 @@ var ConversationStore = class {
|
|
|
392
460
|
}
|
|
393
461
|
this.disposed = true;
|
|
394
462
|
this.clear();
|
|
395
|
-
this.patch(blank());
|
|
463
|
+
this.patch(blank("", this.support));
|
|
396
464
|
};
|
|
397
465
|
fail(cause, generation, history = false) {
|
|
398
466
|
if (!this.alive(generation)) return;
|
|
399
467
|
const status = typeof cause === "object" && cause !== null && "status" in cause ? cause.status : void 0;
|
|
400
468
|
if (history && (status === 401 || status === 403 || status === 404)) {
|
|
401
469
|
this.clear();
|
|
402
|
-
this.patch({ ...blank(this.user), error: cause, hasLoaded: true, hasOlderMessages: false });
|
|
470
|
+
this.patch({ ...blank(this.user, this.support), error: cause, hasLoaded: true, hasOlderMessages: false });
|
|
403
471
|
} else this.patch({ error: cause });
|
|
404
472
|
}
|
|
405
473
|
attach(generation, data = true) {
|
|
@@ -471,7 +539,7 @@ var ConversationStore = class {
|
|
|
471
539
|
if (!this.alive(generation) || type !== "insert" && type !== "update" || !this.validMessage(message) || this.deleted.has(message.id)) return;
|
|
472
540
|
const existing = this.state.messages.find((item) => item.id === message.id);
|
|
473
541
|
const known = existing ?? this.changes.get(message.id)?.message;
|
|
474
|
-
if (known &&
|
|
542
|
+
if (known && older(message, known)) return;
|
|
475
543
|
const insert = type === "insert" || this.changes.get(message.id)?.insert === true;
|
|
476
544
|
if (!existing && !insert && type === "update" && !this.state.isInitialLoading && !this.state.isLoadingOlder && !this.state.isReconciling) return;
|
|
477
545
|
const revision = ++this.revision;
|
|
@@ -484,7 +552,7 @@ var ConversationStore = class {
|
|
|
484
552
|
}
|
|
485
553
|
record(message, insert, revision, complete) {
|
|
486
554
|
const existing = this.state.messages.find((item) => item.id === message.id);
|
|
487
|
-
if (existing &&
|
|
555
|
+
if (existing && older(message, existing)) return;
|
|
488
556
|
if (this.confirmSend(message) && !complete && !message.media.length) {
|
|
489
557
|
message = { ...message, media: this.activeSend.pending.media };
|
|
490
558
|
this.confirmSend(message);
|
|
@@ -535,7 +603,7 @@ var ConversationStore = class {
|
|
|
535
603
|
if (!this.currentHydration(job)) return;
|
|
536
604
|
const full = await this.client.getMessage(id);
|
|
537
605
|
if (!this.currentHydration(job)) return;
|
|
538
|
-
if (!this.validMessage(full) || full.id !== id || full.senderId !== job.message.senderId ||
|
|
606
|
+
if (!this.validMessage(full) || full.id !== id || full.senderId !== job.message.senderId || older(full, job.message)) {
|
|
539
607
|
throw new Error("Complete message response does not match the observed resource/revision");
|
|
540
608
|
}
|
|
541
609
|
this.record(full, job.insert, job.revision, true);
|
|
@@ -615,7 +683,7 @@ var ConversationStore = class {
|
|
|
615
683
|
if (!this.alive()) return;
|
|
616
684
|
this.clear();
|
|
617
685
|
const generation = this.generation;
|
|
618
|
-
this.patch({ ...blank(this.user), isInitialLoading: true });
|
|
686
|
+
this.patch({ ...blank(this.user, this.support), isInitialLoading: true });
|
|
619
687
|
const revision = this.revision;
|
|
620
688
|
try {
|
|
621
689
|
this.attach(generation);
|
|
@@ -626,9 +694,13 @@ var ConversationStore = class {
|
|
|
626
694
|
this.validatePage(page);
|
|
627
695
|
this.cursor = page.at(-1);
|
|
628
696
|
this.patch({ conversation, messages: this.overlay(page, revision), hasOlderMessages: page.length === this.pageSize });
|
|
697
|
+
this.captured = capture(conversation);
|
|
629
698
|
this.mergeReads(conversation.participants.map(readEntry));
|
|
630
699
|
this.prune(revision);
|
|
631
|
-
if (this.options.markReadOnLoad ?? true)
|
|
700
|
+
if ((this.options.markReadOnLoad ?? true) || this.ack.suppressed) {
|
|
701
|
+
this.ack.suppressed = false;
|
|
702
|
+
await this.acknowledge(true);
|
|
703
|
+
}
|
|
632
704
|
} catch (cause) {
|
|
633
705
|
this.fail(cause, generation, true);
|
|
634
706
|
} finally {
|
|
@@ -685,9 +757,12 @@ var ConversationStore = class {
|
|
|
685
757
|
const survivingIds = new Set(reconciled.map((message) => message.id));
|
|
686
758
|
const known = this.state.messages.filter((message) => !isConvoKitPendingMessage(message)).map((message) => message.id).concat([...this.changes].filter(([, change]) => change.insert && change.revision <= revision).map(([id]) => id));
|
|
687
759
|
for (const id of known) if (!survivingIds.has(id)) this.forget(id);
|
|
760
|
+
const opening = this.state.conversation === null;
|
|
688
761
|
this.patch({ conversation, messages: reconciled, hasOlderMessages: hasOlder });
|
|
762
|
+
if (opening) this.captured = capture(conversation);
|
|
689
763
|
this.mergeReads(conversation.participants.map(readEntry));
|
|
690
764
|
this.prune(revision);
|
|
765
|
+
if (opening) void this.resumeAcknowledgement();
|
|
691
766
|
} catch (cause) {
|
|
692
767
|
this.fail(cause, generation, true);
|
|
693
768
|
} finally {
|
|
@@ -721,15 +796,22 @@ var ConversationStore = class {
|
|
|
721
796
|
}
|
|
722
797
|
}
|
|
723
798
|
};
|
|
724
|
-
/** Acknowledge through the newest rendered row now, regardless of visibility; no
|
|
799
|
+
/** Acknowledge through the newest rendered row now, regardless of visibility; no acknowledgement without a
|
|
800
|
+
* target (a room opened with a marker that renders nothing clears the marker instead, once).
|
|
801
|
+
*/
|
|
725
802
|
markRead = () => this.alive() ? this.acknowledge(false) : Promise.resolve();
|
|
726
803
|
/** Automatic acknowledgements wait while hidden and are re-issued (once) on becoming visible. */
|
|
727
804
|
setVisible = (visible) => {
|
|
728
805
|
this.visible = visible;
|
|
729
|
-
if (!visible || !this.
|
|
730
|
-
this.
|
|
731
|
-
void this.acknowledge(true);
|
|
806
|
+
if (!visible || !this.alive()) return;
|
|
807
|
+
void this.resumeAcknowledgement();
|
|
732
808
|
};
|
|
809
|
+
/** Re-issue (once) the automatic acknowledgement that waited while hidden or before this open's DTO, if any. */
|
|
810
|
+
resumeAcknowledgement() {
|
|
811
|
+
if (!this.ack.suppressed) return Promise.resolve();
|
|
812
|
+
this.ack.suppressed = false;
|
|
813
|
+
return this.acknowledge(true);
|
|
814
|
+
}
|
|
733
815
|
/** The newest non-pending rendered row by (createdAt, id), never by list index and never a raw realtime
|
|
734
816
|
* row; rows the server does not know are skipped, and nothing at or before the accepted target is re-sent.
|
|
735
817
|
*/
|
|
@@ -744,7 +826,7 @@ var ConversationStore = class {
|
|
|
744
826
|
/** Resolves when the request this call issued or joined settles; a follow-up is issued, not awaited. */
|
|
745
827
|
acknowledge(automatic) {
|
|
746
828
|
const ack = this.ack;
|
|
747
|
-
if (automatic && !this.visible) {
|
|
829
|
+
if (automatic && (!this.visible || this.state.conversation === null)) {
|
|
748
830
|
ack.suppressed = true;
|
|
749
831
|
return Promise.resolve();
|
|
750
832
|
}
|
|
@@ -757,9 +839,10 @@ var ConversationStore = class {
|
|
|
757
839
|
issue(ack, generation) {
|
|
758
840
|
ack.followUp = false;
|
|
759
841
|
const target = this.ackTarget(ack);
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
ack.
|
|
842
|
+
const request = target ? this.send(ack, generation, target) : this.clearMarker(ack, generation);
|
|
843
|
+
if (!request) return void 0;
|
|
844
|
+
ack.target = target?.id;
|
|
845
|
+
ack.inFlight = request.finally(() => {
|
|
763
846
|
ack.inFlight = void 0;
|
|
764
847
|
ack.target = void 0;
|
|
765
848
|
if (!this.alive(generation) || !ack.followUp) return;
|
|
@@ -770,9 +853,31 @@ var ConversationStore = class {
|
|
|
770
853
|
});
|
|
771
854
|
return ack.inFlight;
|
|
772
855
|
}
|
|
856
|
+
/** A room opened with the caller's unread marker that renders no non-pending, acknowledgeable row cannot clear
|
|
857
|
+
* it through a targeted acknowledgement, so it asks the adapter to clear the marker conditionally on the captured
|
|
858
|
+
* version, once per open, under the acknowledgement triggers and visibility gating. Rows rendered later clear it
|
|
859
|
+
* through their acknowledgements; adapters without the member leave it; `cleared: false` is not an error.
|
|
860
|
+
*/
|
|
861
|
+
clearMarker(ack, generation) {
|
|
862
|
+
const captured = this.captured;
|
|
863
|
+
if (!captured.clearPending || captured.version === void 0 || typeof this.client.clearConversationUnread !== "function" || this.state.messages.some((message) => !isConvoKitPendingMessage(message) && !ack.unacknowledgeable.has(message.id))) return void 0;
|
|
864
|
+
captured.clearPending = false;
|
|
865
|
+
return this.clearUnread(captured.version, generation);
|
|
866
|
+
}
|
|
867
|
+
async clearUnread(version2, generation) {
|
|
868
|
+
try {
|
|
869
|
+
await this.client.clearConversationUnread(this.room, { ifVersion: version2 });
|
|
870
|
+
} catch (cause) {
|
|
871
|
+
if (this.alive(generation)) this.fail(cause, generation);
|
|
872
|
+
}
|
|
873
|
+
}
|
|
773
874
|
async send(ack, generation, target) {
|
|
774
875
|
try {
|
|
775
|
-
|
|
876
|
+
const version2 = this.captured.version;
|
|
877
|
+
await this.client.markConversationRead(this.room, {
|
|
878
|
+
throughMessageId: target.id,
|
|
879
|
+
...version2 === void 0 ? {} : { privateStateVersion: version2 }
|
|
880
|
+
});
|
|
776
881
|
if (!this.alive(generation)) return;
|
|
777
882
|
if (!ack.acknowledged || compare(target, ack.acknowledged) > 0) ack.acknowledged = target;
|
|
778
883
|
} catch (cause) {
|
|
@@ -821,7 +926,8 @@ var ConversationStore = class {
|
|
|
821
926
|
text: normalized || null,
|
|
822
927
|
media: media ?? [],
|
|
823
928
|
createdAt: /* @__PURE__ */ new Date(),
|
|
824
|
-
updatedAt: null
|
|
929
|
+
updatedAt: null,
|
|
930
|
+
revision: 0
|
|
825
931
|
};
|
|
826
932
|
const send = { pending };
|
|
827
933
|
this.activeSend = send;
|
|
@@ -865,6 +971,132 @@ var ConversationStore = class {
|
|
|
865
971
|
}
|
|
866
972
|
}
|
|
867
973
|
};
|
|
974
|
+
/** The caller's role in the open room when known (0.7 `membership`, else the caller's participant row). */
|
|
975
|
+
ownRole() {
|
|
976
|
+
const conversation = this.state.conversation;
|
|
977
|
+
return conversation?.membership?.role ?? conversation?.participants.find((participant) => participant.appUserId === this.user || participant.id === this.user)?.role;
|
|
978
|
+
}
|
|
979
|
+
/** A rendered, confirmed row of the caller's own that is not known to be gone. */
|
|
980
|
+
ownRow(messageId) {
|
|
981
|
+
const row = this.state.messages.find((message) => message.id === messageId);
|
|
982
|
+
return row && row.senderId === this.user && !isConvoKitPendingMessage(row) && !this.deleted.has(messageId) ? row : void 0;
|
|
983
|
+
}
|
|
984
|
+
/** Enter edit mode on one of the caller's own confirmed messages (0.8.0): the row as it stands now becomes the
|
|
985
|
+
* snapshot whose `revision` every save sends. A no-op unless the adapter implements `editMessage`, the row is
|
|
986
|
+
* rendered, own, confirmed, not tombstoned and the caller's role (when known) is not `READ`. Sends nothing.
|
|
987
|
+
*/
|
|
988
|
+
startEditing = (messageId) => {
|
|
989
|
+
if (!this.alive() || !this.support.edit || this.ownRole() === "READ") return;
|
|
990
|
+
const row = this.ownRow(messageId);
|
|
991
|
+
if (row) this.patch({ editingMessage: row });
|
|
992
|
+
};
|
|
993
|
+
/** Leave edit mode without a request; the draft is the view's to restore. */
|
|
994
|
+
cancelEditing = () => {
|
|
995
|
+
if (this.state.editingMessage) this.patch({ editingMessage: null });
|
|
996
|
+
};
|
|
997
|
+
/** Save the edit in progress with the snapshot's revision (never the live row's), trimming the text and sending
|
|
998
|
+
* `null` for an empty caption. Resolves true when the server accepted the edit (the response is merged through the
|
|
999
|
+
* tombstone and precedence guards and edit mode ends); false when nothing was saved: a stale revision (409
|
|
1000
|
+
* `REVISION_CONFLICT`) reloads the row once through `getMessage`, replaces the snapshot with it (the next save
|
|
1001
|
+
* carries the fresh revision) and reports the conflict through `error`, keeping edit mode; a coded 404
|
|
1002
|
+
* (`MESSAGE_NOT_FOUND`, on the save or on that reload) removes the row and ends edit mode; any other failure
|
|
1003
|
+
* (403, 500, network, an uncoded 404 from a 0.7 backend) is reported through `error` without evicting anything and
|
|
1004
|
+
* keeps edit mode; if a newer row for the message arrived during such a request, that row is then the local
|
|
1005
|
+
* conflict (`settleEditing`). A text-only message cannot be saved empty (no request). Rejects when the adapter
|
|
1006
|
+
* lacks `editMessage`.
|
|
1007
|
+
*/
|
|
1008
|
+
saveEdit = async (text) => {
|
|
1009
|
+
const client = this.client;
|
|
1010
|
+
if (typeof client.editMessage !== "function") {
|
|
1011
|
+
throw new TypeError("This ConvoKitUiClient adapter does not implement editMessage (0.8)");
|
|
1012
|
+
}
|
|
1013
|
+
const snapshot = this.state.editingMessage;
|
|
1014
|
+
if (!this.alive() || !snapshot || this.activeEdit !== void 0) return false;
|
|
1015
|
+
const trimmed = text.trim();
|
|
1016
|
+
const normalized = trimmed === "" ? null : trimmed;
|
|
1017
|
+
if (normalized === null && snapshot.media.length === 0) return false;
|
|
1018
|
+
const generation = this.generation;
|
|
1019
|
+
const id = snapshot.id;
|
|
1020
|
+
this.activeEdit = id;
|
|
1021
|
+
this.patch({ error: null });
|
|
1022
|
+
try {
|
|
1023
|
+
const message = await client.editMessage(id, { text: normalized, revision: snapshot.revision });
|
|
1024
|
+
if (!this.alive(generation)) return false;
|
|
1025
|
+
if (!this.validMessage(message) || message.id !== id || message.senderId !== this.user) {
|
|
1026
|
+
throw new Error("Edit response belongs to a different message or sender");
|
|
1027
|
+
}
|
|
1028
|
+
if (this.state.editingMessage?.id === id) this.patch({ editingMessage: null });
|
|
1029
|
+
this.applyRow(message);
|
|
1030
|
+
return true;
|
|
1031
|
+
} catch (cause) {
|
|
1032
|
+
if (!this.alive(generation)) return false;
|
|
1033
|
+
if (isRevisionConflict(cause)) await this.reloadConflict(id, cause, generation);
|
|
1034
|
+
else if (isMessageMissing(cause)) {
|
|
1035
|
+
this.removeMessage(id);
|
|
1036
|
+
this.patch({ error: cause });
|
|
1037
|
+
} else this.fail(cause, generation);
|
|
1038
|
+
return false;
|
|
1039
|
+
} finally {
|
|
1040
|
+
if (this.alive(generation)) {
|
|
1041
|
+
this.activeEdit = void 0;
|
|
1042
|
+
this.settleEditing(id);
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
};
|
|
1046
|
+
/** Merge a complete REST row for a known id through the live-row guards: a tombstoned id is dropped, and an older
|
|
1047
|
+
* revision (or timestamp) never overwrites the newer row already recorded. Recorded as a non-insert change, so a
|
|
1048
|
+
* reconcile keeps it only while the row is still in the fetched range.
|
|
1049
|
+
*/
|
|
1050
|
+
applyRow(message) {
|
|
1051
|
+
if (this.deleted.has(message.id)) return;
|
|
1052
|
+
this.record(message, this.changes.get(message.id)?.insert === true, ++this.revision, true);
|
|
1053
|
+
}
|
|
1054
|
+
/** The 409 path: one `getMessage` shows the conflicting content. Its row is merged through the guards and becomes
|
|
1055
|
+
* the new snapshot; a `MESSAGE_NOT_FOUND` answer removes the row and ends edit mode; another failure keeps the
|
|
1056
|
+
* snapshot. `error` carries the conflict (or the reload failure).
|
|
1057
|
+
*/
|
|
1058
|
+
async reloadConflict(id, conflict, generation) {
|
|
1059
|
+
try {
|
|
1060
|
+
const current = await this.client.getMessage(id);
|
|
1061
|
+
if (!this.alive(generation)) return;
|
|
1062
|
+
if (!this.validMessage(current) || current.id !== id) throw new Error("Complete message response does not match the edited message");
|
|
1063
|
+
this.applyRow(current);
|
|
1064
|
+
const row = this.state.messages.find((message) => message.id === id);
|
|
1065
|
+
const editing = this.state.editingMessage?.id === id && row ? { editingMessage: row } : {};
|
|
1066
|
+
this.patch({ ...editing, error: conflict });
|
|
1067
|
+
} catch (cause) {
|
|
1068
|
+
if (!this.alive(generation)) return;
|
|
1069
|
+
if (isTargetMiss(cause)) this.removeMessage(id);
|
|
1070
|
+
this.patch({ error: cause });
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
/** Delete one of the caller's own confirmed messages (0.8.0). The row stays until the server answers: on success,
|
|
1074
|
+
* or when the server no longer knows it (`MESSAGE_NOT_FOUND`), it is tombstoned and removed (late responses, row
|
|
1075
|
+
* images and hydrations for it are dropped, the acknowledgement target is re-resolved and edit mode on it ends)
|
|
1076
|
+
* and the call resolves true; any other failure keeps the row, reports through `error` and resolves false. Rejects
|
|
1077
|
+
* when the adapter lacks `deleteMessage`.
|
|
1078
|
+
*/
|
|
1079
|
+
deleteMessage = async (messageId) => {
|
|
1080
|
+
const client = this.client;
|
|
1081
|
+
if (typeof client.deleteMessage !== "function") {
|
|
1082
|
+
throw new TypeError("This ConvoKitUiClient adapter does not implement deleteMessage (0.8)");
|
|
1083
|
+
}
|
|
1084
|
+
if (!this.alive() || !this.ownRow(messageId)) return false;
|
|
1085
|
+
const generation = this.generation;
|
|
1086
|
+
this.patch({ error: null });
|
|
1087
|
+
try {
|
|
1088
|
+
await client.deleteMessage(messageId);
|
|
1089
|
+
} catch (cause) {
|
|
1090
|
+
if (!this.alive(generation)) return false;
|
|
1091
|
+
if (!isMessageMissing(cause)) {
|
|
1092
|
+
this.fail(cause, generation);
|
|
1093
|
+
return false;
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
if (!this.alive(generation)) return false;
|
|
1097
|
+
this.removeMessage(messageId);
|
|
1098
|
+
return true;
|
|
1099
|
+
};
|
|
868
1100
|
readerIdsFor = (message) => readerIdsFor(message, this.state.readAtByUserId, this.state.readPositionByUserId);
|
|
869
1101
|
};
|
|
870
1102
|
|
|
@@ -918,11 +1150,18 @@ function useConversation(options) {
|
|
|
918
1150
|
hasLoaded: field("hasLoaded"),
|
|
919
1151
|
error: field("error"),
|
|
920
1152
|
currentUserId: field("currentUserId"),
|
|
1153
|
+
editingMessage: field("editingMessage"),
|
|
1154
|
+
canEditMessages: field("canEditMessages"),
|
|
1155
|
+
canDeleteMessages: field("canDeleteMessages"),
|
|
921
1156
|
readerIdsFor: (message) => store.readerIdsFor(message),
|
|
922
1157
|
loadInitial: () => store.loadInitial(),
|
|
923
1158
|
refresh: () => store.refresh(),
|
|
924
1159
|
loadOlderMessages: () => store.loadOlderMessages(),
|
|
925
1160
|
sendMessage: (input) => store.sendMessage(input),
|
|
1161
|
+
startEditing: (messageId) => store.startEditing(messageId),
|
|
1162
|
+
cancelEditing: () => store.cancelEditing(),
|
|
1163
|
+
saveEdit: (text) => store.saveEdit(text),
|
|
1164
|
+
deleteMessage: (messageId) => store.deleteMessage(messageId),
|
|
926
1165
|
markRead: () => store.markRead(),
|
|
927
1166
|
updateTyping: (isTyping) => store.updateTyping(isTyping),
|
|
928
1167
|
setVisible: (value) => {
|
|
@@ -934,6 +1173,7 @@ function useConversation(options) {
|
|
|
934
1173
|
}
|
|
935
1174
|
|
|
936
1175
|
// src/components/message-list.ts
|
|
1176
|
+
var import_sdk3 = require("@convokitapp/sdk");
|
|
937
1177
|
var import_vue4 = require("@lucide/vue");
|
|
938
1178
|
var import_vue5 = require("vue");
|
|
939
1179
|
var appearanceProps = {
|
|
@@ -988,6 +1228,10 @@ var MessageListView = (0, import_vue5.defineComponent)({
|
|
|
988
1228
|
isLoadingOlder: { type: Boolean, default: false },
|
|
989
1229
|
error: { type: null, required: false },
|
|
990
1230
|
onAttachmentClick: { type: Function, default: void 0 },
|
|
1231
|
+
onEditMessage: { type: Function, default: void 0 },
|
|
1232
|
+
onDeleteMessage: { type: Function, default: void 0 },
|
|
1233
|
+
canEditMessage: { type: Function, default: void 0 },
|
|
1234
|
+
confirmDelete: { type: Function, default: void 0 },
|
|
991
1235
|
scrollElement: { type: Object, default: void 0 },
|
|
992
1236
|
paginationThreshold: { type: Number, default: 240 },
|
|
993
1237
|
reverse: { type: Boolean, default: true },
|
|
@@ -995,9 +1239,10 @@ var MessageListView = (0, import_vue5.defineComponent)({
|
|
|
995
1239
|
formatTime: { type: Function, default: formatMessageTime },
|
|
996
1240
|
imageLoading: { type: String, default: "lazy" }
|
|
997
1241
|
},
|
|
998
|
-
emits: ["load-older", "attachment-click"],
|
|
1242
|
+
emits: ["load-older", "attachment-click", "edit-message", "delete-message"],
|
|
999
1243
|
setup(props, { attrs, emit, slots }) {
|
|
1000
1244
|
const internalElement = (0, import_vue5.ref)(null);
|
|
1245
|
+
const confirming = (0, import_vue5.ref)(null);
|
|
1001
1246
|
let requestInFlight = false;
|
|
1002
1247
|
let lastRequestedLength = null;
|
|
1003
1248
|
let previousMessageCount = 0;
|
|
@@ -1037,16 +1282,77 @@ var MessageListView = (0, import_vue5.defineComponent)({
|
|
|
1037
1282
|
}
|
|
1038
1283
|
}
|
|
1039
1284
|
}, { flush: "post", immediate: true });
|
|
1285
|
+
const viewerRole = (0, import_vue5.computed)(() => props.conversation.membership?.role ?? props.conversation.participants.find((participant) => participant.appUserId === props.currentUserId || participant.id === props.currentUserId)?.role);
|
|
1286
|
+
const remove = async (message) => {
|
|
1287
|
+
if (props.confirmDelete && !await props.confirmDelete(message)) return false;
|
|
1288
|
+
return await props.onDeleteMessage?.(message) !== false;
|
|
1289
|
+
};
|
|
1040
1290
|
const renderMessage = (message, index) => {
|
|
1041
1291
|
const isCurrentUser = message.senderId === props.currentUserId;
|
|
1042
1292
|
const sender = participants.value.get(message.senderId);
|
|
1043
1293
|
const isPending = isConvoKitPendingMessage(message);
|
|
1044
1294
|
const readerIds = isPending ? /* @__PURE__ */ new Set() : props.readersResolver ? props.readersResolver(message) : readerIdsFor(message, props.readAtByUserId, props.readPositionByUserId);
|
|
1045
|
-
const
|
|
1295
|
+
const isEdited = !isPending && (0, import_sdk3.isEditedMessage)(message);
|
|
1296
|
+
const eligible = !isPending && (props.canEditMessage ? props.canEditMessage(message) : isCurrentUser && viewerRole.value !== "READ");
|
|
1297
|
+
const canEdit = eligible && !!props.onEditMessage;
|
|
1298
|
+
const canDelete = eligible && !!props.onDeleteMessage;
|
|
1299
|
+
const edit = () => {
|
|
1300
|
+
props.onEditMessage?.(message);
|
|
1301
|
+
};
|
|
1302
|
+
const slotProps = {
|
|
1303
|
+
message,
|
|
1304
|
+
chronologicalIndex: index,
|
|
1305
|
+
isCurrentUser,
|
|
1306
|
+
sender,
|
|
1307
|
+
readerIds,
|
|
1308
|
+
isEdited,
|
|
1309
|
+
canEdit,
|
|
1310
|
+
canDelete,
|
|
1311
|
+
...canEdit ? { edit } : {},
|
|
1312
|
+
...canDelete ? { remove: () => remove(message) } : {}
|
|
1313
|
+
};
|
|
1046
1314
|
const custom = slots.message?.(slotProps);
|
|
1047
1315
|
if (custom) return (0, import_vue5.h)("div", { key: message.id, role: "listitem" }, custom);
|
|
1048
1316
|
const currentAppearance = appearance();
|
|
1049
1317
|
const messagePart = isCurrentUser ? "outgoingMessage" : "incomingMessage";
|
|
1318
|
+
const iconButton = (label, onClick, icon) => (0, import_vue5.h)("button", {
|
|
1319
|
+
type: "button",
|
|
1320
|
+
"aria-label": label,
|
|
1321
|
+
onClick,
|
|
1322
|
+
class: partClass("button", currentAppearance, "ckui-icon-button"),
|
|
1323
|
+
style: partStyle("button", currentAppearance)
|
|
1324
|
+
}, [icon]);
|
|
1325
|
+
const actions = canEdit || canDelete ? (0, import_vue5.h)("div", { class: "ckui-message-actions" }, [
|
|
1326
|
+
...canEdit ? [iconButton("Edit message", edit, (0, import_vue5.h)(import_vue4.Pencil, { size: 16, "aria-hidden": "true" }))] : [],
|
|
1327
|
+
...canDelete ? [iconButton("Delete message", () => {
|
|
1328
|
+
if (props.confirmDelete) void remove(message);
|
|
1329
|
+
else confirming.value = message.id;
|
|
1330
|
+
}, (0, import_vue5.h)(import_vue4.Trash2, { size: 16, "aria-hidden": "true" }))] : []
|
|
1331
|
+
]) : null;
|
|
1332
|
+
const confirm = canDelete && confirming.value === message.id ? (0, import_vue5.h)("div", {
|
|
1333
|
+
class: "ckui-message-confirm",
|
|
1334
|
+
role: "group",
|
|
1335
|
+
"aria-label": "Delete this message?"
|
|
1336
|
+
}, [
|
|
1337
|
+
(0, import_vue5.h)("span", "Delete this message?"),
|
|
1338
|
+
(0, import_vue5.h)("button", {
|
|
1339
|
+
type: "button",
|
|
1340
|
+
class: "ckui-link-button",
|
|
1341
|
+
"aria-label": "Confirm delete",
|
|
1342
|
+
onClick: () => {
|
|
1343
|
+
confirming.value = null;
|
|
1344
|
+
void props.onDeleteMessage?.(message);
|
|
1345
|
+
}
|
|
1346
|
+
}, "Delete"),
|
|
1347
|
+
(0, import_vue5.h)("button", {
|
|
1348
|
+
type: "button",
|
|
1349
|
+
class: "ckui-link-button",
|
|
1350
|
+
"aria-label": "Cancel delete",
|
|
1351
|
+
onClick: () => {
|
|
1352
|
+
confirming.value = null;
|
|
1353
|
+
}
|
|
1354
|
+
}, "Cancel")
|
|
1355
|
+
]) : null;
|
|
1050
1356
|
const mediaNodes = message.media.map((media, mediaIndex) => {
|
|
1051
1357
|
const open = props.onAttachmentClick ? () => {
|
|
1052
1358
|
props.onAttachmentClick?.(media, message);
|
|
@@ -1070,15 +1376,19 @@ var MessageListView = (0, import_vue5.defineComponent)({
|
|
|
1070
1376
|
style: [props.styles?.message, props.styles?.[messagePart]],
|
|
1071
1377
|
"data-message-id": message.id
|
|
1072
1378
|
}, [
|
|
1379
|
+
// Spread, not null: an absent action/label/prompt must not leave a comment node (0.7 markup stays byte-identical).
|
|
1380
|
+
...actions ? [actions] : [],
|
|
1073
1381
|
(0, import_vue5.h)("div", { class: "ckui-message-bubble" }, [
|
|
1074
1382
|
!isCurrentUser ? (0, import_vue5.h)("strong", { class: "ckui-message-sender" }, sender?.name || message.senderId) : null,
|
|
1075
1383
|
message.text ? (0, import_vue5.h)("div", { class: "ckui-message-text" }, message.text) : null,
|
|
1076
1384
|
...mediaNodes,
|
|
1077
1385
|
(0, import_vue5.h)("span", { class: "ckui-message-time" }, [
|
|
1078
1386
|
isPending ? "Sending\u2026" : props.formatTime(message.createdAt),
|
|
1387
|
+
...isEdited ? [(0, import_vue5.h)("span", { class: "ckui-message-edited", "aria-label": "Edited" }, "Edited")] : [],
|
|
1079
1388
|
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
|
|
1080
1389
|
])
|
|
1081
1390
|
]),
|
|
1391
|
+
...confirm ? [confirm] : [],
|
|
1082
1392
|
isCurrentUser && !isPending ? slots["read-receipt"]?.(receiptSlotProps) ?? (0, import_vue5.h)("div", {
|
|
1083
1393
|
class: partClass("receipt", currentAppearance, "ckui-read-receipt"),
|
|
1084
1394
|
style: partStyle("receipt", currentAppearance)
|
|
@@ -1167,6 +1477,13 @@ var viewProps = {
|
|
|
1167
1477
|
onTypingChange: { type: Function, default: void 0 },
|
|
1168
1478
|
onAddAttachment: { type: Function, default: void 0 },
|
|
1169
1479
|
onAttachmentClick: { type: Function, default: void 0 },
|
|
1480
|
+
editingMessage: { type: Object, default: null },
|
|
1481
|
+
onEditMessage: { type: Function, default: void 0 },
|
|
1482
|
+
onSaveEdit: { type: Function, default: void 0 },
|
|
1483
|
+
onCancelEdit: { type: Function, default: void 0 },
|
|
1484
|
+
onDeleteMessage: { type: Function, default: void 0 },
|
|
1485
|
+
canEditMessage: { type: Function, default: void 0 },
|
|
1486
|
+
confirmDelete: { type: Function, default: void 0 },
|
|
1170
1487
|
isInitialLoading: { type: Boolean, default: false },
|
|
1171
1488
|
isLoadingOlder: { type: Boolean, default: false },
|
|
1172
1489
|
isSending: { type: Boolean, default: false },
|
|
@@ -1186,6 +1503,9 @@ var viewProps = {
|
|
|
1186
1503
|
defaultDraft: { type: String, default: "" },
|
|
1187
1504
|
onDraftChange: { type: Function, default: void 0 }
|
|
1188
1505
|
};
|
|
1506
|
+
function editingSummary(message) {
|
|
1507
|
+
return message.text?.trim() || (message.media.length === 1 ? "1 attachment" : `${message.media.length} attachments`);
|
|
1508
|
+
}
|
|
1189
1509
|
function typingLabel(userIds, displayNameForUser) {
|
|
1190
1510
|
const names = [...userIds].map(displayNameForUser);
|
|
1191
1511
|
if (names.length === 0) return "";
|
|
@@ -1205,7 +1525,11 @@ var ConversationView = (0, import_vue7.defineComponent)({
|
|
|
1205
1525
|
"load-older",
|
|
1206
1526
|
"add-attachment",
|
|
1207
1527
|
"attachment-click",
|
|
1208
|
-
"update:modelValue"
|
|
1528
|
+
"update:modelValue",
|
|
1529
|
+
"edit-message",
|
|
1530
|
+
"save-edit",
|
|
1531
|
+
"cancel-edit",
|
|
1532
|
+
"delete-message"
|
|
1209
1533
|
],
|
|
1210
1534
|
setup(props, { attrs, emit, slots }) {
|
|
1211
1535
|
const internalDraft = (0, import_vue7.ref)(props.defaultDraft);
|
|
@@ -1218,15 +1542,57 @@ var ConversationView = (0, import_vue7.defineComponent)({
|
|
|
1218
1542
|
});
|
|
1219
1543
|
const draft = () => props.modelValue ?? internalDraft.value;
|
|
1220
1544
|
let latestDraft = draft();
|
|
1221
|
-
|
|
1545
|
+
let stash;
|
|
1546
|
+
let saving = false;
|
|
1547
|
+
const setDraft = (value, typing = true) => {
|
|
1222
1548
|
latestDraft = value;
|
|
1223
1549
|
if (props.modelValue === void 0) internalDraft.value = value;
|
|
1224
1550
|
props.onDraftChange?.(value);
|
|
1225
1551
|
emit("update:modelValue", value);
|
|
1226
|
-
|
|
1227
|
-
|
|
1552
|
+
if (typing) void props.onTypingChange?.(value.trim().length > 0);
|
|
1553
|
+
};
|
|
1554
|
+
const enterEdit = (message) => {
|
|
1555
|
+
if (stash === void 0) stash = draft();
|
|
1556
|
+
setDraft(message.text ?? "", false);
|
|
1557
|
+
};
|
|
1558
|
+
const leaveEdit = (message, restore) => {
|
|
1559
|
+
if (stash === void 0) return;
|
|
1560
|
+
const saved = stash;
|
|
1561
|
+
stash = void 0;
|
|
1562
|
+
const current = draft();
|
|
1563
|
+
if (restore === "always" || current.trim() === "" || current === (message.text ?? "")) setDraft(saved);
|
|
1228
1564
|
};
|
|
1565
|
+
(0, import_vue7.watch)(() => props.editingMessage, (next, previous) => {
|
|
1566
|
+
if (next && (!previous || previous.id !== next.id)) enterEdit(next);
|
|
1567
|
+
else if (!next && previous && !saving) leaveEdit(previous, "unchanged");
|
|
1568
|
+
}, { immediate: true });
|
|
1569
|
+
const cancelEdit = () => {
|
|
1570
|
+
const editing = props.editingMessage;
|
|
1571
|
+
if (!editing) return;
|
|
1572
|
+
leaveEdit(editing, "always");
|
|
1573
|
+
props.onCancelEdit?.();
|
|
1574
|
+
};
|
|
1575
|
+
const canSave = (editing) => draft().trim().length > 0 || editing.media.length > 0;
|
|
1229
1576
|
const submit = async () => {
|
|
1577
|
+
const editing = props.editingMessage;
|
|
1578
|
+
if (editing) {
|
|
1579
|
+
const text2 = draft().trim();
|
|
1580
|
+
if (!canSave(editing) || props.isSending || submitting.value) return;
|
|
1581
|
+
submitting.value = true;
|
|
1582
|
+
saving = true;
|
|
1583
|
+
try {
|
|
1584
|
+
const saved = await props.onSaveEdit?.(editing, text2);
|
|
1585
|
+
if (saved === false) {
|
|
1586
|
+
if (props.editingMessage === null) leaveEdit(editing, "unchanged");
|
|
1587
|
+
return;
|
|
1588
|
+
}
|
|
1589
|
+
leaveEdit(editing, "always");
|
|
1590
|
+
} finally {
|
|
1591
|
+
saving = false;
|
|
1592
|
+
submitting.value = false;
|
|
1593
|
+
}
|
|
1594
|
+
return;
|
|
1595
|
+
}
|
|
1230
1596
|
const originalDraft = draft();
|
|
1231
1597
|
const text = originalDraft.trim();
|
|
1232
1598
|
if (!text || props.isSending || submitting.value) return;
|
|
@@ -1295,6 +1661,7 @@ var ConversationView = (0, import_vue7.defineComponent)({
|
|
|
1295
1661
|
}, typingLabel(props.typingUserIds, nameForUser));
|
|
1296
1662
|
};
|
|
1297
1663
|
const renderComposer = () => {
|
|
1664
|
+
const editing = props.editingMessage;
|
|
1298
1665
|
const slotProps = {
|
|
1299
1666
|
value: draft(),
|
|
1300
1667
|
setValue: setDraft,
|
|
@@ -1302,16 +1669,27 @@ var ConversationView = (0, import_vue7.defineComponent)({
|
|
|
1302
1669
|
send: () => {
|
|
1303
1670
|
void submit();
|
|
1304
1671
|
},
|
|
1305
|
-
...props.onAddAttachment ? { addAttachment } : {}
|
|
1672
|
+
...props.onAddAttachment ? { addAttachment } : {},
|
|
1673
|
+
...editing ? { editing, cancelEdit } : {}
|
|
1306
1674
|
};
|
|
1675
|
+
const busy = props.isSending || submitting.value;
|
|
1307
1676
|
return slots.composer?.(slotProps) ?? (0, import_vue7.h)("form", {
|
|
1308
|
-
class: partClass("composer", appearance(), "ckui-composer"),
|
|
1677
|
+
class: cx(partClass("composer", appearance(), "ckui-composer"), editing && !props.unstyled && "ckui-composer--editing"),
|
|
1309
1678
|
style: partStyle("composer", appearance()),
|
|
1310
1679
|
onSubmit: (event) => {
|
|
1311
1680
|
event.preventDefault();
|
|
1312
1681
|
void submit();
|
|
1313
1682
|
}
|
|
1314
1683
|
}, [
|
|
1684
|
+
// Spread, not null: outside edit mode the composer markup stays byte-identical to 0.7.
|
|
1685
|
+
...editing ? [(0, import_vue7.h)("div", { class: "ckui-composer__editing", role: "status" }, [
|
|
1686
|
+
(0, import_vue7.h)(import_vue6.Pencil, { size: 14, "aria-hidden": "true" }),
|
|
1687
|
+
(0, import_vue7.h)("span", { class: "ckui-composer__editing-body" }, [
|
|
1688
|
+
(0, import_vue7.h)("strong", "Editing message"),
|
|
1689
|
+
(0, import_vue7.h)("span", editingSummary(editing))
|
|
1690
|
+
]),
|
|
1691
|
+
(0, import_vue7.h)("button", { type: "button", class: "ckui-link-button", "aria-label": "Cancel editing", onClick: cancelEdit }, "Cancel")
|
|
1692
|
+
])] : [],
|
|
1315
1693
|
props.onAddAttachment ? (0, import_vue7.h)("button", {
|
|
1316
1694
|
type: "button",
|
|
1317
1695
|
"aria-label": "Add attachment",
|
|
@@ -1340,15 +1718,19 @@ var ConversationView = (0, import_vue7.defineComponent)({
|
|
|
1340
1718
|
event.preventDefault();
|
|
1341
1719
|
void submit();
|
|
1342
1720
|
}
|
|
1721
|
+
if (event.key === "Escape" && props.editingMessage) {
|
|
1722
|
+
event.preventDefault();
|
|
1723
|
+
cancelEdit();
|
|
1724
|
+
}
|
|
1343
1725
|
}
|
|
1344
1726
|
}),
|
|
1345
1727
|
(0, import_vue7.h)("button", {
|
|
1346
1728
|
type: "submit",
|
|
1347
|
-
"aria-label": "Send message",
|
|
1348
|
-
disabled: !draft().trim() ||
|
|
1729
|
+
"aria-label": editing ? "Save message" : "Send message",
|
|
1730
|
+
disabled: (editing ? !canSave(editing) : !draft().trim()) || busy,
|
|
1349
1731
|
class: partClass("button", appearance(), "ckui-send-button"),
|
|
1350
1732
|
style: partStyle("button", appearance())
|
|
1351
|
-
}, [
|
|
1733
|
+
}, [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" })])
|
|
1352
1734
|
]);
|
|
1353
1735
|
};
|
|
1354
1736
|
return () => {
|
|
@@ -1397,6 +1779,12 @@ var ConversationView = (0, import_vue7.defineComponent)({
|
|
|
1397
1779
|
...props.onAttachmentClick ? { onAttachmentClick: (media, message) => {
|
|
1398
1780
|
props.onAttachmentClick?.(media, message);
|
|
1399
1781
|
} } : {},
|
|
1782
|
+
...props.onEditMessage ? { onEditMessage: (message) => {
|
|
1783
|
+
props.onEditMessage?.(message);
|
|
1784
|
+
} } : {},
|
|
1785
|
+
...props.onDeleteMessage ? { onDeleteMessage: (message) => props.onDeleteMessage?.(message) } : {},
|
|
1786
|
+
...props.canEditMessage ? { canEditMessage: props.canEditMessage } : {},
|
|
1787
|
+
...props.confirmDelete ? { confirmDelete: props.confirmDelete } : {},
|
|
1400
1788
|
reverse: props.reverseMessages,
|
|
1401
1789
|
stickToBottom: props.stickToBottom,
|
|
1402
1790
|
paginationThreshold: props.paginationThreshold,
|
|
@@ -1427,6 +1815,11 @@ var Conversation = (0, import_vue7.defineComponent)({
|
|
|
1427
1815
|
messages: { type: Array, default: () => [] },
|
|
1428
1816
|
currentUserId: { type: String, default: "" },
|
|
1429
1817
|
onSendMessage: { type: Function, default: void 0 },
|
|
1818
|
+
editingMessage: { type: Object, default: void 0 },
|
|
1819
|
+
onEditMessage: { type: Function, default: void 0 },
|
|
1820
|
+
onSaveEdit: { type: Function, default: void 0 },
|
|
1821
|
+
onCancelEdit: { type: Function, default: void 0 },
|
|
1822
|
+
onDeleteMessage: { type: Function, default: void 0 },
|
|
1430
1823
|
client: { type: Object, required: true },
|
|
1431
1824
|
conversationId: { type: String, required: true },
|
|
1432
1825
|
messagePageSize: { type: Number, default: 30 },
|
|
@@ -1436,7 +1829,21 @@ var Conversation = (0, import_vue7.defineComponent)({
|
|
|
1436
1829
|
autoLoad: { type: Boolean, default: true },
|
|
1437
1830
|
onControllerChange: { type: Function, default: void 0 }
|
|
1438
1831
|
},
|
|
1439
|
-
emits: [
|
|
1832
|
+
emits: [
|
|
1833
|
+
"controller-change",
|
|
1834
|
+
"send-message",
|
|
1835
|
+
"typing-change",
|
|
1836
|
+
"back",
|
|
1837
|
+
"refresh",
|
|
1838
|
+
"load-older",
|
|
1839
|
+
"add-attachment",
|
|
1840
|
+
"attachment-click",
|
|
1841
|
+
"update:modelValue",
|
|
1842
|
+
"edit-message",
|
|
1843
|
+
"save-edit",
|
|
1844
|
+
"cancel-edit",
|
|
1845
|
+
"delete-message"
|
|
1846
|
+
],
|
|
1440
1847
|
setup(props, { attrs, emit, expose, slots }) {
|
|
1441
1848
|
const controller = useConversation({
|
|
1442
1849
|
client: () => props.client,
|
|
@@ -1500,6 +1907,11 @@ var Conversation = (0, import_vue7.defineComponent)({
|
|
|
1500
1907
|
isSending: _isSending,
|
|
1501
1908
|
hasOlderMessages: _hasOlderMessages,
|
|
1502
1909
|
error: _error,
|
|
1910
|
+
editingMessage: _editingMessage,
|
|
1911
|
+
onEditMessage: _onEditMessage,
|
|
1912
|
+
onSaveEdit: _onSaveEdit,
|
|
1913
|
+
onCancelEdit: _onCancelEdit,
|
|
1914
|
+
onDeleteMessage: _onDeleteMessage,
|
|
1503
1915
|
...forwarded
|
|
1504
1916
|
} = props;
|
|
1505
1917
|
return (0, import_vue7.h)(ConversationView, {
|
|
@@ -1523,6 +1935,28 @@ var Conversation = (0, import_vue7.defineComponent)({
|
|
|
1523
1935
|
isSending: controller.isSending.value,
|
|
1524
1936
|
hasOlderMessages: controller.hasOlderMessages.value,
|
|
1525
1937
|
...controller.error.value == null ? {} : { error: controller.error.value },
|
|
1938
|
+
// Edit mode is the store's; the actions render only while the adapter supports them (0.8.0).
|
|
1939
|
+
editingMessage: controller.editingMessage.value,
|
|
1940
|
+
...controller.canEditMessages.value ? {
|
|
1941
|
+
onEditMessage: (message) => {
|
|
1942
|
+
emit("edit-message", message);
|
|
1943
|
+
controller.startEditing(message.id);
|
|
1944
|
+
},
|
|
1945
|
+
onSaveEdit: async (message, text) => {
|
|
1946
|
+
emit("save-edit", message, text);
|
|
1947
|
+
return controller.saveEdit(text);
|
|
1948
|
+
},
|
|
1949
|
+
onCancelEdit: () => {
|
|
1950
|
+
emit("cancel-edit");
|
|
1951
|
+
controller.cancelEditing();
|
|
1952
|
+
}
|
|
1953
|
+
} : {},
|
|
1954
|
+
...controller.canDeleteMessages.value ? {
|
|
1955
|
+
onDeleteMessage: async (message) => {
|
|
1956
|
+
emit("delete-message", message);
|
|
1957
|
+
return controller.deleteMessage(message.id);
|
|
1958
|
+
}
|
|
1959
|
+
} : {},
|
|
1526
1960
|
"onUpdate:modelValue": (value) => emit("update:modelValue", value),
|
|
1527
1961
|
...props.onBack ? { onBack: () => {
|
|
1528
1962
|
props.onBack?.();
|
|
@@ -1936,6 +2370,66 @@ var ConversationListStore = class {
|
|
|
1936
2370
|
else if (!this.state.conversations.length && this.state.hasMore) await this.loadMore();
|
|
1937
2371
|
};
|
|
1938
2372
|
setQuery = (query) => this.setFilter({ ...this.state.filter, query });
|
|
2373
|
+
/** Mark a room unread for the viewer only; the row's summary takes the response (D10). Rejects when the adapter
|
|
2374
|
+
* lacks `markConversationUnread` or the store is not active; a request failure is reported through `error`
|
|
2375
|
+
* without evicting rows and rejects.
|
|
2376
|
+
*/
|
|
2377
|
+
markUnread = async (conversationId) => {
|
|
2378
|
+
const client = this.options.client;
|
|
2379
|
+
if (typeof client.markConversationUnread !== "function") {
|
|
2380
|
+
throw new TypeError("markUnread requires a ConvoKitUiClient adapter with markConversationUnread (core SDK 0.7)");
|
|
2381
|
+
}
|
|
2382
|
+
this.assertActive();
|
|
2383
|
+
this.applyPrivateState(conversationId, await this.mutate(client.markConversationUnread(conversationId)));
|
|
2384
|
+
};
|
|
2385
|
+
/** Remove the viewer's marker (conditionally on `options.ifVersion`); resolves to the response's `cleared` ("this
|
|
2386
|
+
* request removed the marker", not "the room is read") and patches the summary on true and false alike (D10).
|
|
2387
|
+
* Rejects when the adapter lacks `clearConversationUnread` or the store is not active; failures are reported like
|
|
2388
|
+
* `markUnread`.
|
|
2389
|
+
*/
|
|
2390
|
+
clearUnread = async (conversationId, options) => {
|
|
2391
|
+
const client = this.options.client;
|
|
2392
|
+
if (typeof client.clearConversationUnread !== "function") {
|
|
2393
|
+
throw new TypeError("clearUnread requires a ConvoKitUiClient adapter with clearConversationUnread (core SDK 0.7)");
|
|
2394
|
+
}
|
|
2395
|
+
this.assertActive();
|
|
2396
|
+
const result = await this.mutate(client.clearConversationUnread(conversationId, options));
|
|
2397
|
+
this.applyPrivateState(conversationId, result);
|
|
2398
|
+
return result.cleared;
|
|
2399
|
+
};
|
|
2400
|
+
/** A disposed or session-evicted store never sends a private-state mutation: on a shared client it could go out
|
|
2401
|
+
* under a replacement login. Rejected without touching `error` (there is no live snapshot to report into).
|
|
2402
|
+
*/
|
|
2403
|
+
assertActive() {
|
|
2404
|
+
if (!this.alive()) throw new Error("ConversationListStore is not active");
|
|
2405
|
+
}
|
|
2406
|
+
async mutate(request) {
|
|
2407
|
+
try {
|
|
2408
|
+
return await request;
|
|
2409
|
+
} catch (cause) {
|
|
2410
|
+
if (this.alive()) this.patch({ error: cause });
|
|
2411
|
+
throw cause;
|
|
2412
|
+
}
|
|
2413
|
+
}
|
|
2414
|
+
/** Apply a mark/clear response to the row's CURRENT summary (a refresh may have swapped it) as one unit, only while
|
|
2415
|
+
* the store is alive and the response is not older than the stored version: a delayed response never resurrects a
|
|
2416
|
+
* marker a newer action removed (equal versions are an idempotent no-op). `isUnread` is recomputed from the stored
|
|
2417
|
+
* counts and the response marker. Other devices learn of the change through `inbox_activity`.
|
|
2418
|
+
*/
|
|
2419
|
+
applyPrivateState(conversationId, state) {
|
|
2420
|
+
if (!this.alive()) return;
|
|
2421
|
+
const current = this.entries.find((entry) => entry.conversation.id === conversationId);
|
|
2422
|
+
if (!current || state.privateStateVersion < current.privateStateVersion) return;
|
|
2423
|
+
const { unreadMarkedAt, privateStateVersion } = state;
|
|
2424
|
+
const patched = {
|
|
2425
|
+
...current,
|
|
2426
|
+
unreadMarkedAt,
|
|
2427
|
+
privateStateVersion,
|
|
2428
|
+
isUnread: current.unreadCount > 0 || current.unreadCountCapped || unreadMarkedAt !== null
|
|
2429
|
+
};
|
|
2430
|
+
this.entries = this.entries.map((entry) => entry === current ? patched : entry);
|
|
2431
|
+
this.patch({ summaries: new Map(this.entries.map((entry) => [entry.conversation.id, summaryOf(entry)])) });
|
|
2432
|
+
}
|
|
1939
2433
|
};
|
|
1940
2434
|
|
|
1941
2435
|
// src/composables/use-conversation-list.ts
|
|
@@ -1983,6 +2477,8 @@ function useConversationList(options) {
|
|
|
1983
2477
|
loadMore: () => store.loadMore(),
|
|
1984
2478
|
setFilter: (filter) => store.setFilter(filter),
|
|
1985
2479
|
setQuery: (query) => store.setQuery(query),
|
|
2480
|
+
markUnread: (conversationId) => store.markUnread(conversationId),
|
|
2481
|
+
clearUnread: (conversationId, options2) => store.clearUnread(conversationId, options2),
|
|
1986
2482
|
dispose
|
|
1987
2483
|
};
|
|
1988
2484
|
}
|
|
@@ -2059,13 +2555,16 @@ var ConversationListView = (0, import_vue10.defineComponent)({
|
|
|
2059
2555
|
return void 0;
|
|
2060
2556
|
};
|
|
2061
2557
|
const unreadBadge = (summary) => {
|
|
2062
|
-
if (summary.unreadCount
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2558
|
+
if (summary.unreadCount > 0 || summary.unreadCountCapped) {
|
|
2559
|
+
const capped = summary.unreadCountCapped || summary.unreadCount > 99;
|
|
2560
|
+
return [(0, import_vue10.h)("span", {
|
|
2561
|
+
class: "ckui-unread-badge",
|
|
2562
|
+
role: "img",
|
|
2563
|
+
"aria-label": `${summary.unreadCountCapped ? "99+" : summary.unreadCount} unread`
|
|
2564
|
+
}, [(0, import_vue10.h)("span", { "aria-hidden": "true" }, capped ? "99+" : String(summary.unreadCount))])];
|
|
2565
|
+
}
|
|
2566
|
+
if (summary.isUnread) return [(0, import_vue10.h)("span", { class: "ckui-unread-badge ckui-unread-badge--dot", role: "img", "aria-label": "Unread" })];
|
|
2567
|
+
return [];
|
|
2069
2568
|
};
|
|
2070
2569
|
const renderContent = () => {
|
|
2071
2570
|
const currentAppearance = appearance();
|
|
@@ -2108,7 +2607,7 @@ var ConversationListView = (0, import_vue10.defineComponent)({
|
|
|
2108
2607
|
...props.currentUserId === void 0 ? {} : { currentUserId: props.currentUserId }
|
|
2109
2608
|
};
|
|
2110
2609
|
const preview = inboxPreview(conversation, summary, props.currentUserId);
|
|
2111
|
-
const unread = summary !== void 0 && (summary.unreadCount > 0 || summary.unreadCountCapped);
|
|
2610
|
+
const unread = summary !== void 0 && (summary.isUnread || summary.unreadCount > 0 || summary.unreadCountCapped);
|
|
2112
2611
|
const item = slots["conversation-item"]?.(slotProps) ?? (0, import_vue10.h)("button", {
|
|
2113
2612
|
type: "button",
|
|
2114
2613
|
"data-selected": selected || void 0,
|
|
@@ -2132,7 +2631,7 @@ var ConversationListView = (0, import_vue10.defineComponent)({
|
|
|
2132
2631
|
// summary must keep 0.5's exact markup.
|
|
2133
2632
|
...summary ? [(0, import_vue10.h)("span", { class: "ckui-conversation-item__meta" }, [
|
|
2134
2633
|
(0, import_vue10.h)("time", { class: "ckui-conversation-item__time", datetime: summary.activityAt.toISOString() }, formatMessageTime(summary.activityAt)),
|
|
2135
|
-
unreadBadge(summary)
|
|
2634
|
+
...unreadBadge(summary)
|
|
2136
2635
|
])] : [],
|
|
2137
2636
|
(0, import_vue10.h)(import_vue9.ChevronRight, { size: 18, "aria-hidden": "true" })
|
|
2138
2637
|
]);
|