@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.js
CHANGED
|
@@ -26,7 +26,11 @@ function createConvoKitUiClient(client) {
|
|
|
26
26
|
getMessages: (options) => client.getMessages(options),
|
|
27
27
|
getMessage: (id) => client.getMessage(id),
|
|
28
28
|
sendMessage: (input) => client.sendMessage(input),
|
|
29
|
+
editMessage: (messageId, input) => client.editMessage(messageId, input),
|
|
30
|
+
deleteMessage: (messageId) => client.deleteMessage(messageId),
|
|
29
31
|
markConversationRead: (conversationId, options) => client.markConversationRead(conversationId, options),
|
|
32
|
+
markConversationUnread: (conversationId) => client.markConversationUnread(conversationId),
|
|
33
|
+
clearConversationUnread: (conversationId, options) => client.clearConversationUnread(conversationId, options),
|
|
30
34
|
sendTyping: (input) => client.sendTyping(input),
|
|
31
35
|
onMessage: (conversationId, handler, onError) => client.realtime.onMessage(conversationId, {
|
|
32
36
|
onEvent: handler,
|
|
@@ -184,12 +188,13 @@ var ConvoKitAvatar = defineComponent({
|
|
|
184
188
|
});
|
|
185
189
|
|
|
186
190
|
// src/components/conversation.ts
|
|
187
|
-
import { ArrowLeft, LoaderCircle as LoaderCircle2, Paperclip, RefreshCw, Send } from "@lucide/vue";
|
|
191
|
+
import { ArrowLeft, Check as Check2, LoaderCircle as LoaderCircle2, Paperclip, Pencil as Pencil2, RefreshCw, Send } from "@lucide/vue";
|
|
188
192
|
import {
|
|
189
193
|
defineComponent as defineComponent3,
|
|
190
194
|
h as h3,
|
|
191
195
|
onBeforeUnmount,
|
|
192
196
|
ref as ref2,
|
|
197
|
+
watch as watch3,
|
|
193
198
|
watchEffect
|
|
194
199
|
} from "vue";
|
|
195
200
|
|
|
@@ -204,9 +209,31 @@ function version(message) {
|
|
|
204
209
|
function hasContent(message) {
|
|
205
210
|
return !!message.text?.trim() || message.media.length > 0;
|
|
206
211
|
}
|
|
212
|
+
function revisionOrder(left, right) {
|
|
213
|
+
const a = left.revision, b = right.revision;
|
|
214
|
+
if (typeof a !== "number" || typeof b !== "number" || a <= 0 && b <= 0) return void 0;
|
|
215
|
+
return a === b ? void 0 : a - b;
|
|
216
|
+
}
|
|
217
|
+
function older(candidate, reference) {
|
|
218
|
+
const byRevision = revisionOrder(candidate, reference);
|
|
219
|
+
return byRevision === void 0 ? version(candidate) < version(reference) : byRevision < 0;
|
|
220
|
+
}
|
|
207
221
|
function newest(current, incoming, incomingComplete = true) {
|
|
222
|
+
const byRevision = revisionOrder(current, incoming);
|
|
223
|
+
if (byRevision !== void 0) return byRevision > 0 ? current : incoming;
|
|
208
224
|
return version(current) > version(incoming) || !incomingComplete && version(current) === version(incoming) ? current : incoming;
|
|
209
225
|
}
|
|
226
|
+
function isRevisionConflict(cause) {
|
|
227
|
+
if (typeof cause !== "object" || cause === null) return false;
|
|
228
|
+
const { code, status } = cause;
|
|
229
|
+
return code === "REVISION_CONFLICT" || code === void 0 && status === 409;
|
|
230
|
+
}
|
|
231
|
+
function isMessageMissing(cause) {
|
|
232
|
+
return typeof cause === "object" && cause !== null && cause.code === "MESSAGE_NOT_FOUND";
|
|
233
|
+
}
|
|
234
|
+
function localConflict() {
|
|
235
|
+
return Object.assign(new Error("Message was changed since it was loaded"), { code: "REVISION_CONFLICT" });
|
|
236
|
+
}
|
|
210
237
|
var compare = compareMessageOrder;
|
|
211
238
|
function positionCursor(position) {
|
|
212
239
|
return { createdAt: position.createdAt, id: position.messageId };
|
|
@@ -217,12 +244,16 @@ function readEntry(participant) {
|
|
|
217
244
|
function acknowledgement() {
|
|
218
245
|
return { inFlight: void 0, followUp: false, suppressed: false, target: void 0, acknowledged: void 0, unacknowledgeable: /* @__PURE__ */ new Set() };
|
|
219
246
|
}
|
|
247
|
+
function capture(conversation) {
|
|
248
|
+
const membership = conversation?.membership;
|
|
249
|
+
return { version: membership?.privateStateVersion, clearPending: membership?.unreadMarkedAt != null };
|
|
250
|
+
}
|
|
220
251
|
function isTargetMiss(cause) {
|
|
221
252
|
if (typeof cause !== "object" || cause === null) return false;
|
|
222
253
|
const { code, status } = cause;
|
|
223
254
|
return code === "MESSAGE_NOT_FOUND" || code === void 0 && status === 404;
|
|
224
255
|
}
|
|
225
|
-
function blank(currentUserId = "") {
|
|
256
|
+
function blank(currentUserId = "", support = { edit: false, delete: false }) {
|
|
226
257
|
return {
|
|
227
258
|
conversation: null,
|
|
228
259
|
messages: [],
|
|
@@ -236,7 +267,10 @@ function blank(currentUserId = "") {
|
|
|
236
267
|
hasOlderMessages: true,
|
|
237
268
|
hasLoaded: false,
|
|
238
269
|
error: null,
|
|
239
|
-
currentUserId
|
|
270
|
+
currentUserId,
|
|
271
|
+
editingMessage: null,
|
|
272
|
+
canEditMessages: support.edit,
|
|
273
|
+
canDeleteMessages: support.delete
|
|
240
274
|
};
|
|
241
275
|
}
|
|
242
276
|
var ConversationStore = class {
|
|
@@ -255,7 +289,8 @@ var ConversationStore = class {
|
|
|
255
289
|
}
|
|
256
290
|
this.owner = this.client.sessionIdentity;
|
|
257
291
|
this.user = this.owner ? this.client.currentUserId : "";
|
|
258
|
-
this.
|
|
292
|
+
this.support = { edit: typeof this.client.editMessage === "function", delete: typeof this.client.deleteMessage === "function" };
|
|
293
|
+
this.state = blank(this.user, this.support);
|
|
259
294
|
}
|
|
260
295
|
options;
|
|
261
296
|
client;
|
|
@@ -277,10 +312,18 @@ var ConversationStore = class {
|
|
|
277
312
|
// Keep tombstones until an explicit reload/session change, including across refreshes.
|
|
278
313
|
deleted = /* @__PURE__ */ new Set();
|
|
279
314
|
ack = acknowledgement();
|
|
315
|
+
captured = capture();
|
|
280
316
|
// Visible until the platform reports otherwise; unknown/prerender/no document count as visible.
|
|
281
317
|
visible = true;
|
|
282
318
|
sendRevision;
|
|
283
319
|
activeSend;
|
|
320
|
+
/** Adapter support for author edits/deletes, decided once like `listInbox`. */
|
|
321
|
+
support;
|
|
322
|
+
/** The id whose `saveEdit` request is in flight: its outcome (success or 409) decides the edit, so newer rows for
|
|
323
|
+
* it arriving meanwhile (its own UPDATE image, typically) are not reported as a local conflict while it lasts;
|
|
324
|
+
* `settleEditing` re-evaluates them once the request has settled any other way.
|
|
325
|
+
*/
|
|
326
|
+
activeEdit;
|
|
284
327
|
refreshQueued = false;
|
|
285
328
|
typingTimers = /* @__PURE__ */ new Map();
|
|
286
329
|
ownTypingTimer;
|
|
@@ -299,9 +342,33 @@ var ConversationStore = class {
|
|
|
299
342
|
for (const message of patch.messages) this.confirmSend(message);
|
|
300
343
|
if (this.activeSend.confirmed) patch.messages = patch.messages.filter((message) => message.id !== this.activeSend.pending.id);
|
|
301
344
|
}
|
|
345
|
+
if (patch.messages) patch = this.trackEditing(patch);
|
|
302
346
|
this.state = { ...this.state, ...patch };
|
|
303
347
|
for (const listener of this.listeners) listener();
|
|
304
348
|
}
|
|
349
|
+
/** Edit mode follows the edited row wherever a message list reaches the state: the row leaving the list (deletion,
|
|
350
|
+
* reconcile tombstone, eviction) ends it, and a row for it with a higher revision than the snapshot (UPDATE image,
|
|
351
|
+
* hydration, reconcile, refresh) is the local conflict: the snapshot is replaced and `error` carries the conflict
|
|
352
|
+
* code, without a request. A save in flight owns its own outcome (`activeEdit`) and re-checks when it settles
|
|
353
|
+
* (`settleEditing`).
|
|
354
|
+
*/
|
|
355
|
+
trackEditing(patch) {
|
|
356
|
+
const editing = patch.editingMessage === void 0 ? this.state.editingMessage : patch.editingMessage;
|
|
357
|
+
if (!editing || !patch.messages) return patch;
|
|
358
|
+
const live = patch.messages.find((message) => message.id === editing.id);
|
|
359
|
+
if (!live) return { ...patch, editingMessage: null };
|
|
360
|
+
if (this.activeEdit === editing.id || !(live.revision > editing.revision)) return patch;
|
|
361
|
+
return { ...patch, editingMessage: live, error: localConflict() };
|
|
362
|
+
}
|
|
363
|
+
/** After a save for `id` has settled without deciding the edit (a failure, or a 409 whose reload failed), a newer
|
|
364
|
+
* row for it that arrived during the request is the local conflict after all: the snapshot is replaced and
|
|
365
|
+
* `error` carries the conflict code, so the next save carries the fresh revision without another round trip.
|
|
366
|
+
*/
|
|
367
|
+
settleEditing(id) {
|
|
368
|
+
const editing = this.state.editingMessage;
|
|
369
|
+
const live = editing?.id === id ? this.state.messages.find((message) => message.id === id) : void 0;
|
|
370
|
+
if (editing && live && live.revision > editing.revision) this.patch({ editingMessage: live, error: localConflict() });
|
|
371
|
+
}
|
|
305
372
|
alive(generation = this.generation) {
|
|
306
373
|
return !this.disposed && generation === this.generation && this.owner !== null && this.client.sessionIdentity === this.owner;
|
|
307
374
|
}
|
|
@@ -342,8 +409,10 @@ var ConversationStore = class {
|
|
|
342
409
|
this.hydrationPool.queued.clear();
|
|
343
410
|
this.deleted.clear();
|
|
344
411
|
this.ack = acknowledgement();
|
|
412
|
+
this.captured = capture();
|
|
345
413
|
this.sendRevision = void 0;
|
|
346
414
|
this.activeSend = void 0;
|
|
415
|
+
this.activeEdit = void 0;
|
|
347
416
|
this.refreshQueued = false;
|
|
348
417
|
}
|
|
349
418
|
dispose = () => {
|
|
@@ -352,14 +421,14 @@ var ConversationStore = class {
|
|
|
352
421
|
}
|
|
353
422
|
this.disposed = true;
|
|
354
423
|
this.clear();
|
|
355
|
-
this.patch(blank());
|
|
424
|
+
this.patch(blank("", this.support));
|
|
356
425
|
};
|
|
357
426
|
fail(cause, generation, history = false) {
|
|
358
427
|
if (!this.alive(generation)) return;
|
|
359
428
|
const status = typeof cause === "object" && cause !== null && "status" in cause ? cause.status : void 0;
|
|
360
429
|
if (history && (status === 401 || status === 403 || status === 404)) {
|
|
361
430
|
this.clear();
|
|
362
|
-
this.patch({ ...blank(this.user), error: cause, hasLoaded: true, hasOlderMessages: false });
|
|
431
|
+
this.patch({ ...blank(this.user, this.support), error: cause, hasLoaded: true, hasOlderMessages: false });
|
|
363
432
|
} else this.patch({ error: cause });
|
|
364
433
|
}
|
|
365
434
|
attach(generation, data = true) {
|
|
@@ -431,7 +500,7 @@ var ConversationStore = class {
|
|
|
431
500
|
if (!this.alive(generation) || type !== "insert" && type !== "update" || !this.validMessage(message) || this.deleted.has(message.id)) return;
|
|
432
501
|
const existing = this.state.messages.find((item) => item.id === message.id);
|
|
433
502
|
const known = existing ?? this.changes.get(message.id)?.message;
|
|
434
|
-
if (known &&
|
|
503
|
+
if (known && older(message, known)) return;
|
|
435
504
|
const insert = type === "insert" || this.changes.get(message.id)?.insert === true;
|
|
436
505
|
if (!existing && !insert && type === "update" && !this.state.isInitialLoading && !this.state.isLoadingOlder && !this.state.isReconciling) return;
|
|
437
506
|
const revision = ++this.revision;
|
|
@@ -444,7 +513,7 @@ var ConversationStore = class {
|
|
|
444
513
|
}
|
|
445
514
|
record(message, insert, revision, complete) {
|
|
446
515
|
const existing = this.state.messages.find((item) => item.id === message.id);
|
|
447
|
-
if (existing &&
|
|
516
|
+
if (existing && older(message, existing)) return;
|
|
448
517
|
if (this.confirmSend(message) && !complete && !message.media.length) {
|
|
449
518
|
message = { ...message, media: this.activeSend.pending.media };
|
|
450
519
|
this.confirmSend(message);
|
|
@@ -495,7 +564,7 @@ var ConversationStore = class {
|
|
|
495
564
|
if (!this.currentHydration(job)) return;
|
|
496
565
|
const full = await this.client.getMessage(id);
|
|
497
566
|
if (!this.currentHydration(job)) return;
|
|
498
|
-
if (!this.validMessage(full) || full.id !== id || full.senderId !== job.message.senderId ||
|
|
567
|
+
if (!this.validMessage(full) || full.id !== id || full.senderId !== job.message.senderId || older(full, job.message)) {
|
|
499
568
|
throw new Error("Complete message response does not match the observed resource/revision");
|
|
500
569
|
}
|
|
501
570
|
this.record(full, job.insert, job.revision, true);
|
|
@@ -575,7 +644,7 @@ var ConversationStore = class {
|
|
|
575
644
|
if (!this.alive()) return;
|
|
576
645
|
this.clear();
|
|
577
646
|
const generation = this.generation;
|
|
578
|
-
this.patch({ ...blank(this.user), isInitialLoading: true });
|
|
647
|
+
this.patch({ ...blank(this.user, this.support), isInitialLoading: true });
|
|
579
648
|
const revision = this.revision;
|
|
580
649
|
try {
|
|
581
650
|
this.attach(generation);
|
|
@@ -586,9 +655,13 @@ var ConversationStore = class {
|
|
|
586
655
|
this.validatePage(page);
|
|
587
656
|
this.cursor = page.at(-1);
|
|
588
657
|
this.patch({ conversation, messages: this.overlay(page, revision), hasOlderMessages: page.length === this.pageSize });
|
|
658
|
+
this.captured = capture(conversation);
|
|
589
659
|
this.mergeReads(conversation.participants.map(readEntry));
|
|
590
660
|
this.prune(revision);
|
|
591
|
-
if (this.options.markReadOnLoad ?? true)
|
|
661
|
+
if ((this.options.markReadOnLoad ?? true) || this.ack.suppressed) {
|
|
662
|
+
this.ack.suppressed = false;
|
|
663
|
+
await this.acknowledge(true);
|
|
664
|
+
}
|
|
592
665
|
} catch (cause) {
|
|
593
666
|
this.fail(cause, generation, true);
|
|
594
667
|
} finally {
|
|
@@ -645,9 +718,12 @@ var ConversationStore = class {
|
|
|
645
718
|
const survivingIds = new Set(reconciled.map((message) => message.id));
|
|
646
719
|
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));
|
|
647
720
|
for (const id of known) if (!survivingIds.has(id)) this.forget(id);
|
|
721
|
+
const opening = this.state.conversation === null;
|
|
648
722
|
this.patch({ conversation, messages: reconciled, hasOlderMessages: hasOlder });
|
|
723
|
+
if (opening) this.captured = capture(conversation);
|
|
649
724
|
this.mergeReads(conversation.participants.map(readEntry));
|
|
650
725
|
this.prune(revision);
|
|
726
|
+
if (opening) void this.resumeAcknowledgement();
|
|
651
727
|
} catch (cause) {
|
|
652
728
|
this.fail(cause, generation, true);
|
|
653
729
|
} finally {
|
|
@@ -681,15 +757,22 @@ var ConversationStore = class {
|
|
|
681
757
|
}
|
|
682
758
|
}
|
|
683
759
|
};
|
|
684
|
-
/** Acknowledge through the newest rendered row now, regardless of visibility; no
|
|
760
|
+
/** Acknowledge through the newest rendered row now, regardless of visibility; no acknowledgement without a
|
|
761
|
+
* target (a room opened with a marker that renders nothing clears the marker instead, once).
|
|
762
|
+
*/
|
|
685
763
|
markRead = () => this.alive() ? this.acknowledge(false) : Promise.resolve();
|
|
686
764
|
/** Automatic acknowledgements wait while hidden and are re-issued (once) on becoming visible. */
|
|
687
765
|
setVisible = (visible) => {
|
|
688
766
|
this.visible = visible;
|
|
689
|
-
if (!visible || !this.
|
|
690
|
-
this.
|
|
691
|
-
void this.acknowledge(true);
|
|
767
|
+
if (!visible || !this.alive()) return;
|
|
768
|
+
void this.resumeAcknowledgement();
|
|
692
769
|
};
|
|
770
|
+
/** Re-issue (once) the automatic acknowledgement that waited while hidden or before this open's DTO, if any. */
|
|
771
|
+
resumeAcknowledgement() {
|
|
772
|
+
if (!this.ack.suppressed) return Promise.resolve();
|
|
773
|
+
this.ack.suppressed = false;
|
|
774
|
+
return this.acknowledge(true);
|
|
775
|
+
}
|
|
693
776
|
/** The newest non-pending rendered row by (createdAt, id), never by list index and never a raw realtime
|
|
694
777
|
* row; rows the server does not know are skipped, and nothing at or before the accepted target is re-sent.
|
|
695
778
|
*/
|
|
@@ -704,7 +787,7 @@ var ConversationStore = class {
|
|
|
704
787
|
/** Resolves when the request this call issued or joined settles; a follow-up is issued, not awaited. */
|
|
705
788
|
acknowledge(automatic) {
|
|
706
789
|
const ack = this.ack;
|
|
707
|
-
if (automatic && !this.visible) {
|
|
790
|
+
if (automatic && (!this.visible || this.state.conversation === null)) {
|
|
708
791
|
ack.suppressed = true;
|
|
709
792
|
return Promise.resolve();
|
|
710
793
|
}
|
|
@@ -717,9 +800,10 @@ var ConversationStore = class {
|
|
|
717
800
|
issue(ack, generation) {
|
|
718
801
|
ack.followUp = false;
|
|
719
802
|
const target = this.ackTarget(ack);
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
ack.
|
|
803
|
+
const request = target ? this.send(ack, generation, target) : this.clearMarker(ack, generation);
|
|
804
|
+
if (!request) return void 0;
|
|
805
|
+
ack.target = target?.id;
|
|
806
|
+
ack.inFlight = request.finally(() => {
|
|
723
807
|
ack.inFlight = void 0;
|
|
724
808
|
ack.target = void 0;
|
|
725
809
|
if (!this.alive(generation) || !ack.followUp) return;
|
|
@@ -730,9 +814,31 @@ var ConversationStore = class {
|
|
|
730
814
|
});
|
|
731
815
|
return ack.inFlight;
|
|
732
816
|
}
|
|
817
|
+
/** A room opened with the caller's unread marker that renders no non-pending, acknowledgeable row cannot clear
|
|
818
|
+
* it through a targeted acknowledgement, so it asks the adapter to clear the marker conditionally on the captured
|
|
819
|
+
* version, once per open, under the acknowledgement triggers and visibility gating. Rows rendered later clear it
|
|
820
|
+
* through their acknowledgements; adapters without the member leave it; `cleared: false` is not an error.
|
|
821
|
+
*/
|
|
822
|
+
clearMarker(ack, generation) {
|
|
823
|
+
const captured = this.captured;
|
|
824
|
+
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;
|
|
825
|
+
captured.clearPending = false;
|
|
826
|
+
return this.clearUnread(captured.version, generation);
|
|
827
|
+
}
|
|
828
|
+
async clearUnread(version2, generation) {
|
|
829
|
+
try {
|
|
830
|
+
await this.client.clearConversationUnread(this.room, { ifVersion: version2 });
|
|
831
|
+
} catch (cause) {
|
|
832
|
+
if (this.alive(generation)) this.fail(cause, generation);
|
|
833
|
+
}
|
|
834
|
+
}
|
|
733
835
|
async send(ack, generation, target) {
|
|
734
836
|
try {
|
|
735
|
-
|
|
837
|
+
const version2 = this.captured.version;
|
|
838
|
+
await this.client.markConversationRead(this.room, {
|
|
839
|
+
throughMessageId: target.id,
|
|
840
|
+
...version2 === void 0 ? {} : { privateStateVersion: version2 }
|
|
841
|
+
});
|
|
736
842
|
if (!this.alive(generation)) return;
|
|
737
843
|
if (!ack.acknowledged || compare(target, ack.acknowledged) > 0) ack.acknowledged = target;
|
|
738
844
|
} catch (cause) {
|
|
@@ -781,7 +887,8 @@ var ConversationStore = class {
|
|
|
781
887
|
text: normalized || null,
|
|
782
888
|
media: media ?? [],
|
|
783
889
|
createdAt: /* @__PURE__ */ new Date(),
|
|
784
|
-
updatedAt: null
|
|
890
|
+
updatedAt: null,
|
|
891
|
+
revision: 0
|
|
785
892
|
};
|
|
786
893
|
const send = { pending };
|
|
787
894
|
this.activeSend = send;
|
|
@@ -825,6 +932,132 @@ var ConversationStore = class {
|
|
|
825
932
|
}
|
|
826
933
|
}
|
|
827
934
|
};
|
|
935
|
+
/** The caller's role in the open room when known (0.7 `membership`, else the caller's participant row). */
|
|
936
|
+
ownRole() {
|
|
937
|
+
const conversation = this.state.conversation;
|
|
938
|
+
return conversation?.membership?.role ?? conversation?.participants.find((participant) => participant.appUserId === this.user || participant.id === this.user)?.role;
|
|
939
|
+
}
|
|
940
|
+
/** A rendered, confirmed row of the caller's own that is not known to be gone. */
|
|
941
|
+
ownRow(messageId) {
|
|
942
|
+
const row = this.state.messages.find((message) => message.id === messageId);
|
|
943
|
+
return row && row.senderId === this.user && !isConvoKitPendingMessage(row) && !this.deleted.has(messageId) ? row : void 0;
|
|
944
|
+
}
|
|
945
|
+
/** Enter edit mode on one of the caller's own confirmed messages (0.8.0): the row as it stands now becomes the
|
|
946
|
+
* snapshot whose `revision` every save sends. A no-op unless the adapter implements `editMessage`, the row is
|
|
947
|
+
* rendered, own, confirmed, not tombstoned and the caller's role (when known) is not `READ`. Sends nothing.
|
|
948
|
+
*/
|
|
949
|
+
startEditing = (messageId) => {
|
|
950
|
+
if (!this.alive() || !this.support.edit || this.ownRole() === "READ") return;
|
|
951
|
+
const row = this.ownRow(messageId);
|
|
952
|
+
if (row) this.patch({ editingMessage: row });
|
|
953
|
+
};
|
|
954
|
+
/** Leave edit mode without a request; the draft is the view's to restore. */
|
|
955
|
+
cancelEditing = () => {
|
|
956
|
+
if (this.state.editingMessage) this.patch({ editingMessage: null });
|
|
957
|
+
};
|
|
958
|
+
/** Save the edit in progress with the snapshot's revision (never the live row's), trimming the text and sending
|
|
959
|
+
* `null` for an empty caption. Resolves true when the server accepted the edit (the response is merged through the
|
|
960
|
+
* tombstone and precedence guards and edit mode ends); false when nothing was saved: a stale revision (409
|
|
961
|
+
* `REVISION_CONFLICT`) reloads the row once through `getMessage`, replaces the snapshot with it (the next save
|
|
962
|
+
* carries the fresh revision) and reports the conflict through `error`, keeping edit mode; a coded 404
|
|
963
|
+
* (`MESSAGE_NOT_FOUND`, on the save or on that reload) removes the row and ends edit mode; any other failure
|
|
964
|
+
* (403, 500, network, an uncoded 404 from a 0.7 backend) is reported through `error` without evicting anything and
|
|
965
|
+
* keeps edit mode; if a newer row for the message arrived during such a request, that row is then the local
|
|
966
|
+
* conflict (`settleEditing`). A text-only message cannot be saved empty (no request). Rejects when the adapter
|
|
967
|
+
* lacks `editMessage`.
|
|
968
|
+
*/
|
|
969
|
+
saveEdit = async (text) => {
|
|
970
|
+
const client = this.client;
|
|
971
|
+
if (typeof client.editMessage !== "function") {
|
|
972
|
+
throw new TypeError("This ConvoKitUiClient adapter does not implement editMessage (0.8)");
|
|
973
|
+
}
|
|
974
|
+
const snapshot = this.state.editingMessage;
|
|
975
|
+
if (!this.alive() || !snapshot || this.activeEdit !== void 0) return false;
|
|
976
|
+
const trimmed = text.trim();
|
|
977
|
+
const normalized = trimmed === "" ? null : trimmed;
|
|
978
|
+
if (normalized === null && snapshot.media.length === 0) return false;
|
|
979
|
+
const generation = this.generation;
|
|
980
|
+
const id = snapshot.id;
|
|
981
|
+
this.activeEdit = id;
|
|
982
|
+
this.patch({ error: null });
|
|
983
|
+
try {
|
|
984
|
+
const message = await client.editMessage(id, { text: normalized, revision: snapshot.revision });
|
|
985
|
+
if (!this.alive(generation)) return false;
|
|
986
|
+
if (!this.validMessage(message) || message.id !== id || message.senderId !== this.user) {
|
|
987
|
+
throw new Error("Edit response belongs to a different message or sender");
|
|
988
|
+
}
|
|
989
|
+
if (this.state.editingMessage?.id === id) this.patch({ editingMessage: null });
|
|
990
|
+
this.applyRow(message);
|
|
991
|
+
return true;
|
|
992
|
+
} catch (cause) {
|
|
993
|
+
if (!this.alive(generation)) return false;
|
|
994
|
+
if (isRevisionConflict(cause)) await this.reloadConflict(id, cause, generation);
|
|
995
|
+
else if (isMessageMissing(cause)) {
|
|
996
|
+
this.removeMessage(id);
|
|
997
|
+
this.patch({ error: cause });
|
|
998
|
+
} else this.fail(cause, generation);
|
|
999
|
+
return false;
|
|
1000
|
+
} finally {
|
|
1001
|
+
if (this.alive(generation)) {
|
|
1002
|
+
this.activeEdit = void 0;
|
|
1003
|
+
this.settleEditing(id);
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
};
|
|
1007
|
+
/** Merge a complete REST row for a known id through the live-row guards: a tombstoned id is dropped, and an older
|
|
1008
|
+
* revision (or timestamp) never overwrites the newer row already recorded. Recorded as a non-insert change, so a
|
|
1009
|
+
* reconcile keeps it only while the row is still in the fetched range.
|
|
1010
|
+
*/
|
|
1011
|
+
applyRow(message) {
|
|
1012
|
+
if (this.deleted.has(message.id)) return;
|
|
1013
|
+
this.record(message, this.changes.get(message.id)?.insert === true, ++this.revision, true);
|
|
1014
|
+
}
|
|
1015
|
+
/** The 409 path: one `getMessage` shows the conflicting content. Its row is merged through the guards and becomes
|
|
1016
|
+
* the new snapshot; a `MESSAGE_NOT_FOUND` answer removes the row and ends edit mode; another failure keeps the
|
|
1017
|
+
* snapshot. `error` carries the conflict (or the reload failure).
|
|
1018
|
+
*/
|
|
1019
|
+
async reloadConflict(id, conflict, generation) {
|
|
1020
|
+
try {
|
|
1021
|
+
const current = await this.client.getMessage(id);
|
|
1022
|
+
if (!this.alive(generation)) return;
|
|
1023
|
+
if (!this.validMessage(current) || current.id !== id) throw new Error("Complete message response does not match the edited message");
|
|
1024
|
+
this.applyRow(current);
|
|
1025
|
+
const row = this.state.messages.find((message) => message.id === id);
|
|
1026
|
+
const editing = this.state.editingMessage?.id === id && row ? { editingMessage: row } : {};
|
|
1027
|
+
this.patch({ ...editing, error: conflict });
|
|
1028
|
+
} catch (cause) {
|
|
1029
|
+
if (!this.alive(generation)) return;
|
|
1030
|
+
if (isTargetMiss(cause)) this.removeMessage(id);
|
|
1031
|
+
this.patch({ error: cause });
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
/** Delete one of the caller's own confirmed messages (0.8.0). The row stays until the server answers: on success,
|
|
1035
|
+
* or when the server no longer knows it (`MESSAGE_NOT_FOUND`), it is tombstoned and removed (late responses, row
|
|
1036
|
+
* images and hydrations for it are dropped, the acknowledgement target is re-resolved and edit mode on it ends)
|
|
1037
|
+
* and the call resolves true; any other failure keeps the row, reports through `error` and resolves false. Rejects
|
|
1038
|
+
* when the adapter lacks `deleteMessage`.
|
|
1039
|
+
*/
|
|
1040
|
+
deleteMessage = async (messageId) => {
|
|
1041
|
+
const client = this.client;
|
|
1042
|
+
if (typeof client.deleteMessage !== "function") {
|
|
1043
|
+
throw new TypeError("This ConvoKitUiClient adapter does not implement deleteMessage (0.8)");
|
|
1044
|
+
}
|
|
1045
|
+
if (!this.alive() || !this.ownRow(messageId)) return false;
|
|
1046
|
+
const generation = this.generation;
|
|
1047
|
+
this.patch({ error: null });
|
|
1048
|
+
try {
|
|
1049
|
+
await client.deleteMessage(messageId);
|
|
1050
|
+
} catch (cause) {
|
|
1051
|
+
if (!this.alive(generation)) return false;
|
|
1052
|
+
if (!isMessageMissing(cause)) {
|
|
1053
|
+
this.fail(cause, generation);
|
|
1054
|
+
return false;
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
if (!this.alive(generation)) return false;
|
|
1058
|
+
this.removeMessage(messageId);
|
|
1059
|
+
return true;
|
|
1060
|
+
};
|
|
828
1061
|
readerIdsFor = (message) => readerIdsFor(message, this.state.readAtByUserId, this.state.readPositionByUserId);
|
|
829
1062
|
};
|
|
830
1063
|
|
|
@@ -878,11 +1111,18 @@ function useConversation(options) {
|
|
|
878
1111
|
hasLoaded: field("hasLoaded"),
|
|
879
1112
|
error: field("error"),
|
|
880
1113
|
currentUserId: field("currentUserId"),
|
|
1114
|
+
editingMessage: field("editingMessage"),
|
|
1115
|
+
canEditMessages: field("canEditMessages"),
|
|
1116
|
+
canDeleteMessages: field("canDeleteMessages"),
|
|
881
1117
|
readerIdsFor: (message) => store.readerIdsFor(message),
|
|
882
1118
|
loadInitial: () => store.loadInitial(),
|
|
883
1119
|
refresh: () => store.refresh(),
|
|
884
1120
|
loadOlderMessages: () => store.loadOlderMessages(),
|
|
885
1121
|
sendMessage: (input) => store.sendMessage(input),
|
|
1122
|
+
startEditing: (messageId) => store.startEditing(messageId),
|
|
1123
|
+
cancelEditing: () => store.cancelEditing(),
|
|
1124
|
+
saveEdit: (text) => store.saveEdit(text),
|
|
1125
|
+
deleteMessage: (messageId) => store.deleteMessage(messageId),
|
|
886
1126
|
markRead: () => store.markRead(),
|
|
887
1127
|
updateTyping: (isTyping) => store.updateTyping(isTyping),
|
|
888
1128
|
setVisible: (value) => {
|
|
@@ -894,6 +1134,7 @@ function useConversation(options) {
|
|
|
894
1134
|
}
|
|
895
1135
|
|
|
896
1136
|
// src/components/message-list.ts
|
|
1137
|
+
import { isEditedMessage } from "@convokitapp/sdk";
|
|
897
1138
|
import {
|
|
898
1139
|
Check,
|
|
899
1140
|
CheckCheck,
|
|
@@ -903,7 +1144,9 @@ import {
|
|
|
903
1144
|
ImageOff,
|
|
904
1145
|
LoaderCircle,
|
|
905
1146
|
MapPin,
|
|
906
|
-
MessageCircle
|
|
1147
|
+
MessageCircle,
|
|
1148
|
+
Pencil,
|
|
1149
|
+
Trash2
|
|
907
1150
|
} from "@lucide/vue";
|
|
908
1151
|
import {
|
|
909
1152
|
computed as computed2,
|
|
@@ -965,6 +1208,10 @@ var MessageListView = defineComponent2({
|
|
|
965
1208
|
isLoadingOlder: { type: Boolean, default: false },
|
|
966
1209
|
error: { type: null, required: false },
|
|
967
1210
|
onAttachmentClick: { type: Function, default: void 0 },
|
|
1211
|
+
onEditMessage: { type: Function, default: void 0 },
|
|
1212
|
+
onDeleteMessage: { type: Function, default: void 0 },
|
|
1213
|
+
canEditMessage: { type: Function, default: void 0 },
|
|
1214
|
+
confirmDelete: { type: Function, default: void 0 },
|
|
968
1215
|
scrollElement: { type: Object, default: void 0 },
|
|
969
1216
|
paginationThreshold: { type: Number, default: 240 },
|
|
970
1217
|
reverse: { type: Boolean, default: true },
|
|
@@ -972,9 +1219,10 @@ var MessageListView = defineComponent2({
|
|
|
972
1219
|
formatTime: { type: Function, default: formatMessageTime },
|
|
973
1220
|
imageLoading: { type: String, default: "lazy" }
|
|
974
1221
|
},
|
|
975
|
-
emits: ["load-older", "attachment-click"],
|
|
1222
|
+
emits: ["load-older", "attachment-click", "edit-message", "delete-message"],
|
|
976
1223
|
setup(props, { attrs, emit, slots }) {
|
|
977
1224
|
const internalElement = ref(null);
|
|
1225
|
+
const confirming = ref(null);
|
|
978
1226
|
let requestInFlight = false;
|
|
979
1227
|
let lastRequestedLength = null;
|
|
980
1228
|
let previousMessageCount = 0;
|
|
@@ -1014,16 +1262,77 @@ var MessageListView = defineComponent2({
|
|
|
1014
1262
|
}
|
|
1015
1263
|
}
|
|
1016
1264
|
}, { flush: "post", immediate: true });
|
|
1265
|
+
const viewerRole = computed2(() => props.conversation.membership?.role ?? props.conversation.participants.find((participant) => participant.appUserId === props.currentUserId || participant.id === props.currentUserId)?.role);
|
|
1266
|
+
const remove = async (message) => {
|
|
1267
|
+
if (props.confirmDelete && !await props.confirmDelete(message)) return false;
|
|
1268
|
+
return await props.onDeleteMessage?.(message) !== false;
|
|
1269
|
+
};
|
|
1017
1270
|
const renderMessage = (message, index) => {
|
|
1018
1271
|
const isCurrentUser = message.senderId === props.currentUserId;
|
|
1019
1272
|
const sender = participants.value.get(message.senderId);
|
|
1020
1273
|
const isPending = isConvoKitPendingMessage(message);
|
|
1021
1274
|
const readerIds = isPending ? /* @__PURE__ */ new Set() : props.readersResolver ? props.readersResolver(message) : readerIdsFor(message, props.readAtByUserId, props.readPositionByUserId);
|
|
1022
|
-
const
|
|
1275
|
+
const isEdited = !isPending && isEditedMessage(message);
|
|
1276
|
+
const eligible = !isPending && (props.canEditMessage ? props.canEditMessage(message) : isCurrentUser && viewerRole.value !== "READ");
|
|
1277
|
+
const canEdit = eligible && !!props.onEditMessage;
|
|
1278
|
+
const canDelete = eligible && !!props.onDeleteMessage;
|
|
1279
|
+
const edit = () => {
|
|
1280
|
+
props.onEditMessage?.(message);
|
|
1281
|
+
};
|
|
1282
|
+
const slotProps = {
|
|
1283
|
+
message,
|
|
1284
|
+
chronologicalIndex: index,
|
|
1285
|
+
isCurrentUser,
|
|
1286
|
+
sender,
|
|
1287
|
+
readerIds,
|
|
1288
|
+
isEdited,
|
|
1289
|
+
canEdit,
|
|
1290
|
+
canDelete,
|
|
1291
|
+
...canEdit ? { edit } : {},
|
|
1292
|
+
...canDelete ? { remove: () => remove(message) } : {}
|
|
1293
|
+
};
|
|
1023
1294
|
const custom = slots.message?.(slotProps);
|
|
1024
1295
|
if (custom) return h2("div", { key: message.id, role: "listitem" }, custom);
|
|
1025
1296
|
const currentAppearance = appearance();
|
|
1026
1297
|
const messagePart = isCurrentUser ? "outgoingMessage" : "incomingMessage";
|
|
1298
|
+
const iconButton = (label, onClick, icon) => h2("button", {
|
|
1299
|
+
type: "button",
|
|
1300
|
+
"aria-label": label,
|
|
1301
|
+
onClick,
|
|
1302
|
+
class: partClass("button", currentAppearance, "ckui-icon-button"),
|
|
1303
|
+
style: partStyle("button", currentAppearance)
|
|
1304
|
+
}, [icon]);
|
|
1305
|
+
const actions = canEdit || canDelete ? h2("div", { class: "ckui-message-actions" }, [
|
|
1306
|
+
...canEdit ? [iconButton("Edit message", edit, h2(Pencil, { size: 16, "aria-hidden": "true" }))] : [],
|
|
1307
|
+
...canDelete ? [iconButton("Delete message", () => {
|
|
1308
|
+
if (props.confirmDelete) void remove(message);
|
|
1309
|
+
else confirming.value = message.id;
|
|
1310
|
+
}, h2(Trash2, { size: 16, "aria-hidden": "true" }))] : []
|
|
1311
|
+
]) : null;
|
|
1312
|
+
const confirm = canDelete && confirming.value === message.id ? h2("div", {
|
|
1313
|
+
class: "ckui-message-confirm",
|
|
1314
|
+
role: "group",
|
|
1315
|
+
"aria-label": "Delete this message?"
|
|
1316
|
+
}, [
|
|
1317
|
+
h2("span", "Delete this message?"),
|
|
1318
|
+
h2("button", {
|
|
1319
|
+
type: "button",
|
|
1320
|
+
class: "ckui-link-button",
|
|
1321
|
+
"aria-label": "Confirm delete",
|
|
1322
|
+
onClick: () => {
|
|
1323
|
+
confirming.value = null;
|
|
1324
|
+
void props.onDeleteMessage?.(message);
|
|
1325
|
+
}
|
|
1326
|
+
}, "Delete"),
|
|
1327
|
+
h2("button", {
|
|
1328
|
+
type: "button",
|
|
1329
|
+
class: "ckui-link-button",
|
|
1330
|
+
"aria-label": "Cancel delete",
|
|
1331
|
+
onClick: () => {
|
|
1332
|
+
confirming.value = null;
|
|
1333
|
+
}
|
|
1334
|
+
}, "Cancel")
|
|
1335
|
+
]) : null;
|
|
1027
1336
|
const mediaNodes = message.media.map((media, mediaIndex) => {
|
|
1028
1337
|
const open = props.onAttachmentClick ? () => {
|
|
1029
1338
|
props.onAttachmentClick?.(media, message);
|
|
@@ -1047,15 +1356,19 @@ var MessageListView = defineComponent2({
|
|
|
1047
1356
|
style: [props.styles?.message, props.styles?.[messagePart]],
|
|
1048
1357
|
"data-message-id": message.id
|
|
1049
1358
|
}, [
|
|
1359
|
+
// Spread, not null: an absent action/label/prompt must not leave a comment node (0.7 markup stays byte-identical).
|
|
1360
|
+
...actions ? [actions] : [],
|
|
1050
1361
|
h2("div", { class: "ckui-message-bubble" }, [
|
|
1051
1362
|
!isCurrentUser ? h2("strong", { class: "ckui-message-sender" }, sender?.name || message.senderId) : null,
|
|
1052
1363
|
message.text ? h2("div", { class: "ckui-message-text" }, message.text) : null,
|
|
1053
1364
|
...mediaNodes,
|
|
1054
1365
|
h2("span", { class: "ckui-message-time" }, [
|
|
1055
1366
|
isPending ? "Sending\u2026" : props.formatTime(message.createdAt),
|
|
1367
|
+
...isEdited ? [h2("span", { class: "ckui-message-edited", "aria-label": "Edited" }, "Edited")] : [],
|
|
1056
1368
|
isCurrentUser && !isPending ? readerIds.size > 0 ? h2(CheckCheck, { size: 14, "aria-label": "Read" }) : h2(Check, { size: 14, "aria-label": "Sent" }) : null
|
|
1057
1369
|
])
|
|
1058
1370
|
]),
|
|
1371
|
+
...confirm ? [confirm] : [],
|
|
1059
1372
|
isCurrentUser && !isPending ? slots["read-receipt"]?.(receiptSlotProps) ?? h2("div", {
|
|
1060
1373
|
class: partClass("receipt", currentAppearance, "ckui-read-receipt"),
|
|
1061
1374
|
style: partStyle("receipt", currentAppearance)
|
|
@@ -1144,6 +1457,13 @@ var viewProps = {
|
|
|
1144
1457
|
onTypingChange: { type: Function, default: void 0 },
|
|
1145
1458
|
onAddAttachment: { type: Function, default: void 0 },
|
|
1146
1459
|
onAttachmentClick: { type: Function, default: void 0 },
|
|
1460
|
+
editingMessage: { type: Object, default: null },
|
|
1461
|
+
onEditMessage: { type: Function, default: void 0 },
|
|
1462
|
+
onSaveEdit: { type: Function, default: void 0 },
|
|
1463
|
+
onCancelEdit: { type: Function, default: void 0 },
|
|
1464
|
+
onDeleteMessage: { type: Function, default: void 0 },
|
|
1465
|
+
canEditMessage: { type: Function, default: void 0 },
|
|
1466
|
+
confirmDelete: { type: Function, default: void 0 },
|
|
1147
1467
|
isInitialLoading: { type: Boolean, default: false },
|
|
1148
1468
|
isLoadingOlder: { type: Boolean, default: false },
|
|
1149
1469
|
isSending: { type: Boolean, default: false },
|
|
@@ -1163,6 +1483,9 @@ var viewProps = {
|
|
|
1163
1483
|
defaultDraft: { type: String, default: "" },
|
|
1164
1484
|
onDraftChange: { type: Function, default: void 0 }
|
|
1165
1485
|
};
|
|
1486
|
+
function editingSummary(message) {
|
|
1487
|
+
return message.text?.trim() || (message.media.length === 1 ? "1 attachment" : `${message.media.length} attachments`);
|
|
1488
|
+
}
|
|
1166
1489
|
function typingLabel(userIds, displayNameForUser) {
|
|
1167
1490
|
const names = [...userIds].map(displayNameForUser);
|
|
1168
1491
|
if (names.length === 0) return "";
|
|
@@ -1182,7 +1505,11 @@ var ConversationView = defineComponent3({
|
|
|
1182
1505
|
"load-older",
|
|
1183
1506
|
"add-attachment",
|
|
1184
1507
|
"attachment-click",
|
|
1185
|
-
"update:modelValue"
|
|
1508
|
+
"update:modelValue",
|
|
1509
|
+
"edit-message",
|
|
1510
|
+
"save-edit",
|
|
1511
|
+
"cancel-edit",
|
|
1512
|
+
"delete-message"
|
|
1186
1513
|
],
|
|
1187
1514
|
setup(props, { attrs, emit, slots }) {
|
|
1188
1515
|
const internalDraft = ref2(props.defaultDraft);
|
|
@@ -1195,15 +1522,57 @@ var ConversationView = defineComponent3({
|
|
|
1195
1522
|
});
|
|
1196
1523
|
const draft = () => props.modelValue ?? internalDraft.value;
|
|
1197
1524
|
let latestDraft = draft();
|
|
1198
|
-
|
|
1525
|
+
let stash;
|
|
1526
|
+
let saving = false;
|
|
1527
|
+
const setDraft = (value, typing = true) => {
|
|
1199
1528
|
latestDraft = value;
|
|
1200
1529
|
if (props.modelValue === void 0) internalDraft.value = value;
|
|
1201
1530
|
props.onDraftChange?.(value);
|
|
1202
1531
|
emit("update:modelValue", value);
|
|
1203
|
-
|
|
1204
|
-
|
|
1532
|
+
if (typing) void props.onTypingChange?.(value.trim().length > 0);
|
|
1533
|
+
};
|
|
1534
|
+
const enterEdit = (message) => {
|
|
1535
|
+
if (stash === void 0) stash = draft();
|
|
1536
|
+
setDraft(message.text ?? "", false);
|
|
1537
|
+
};
|
|
1538
|
+
const leaveEdit = (message, restore) => {
|
|
1539
|
+
if (stash === void 0) return;
|
|
1540
|
+
const saved = stash;
|
|
1541
|
+
stash = void 0;
|
|
1542
|
+
const current = draft();
|
|
1543
|
+
if (restore === "always" || current.trim() === "" || current === (message.text ?? "")) setDraft(saved);
|
|
1205
1544
|
};
|
|
1545
|
+
watch3(() => props.editingMessage, (next, previous) => {
|
|
1546
|
+
if (next && (!previous || previous.id !== next.id)) enterEdit(next);
|
|
1547
|
+
else if (!next && previous && !saving) leaveEdit(previous, "unchanged");
|
|
1548
|
+
}, { immediate: true });
|
|
1549
|
+
const cancelEdit = () => {
|
|
1550
|
+
const editing = props.editingMessage;
|
|
1551
|
+
if (!editing) return;
|
|
1552
|
+
leaveEdit(editing, "always");
|
|
1553
|
+
props.onCancelEdit?.();
|
|
1554
|
+
};
|
|
1555
|
+
const canSave = (editing) => draft().trim().length > 0 || editing.media.length > 0;
|
|
1206
1556
|
const submit = async () => {
|
|
1557
|
+
const editing = props.editingMessage;
|
|
1558
|
+
if (editing) {
|
|
1559
|
+
const text2 = draft().trim();
|
|
1560
|
+
if (!canSave(editing) || props.isSending || submitting.value) return;
|
|
1561
|
+
submitting.value = true;
|
|
1562
|
+
saving = true;
|
|
1563
|
+
try {
|
|
1564
|
+
const saved = await props.onSaveEdit?.(editing, text2);
|
|
1565
|
+
if (saved === false) {
|
|
1566
|
+
if (props.editingMessage === null) leaveEdit(editing, "unchanged");
|
|
1567
|
+
return;
|
|
1568
|
+
}
|
|
1569
|
+
leaveEdit(editing, "always");
|
|
1570
|
+
} finally {
|
|
1571
|
+
saving = false;
|
|
1572
|
+
submitting.value = false;
|
|
1573
|
+
}
|
|
1574
|
+
return;
|
|
1575
|
+
}
|
|
1207
1576
|
const originalDraft = draft();
|
|
1208
1577
|
const text = originalDraft.trim();
|
|
1209
1578
|
if (!text || props.isSending || submitting.value) return;
|
|
@@ -1272,6 +1641,7 @@ var ConversationView = defineComponent3({
|
|
|
1272
1641
|
}, typingLabel(props.typingUserIds, nameForUser));
|
|
1273
1642
|
};
|
|
1274
1643
|
const renderComposer = () => {
|
|
1644
|
+
const editing = props.editingMessage;
|
|
1275
1645
|
const slotProps = {
|
|
1276
1646
|
value: draft(),
|
|
1277
1647
|
setValue: setDraft,
|
|
@@ -1279,16 +1649,27 @@ var ConversationView = defineComponent3({
|
|
|
1279
1649
|
send: () => {
|
|
1280
1650
|
void submit();
|
|
1281
1651
|
},
|
|
1282
|
-
...props.onAddAttachment ? { addAttachment } : {}
|
|
1652
|
+
...props.onAddAttachment ? { addAttachment } : {},
|
|
1653
|
+
...editing ? { editing, cancelEdit } : {}
|
|
1283
1654
|
};
|
|
1655
|
+
const busy = props.isSending || submitting.value;
|
|
1284
1656
|
return slots.composer?.(slotProps) ?? h3("form", {
|
|
1285
|
-
class: partClass("composer", appearance(), "ckui-composer"),
|
|
1657
|
+
class: cx(partClass("composer", appearance(), "ckui-composer"), editing && !props.unstyled && "ckui-composer--editing"),
|
|
1286
1658
|
style: partStyle("composer", appearance()),
|
|
1287
1659
|
onSubmit: (event) => {
|
|
1288
1660
|
event.preventDefault();
|
|
1289
1661
|
void submit();
|
|
1290
1662
|
}
|
|
1291
1663
|
}, [
|
|
1664
|
+
// Spread, not null: outside edit mode the composer markup stays byte-identical to 0.7.
|
|
1665
|
+
...editing ? [h3("div", { class: "ckui-composer__editing", role: "status" }, [
|
|
1666
|
+
h3(Pencil2, { size: 14, "aria-hidden": "true" }),
|
|
1667
|
+
h3("span", { class: "ckui-composer__editing-body" }, [
|
|
1668
|
+
h3("strong", "Editing message"),
|
|
1669
|
+
h3("span", editingSummary(editing))
|
|
1670
|
+
]),
|
|
1671
|
+
h3("button", { type: "button", class: "ckui-link-button", "aria-label": "Cancel editing", onClick: cancelEdit }, "Cancel")
|
|
1672
|
+
])] : [],
|
|
1292
1673
|
props.onAddAttachment ? h3("button", {
|
|
1293
1674
|
type: "button",
|
|
1294
1675
|
"aria-label": "Add attachment",
|
|
@@ -1317,15 +1698,19 @@ var ConversationView = defineComponent3({
|
|
|
1317
1698
|
event.preventDefault();
|
|
1318
1699
|
void submit();
|
|
1319
1700
|
}
|
|
1701
|
+
if (event.key === "Escape" && props.editingMessage) {
|
|
1702
|
+
event.preventDefault();
|
|
1703
|
+
cancelEdit();
|
|
1704
|
+
}
|
|
1320
1705
|
}
|
|
1321
1706
|
}),
|
|
1322
1707
|
h3("button", {
|
|
1323
1708
|
type: "submit",
|
|
1324
|
-
"aria-label": "Send message",
|
|
1325
|
-
disabled: !draft().trim() ||
|
|
1709
|
+
"aria-label": editing ? "Save message" : "Send message",
|
|
1710
|
+
disabled: (editing ? !canSave(editing) : !draft().trim()) || busy,
|
|
1326
1711
|
class: partClass("button", appearance(), "ckui-send-button"),
|
|
1327
1712
|
style: partStyle("button", appearance())
|
|
1328
|
-
}, [
|
|
1713
|
+
}, [busy ? h3(LoaderCircle2, { class: "ckui-spin", size: 18, "aria-hidden": "true" }) : h3(editing ? Check2 : Send, { size: 18, "aria-hidden": "true" })])
|
|
1329
1714
|
]);
|
|
1330
1715
|
};
|
|
1331
1716
|
return () => {
|
|
@@ -1374,6 +1759,12 @@ var ConversationView = defineComponent3({
|
|
|
1374
1759
|
...props.onAttachmentClick ? { onAttachmentClick: (media, message) => {
|
|
1375
1760
|
props.onAttachmentClick?.(media, message);
|
|
1376
1761
|
} } : {},
|
|
1762
|
+
...props.onEditMessage ? { onEditMessage: (message) => {
|
|
1763
|
+
props.onEditMessage?.(message);
|
|
1764
|
+
} } : {},
|
|
1765
|
+
...props.onDeleteMessage ? { onDeleteMessage: (message) => props.onDeleteMessage?.(message) } : {},
|
|
1766
|
+
...props.canEditMessage ? { canEditMessage: props.canEditMessage } : {},
|
|
1767
|
+
...props.confirmDelete ? { confirmDelete: props.confirmDelete } : {},
|
|
1377
1768
|
reverse: props.reverseMessages,
|
|
1378
1769
|
stickToBottom: props.stickToBottom,
|
|
1379
1770
|
paginationThreshold: props.paginationThreshold,
|
|
@@ -1404,6 +1795,11 @@ var Conversation = defineComponent3({
|
|
|
1404
1795
|
messages: { type: Array, default: () => [] },
|
|
1405
1796
|
currentUserId: { type: String, default: "" },
|
|
1406
1797
|
onSendMessage: { type: Function, default: void 0 },
|
|
1798
|
+
editingMessage: { type: Object, default: void 0 },
|
|
1799
|
+
onEditMessage: { type: Function, default: void 0 },
|
|
1800
|
+
onSaveEdit: { type: Function, default: void 0 },
|
|
1801
|
+
onCancelEdit: { type: Function, default: void 0 },
|
|
1802
|
+
onDeleteMessage: { type: Function, default: void 0 },
|
|
1407
1803
|
client: { type: Object, required: true },
|
|
1408
1804
|
conversationId: { type: String, required: true },
|
|
1409
1805
|
messagePageSize: { type: Number, default: 30 },
|
|
@@ -1413,7 +1809,21 @@ var Conversation = defineComponent3({
|
|
|
1413
1809
|
autoLoad: { type: Boolean, default: true },
|
|
1414
1810
|
onControllerChange: { type: Function, default: void 0 }
|
|
1415
1811
|
},
|
|
1416
|
-
emits: [
|
|
1812
|
+
emits: [
|
|
1813
|
+
"controller-change",
|
|
1814
|
+
"send-message",
|
|
1815
|
+
"typing-change",
|
|
1816
|
+
"back",
|
|
1817
|
+
"refresh",
|
|
1818
|
+
"load-older",
|
|
1819
|
+
"add-attachment",
|
|
1820
|
+
"attachment-click",
|
|
1821
|
+
"update:modelValue",
|
|
1822
|
+
"edit-message",
|
|
1823
|
+
"save-edit",
|
|
1824
|
+
"cancel-edit",
|
|
1825
|
+
"delete-message"
|
|
1826
|
+
],
|
|
1417
1827
|
setup(props, { attrs, emit, expose, slots }) {
|
|
1418
1828
|
const controller = useConversation({
|
|
1419
1829
|
client: () => props.client,
|
|
@@ -1477,6 +1887,11 @@ var Conversation = defineComponent3({
|
|
|
1477
1887
|
isSending: _isSending,
|
|
1478
1888
|
hasOlderMessages: _hasOlderMessages,
|
|
1479
1889
|
error: _error,
|
|
1890
|
+
editingMessage: _editingMessage,
|
|
1891
|
+
onEditMessage: _onEditMessage,
|
|
1892
|
+
onSaveEdit: _onSaveEdit,
|
|
1893
|
+
onCancelEdit: _onCancelEdit,
|
|
1894
|
+
onDeleteMessage: _onDeleteMessage,
|
|
1480
1895
|
...forwarded
|
|
1481
1896
|
} = props;
|
|
1482
1897
|
return h3(ConversationView, {
|
|
@@ -1500,6 +1915,28 @@ var Conversation = defineComponent3({
|
|
|
1500
1915
|
isSending: controller.isSending.value,
|
|
1501
1916
|
hasOlderMessages: controller.hasOlderMessages.value,
|
|
1502
1917
|
...controller.error.value == null ? {} : { error: controller.error.value },
|
|
1918
|
+
// Edit mode is the store's; the actions render only while the adapter supports them (0.8.0).
|
|
1919
|
+
editingMessage: controller.editingMessage.value,
|
|
1920
|
+
...controller.canEditMessages.value ? {
|
|
1921
|
+
onEditMessage: (message) => {
|
|
1922
|
+
emit("edit-message", message);
|
|
1923
|
+
controller.startEditing(message.id);
|
|
1924
|
+
},
|
|
1925
|
+
onSaveEdit: async (message, text) => {
|
|
1926
|
+
emit("save-edit", message, text);
|
|
1927
|
+
return controller.saveEdit(text);
|
|
1928
|
+
},
|
|
1929
|
+
onCancelEdit: () => {
|
|
1930
|
+
emit("cancel-edit");
|
|
1931
|
+
controller.cancelEditing();
|
|
1932
|
+
}
|
|
1933
|
+
} : {},
|
|
1934
|
+
...controller.canDeleteMessages.value ? {
|
|
1935
|
+
onDeleteMessage: async (message) => {
|
|
1936
|
+
emit("delete-message", message);
|
|
1937
|
+
return controller.deleteMessage(message.id);
|
|
1938
|
+
}
|
|
1939
|
+
} : {},
|
|
1503
1940
|
"onUpdate:modelValue": (value) => emit("update:modelValue", value),
|
|
1504
1941
|
...props.onBack ? { onBack: () => {
|
|
1505
1942
|
props.onBack?.();
|
|
@@ -1528,7 +1965,7 @@ import {
|
|
|
1528
1965
|
} from "vue";
|
|
1529
1966
|
|
|
1530
1967
|
// src/composables/use-conversation-list.ts
|
|
1531
|
-
import { computed as computed3, getCurrentScope as getCurrentScope2, onScopeDispose as onScopeDispose2, shallowRef as shallowRef2, toValue as toValue2, watch as
|
|
1968
|
+
import { computed as computed3, getCurrentScope as getCurrentScope2, onScopeDispose as onScopeDispose2, shallowRef as shallowRef2, toValue as toValue2, watch as watch4 } from "vue";
|
|
1532
1969
|
|
|
1533
1970
|
// src/conversation-list-store.ts
|
|
1534
1971
|
var defaultActivityRefreshWindowMs = 500;
|
|
@@ -1918,6 +2355,66 @@ var ConversationListStore = class {
|
|
|
1918
2355
|
else if (!this.state.conversations.length && this.state.hasMore) await this.loadMore();
|
|
1919
2356
|
};
|
|
1920
2357
|
setQuery = (query) => this.setFilter({ ...this.state.filter, query });
|
|
2358
|
+
/** Mark a room unread for the viewer only; the row's summary takes the response (D10). Rejects when the adapter
|
|
2359
|
+
* lacks `markConversationUnread` or the store is not active; a request failure is reported through `error`
|
|
2360
|
+
* without evicting rows and rejects.
|
|
2361
|
+
*/
|
|
2362
|
+
markUnread = async (conversationId) => {
|
|
2363
|
+
const client = this.options.client;
|
|
2364
|
+
if (typeof client.markConversationUnread !== "function") {
|
|
2365
|
+
throw new TypeError("markUnread requires a ConvoKitUiClient adapter with markConversationUnread (core SDK 0.7)");
|
|
2366
|
+
}
|
|
2367
|
+
this.assertActive();
|
|
2368
|
+
this.applyPrivateState(conversationId, await this.mutate(client.markConversationUnread(conversationId)));
|
|
2369
|
+
};
|
|
2370
|
+
/** Remove the viewer's marker (conditionally on `options.ifVersion`); resolves to the response's `cleared` ("this
|
|
2371
|
+
* request removed the marker", not "the room is read") and patches the summary on true and false alike (D10).
|
|
2372
|
+
* Rejects when the adapter lacks `clearConversationUnread` or the store is not active; failures are reported like
|
|
2373
|
+
* `markUnread`.
|
|
2374
|
+
*/
|
|
2375
|
+
clearUnread = async (conversationId, options) => {
|
|
2376
|
+
const client = this.options.client;
|
|
2377
|
+
if (typeof client.clearConversationUnread !== "function") {
|
|
2378
|
+
throw new TypeError("clearUnread requires a ConvoKitUiClient adapter with clearConversationUnread (core SDK 0.7)");
|
|
2379
|
+
}
|
|
2380
|
+
this.assertActive();
|
|
2381
|
+
const result = await this.mutate(client.clearConversationUnread(conversationId, options));
|
|
2382
|
+
this.applyPrivateState(conversationId, result);
|
|
2383
|
+
return result.cleared;
|
|
2384
|
+
};
|
|
2385
|
+
/** A disposed or session-evicted store never sends a private-state mutation: on a shared client it could go out
|
|
2386
|
+
* under a replacement login. Rejected without touching `error` (there is no live snapshot to report into).
|
|
2387
|
+
*/
|
|
2388
|
+
assertActive() {
|
|
2389
|
+
if (!this.alive()) throw new Error("ConversationListStore is not active");
|
|
2390
|
+
}
|
|
2391
|
+
async mutate(request) {
|
|
2392
|
+
try {
|
|
2393
|
+
return await request;
|
|
2394
|
+
} catch (cause) {
|
|
2395
|
+
if (this.alive()) this.patch({ error: cause });
|
|
2396
|
+
throw cause;
|
|
2397
|
+
}
|
|
2398
|
+
}
|
|
2399
|
+
/** Apply a mark/clear response to the row's CURRENT summary (a refresh may have swapped it) as one unit, only while
|
|
2400
|
+
* the store is alive and the response is not older than the stored version: a delayed response never resurrects a
|
|
2401
|
+
* marker a newer action removed (equal versions are an idempotent no-op). `isUnread` is recomputed from the stored
|
|
2402
|
+
* counts and the response marker. Other devices learn of the change through `inbox_activity`.
|
|
2403
|
+
*/
|
|
2404
|
+
applyPrivateState(conversationId, state) {
|
|
2405
|
+
if (!this.alive()) return;
|
|
2406
|
+
const current = this.entries.find((entry) => entry.conversation.id === conversationId);
|
|
2407
|
+
if (!current || state.privateStateVersion < current.privateStateVersion) return;
|
|
2408
|
+
const { unreadMarkedAt, privateStateVersion } = state;
|
|
2409
|
+
const patched = {
|
|
2410
|
+
...current,
|
|
2411
|
+
unreadMarkedAt,
|
|
2412
|
+
privateStateVersion,
|
|
2413
|
+
isUnread: current.unreadCount > 0 || current.unreadCountCapped || unreadMarkedAt !== null
|
|
2414
|
+
};
|
|
2415
|
+
this.entries = this.entries.map((entry) => entry === current ? patched : entry);
|
|
2416
|
+
this.patch({ summaries: new Map(this.entries.map((entry) => [entry.conversation.id, summaryOf(entry)])) });
|
|
2417
|
+
}
|
|
1921
2418
|
};
|
|
1922
2419
|
|
|
1923
2420
|
// src/composables/use-conversation-list.ts
|
|
@@ -1926,7 +2423,7 @@ function useConversationList(options) {
|
|
|
1926
2423
|
let store = createStore();
|
|
1927
2424
|
const snapshot = shallowRef2(store.getSnapshot());
|
|
1928
2425
|
let unsubscribe;
|
|
1929
|
-
const stop =
|
|
2426
|
+
const stop = watch4(
|
|
1930
2427
|
() => [toValue2(options.client), toValue2(options.client).sessionIdentity],
|
|
1931
2428
|
() => {
|
|
1932
2429
|
store.dispose();
|
|
@@ -1965,6 +2462,8 @@ function useConversationList(options) {
|
|
|
1965
2462
|
loadMore: () => store.loadMore(),
|
|
1966
2463
|
setFilter: (filter) => store.setFilter(filter),
|
|
1967
2464
|
setQuery: (query) => store.setQuery(query),
|
|
2465
|
+
markUnread: (conversationId) => store.markUnread(conversationId),
|
|
2466
|
+
clearUnread: (conversationId, options2) => store.clearUnread(conversationId, options2),
|
|
1968
2467
|
dispose
|
|
1969
2468
|
};
|
|
1970
2469
|
}
|
|
@@ -2041,13 +2540,16 @@ var ConversationListView = defineComponent4({
|
|
|
2041
2540
|
return void 0;
|
|
2042
2541
|
};
|
|
2043
2542
|
const unreadBadge = (summary) => {
|
|
2044
|
-
if (summary.unreadCount
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2543
|
+
if (summary.unreadCount > 0 || summary.unreadCountCapped) {
|
|
2544
|
+
const capped = summary.unreadCountCapped || summary.unreadCount > 99;
|
|
2545
|
+
return [h4("span", {
|
|
2546
|
+
class: "ckui-unread-badge",
|
|
2547
|
+
role: "img",
|
|
2548
|
+
"aria-label": `${summary.unreadCountCapped ? "99+" : summary.unreadCount} unread`
|
|
2549
|
+
}, [h4("span", { "aria-hidden": "true" }, capped ? "99+" : String(summary.unreadCount))])];
|
|
2550
|
+
}
|
|
2551
|
+
if (summary.isUnread) return [h4("span", { class: "ckui-unread-badge ckui-unread-badge--dot", role: "img", "aria-label": "Unread" })];
|
|
2552
|
+
return [];
|
|
2051
2553
|
};
|
|
2052
2554
|
const renderContent = () => {
|
|
2053
2555
|
const currentAppearance = appearance();
|
|
@@ -2090,7 +2592,7 @@ var ConversationListView = defineComponent4({
|
|
|
2090
2592
|
...props.currentUserId === void 0 ? {} : { currentUserId: props.currentUserId }
|
|
2091
2593
|
};
|
|
2092
2594
|
const preview = inboxPreview(conversation, summary, props.currentUserId);
|
|
2093
|
-
const unread = summary !== void 0 && (summary.unreadCount > 0 || summary.unreadCountCapped);
|
|
2595
|
+
const unread = summary !== void 0 && (summary.isUnread || summary.unreadCount > 0 || summary.unreadCountCapped);
|
|
2094
2596
|
const item = slots["conversation-item"]?.(slotProps) ?? h4("button", {
|
|
2095
2597
|
type: "button",
|
|
2096
2598
|
"data-selected": selected || void 0,
|
|
@@ -2114,7 +2616,7 @@ var ConversationListView = defineComponent4({
|
|
|
2114
2616
|
// summary must keep 0.5's exact markup.
|
|
2115
2617
|
...summary ? [h4("span", { class: "ckui-conversation-item__meta" }, [
|
|
2116
2618
|
h4("time", { class: "ckui-conversation-item__time", datetime: summary.activityAt.toISOString() }, formatMessageTime(summary.activityAt)),
|
|
2117
|
-
unreadBadge(summary)
|
|
2619
|
+
...unreadBadge(summary)
|
|
2118
2620
|
])] : [],
|
|
2119
2621
|
h4(ChevronRight, { size: 18, "aria-hidden": "true" })
|
|
2120
2622
|
]);
|