@convokitapp/vue-ui 0.2.0 → 0.2.2
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 +11 -0
- package/PARITY.md +1 -0
- package/README.md +8 -0
- package/dist/index.cjs +56 -12
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +3 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.js +55 -12
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.2
|
|
4
|
+
|
|
5
|
+
- Show `Sending…` for optimistic messages instead of a device-clock timestamp that can jump after acknowledgement.
|
|
6
|
+
- Keep pending messages after canonical history and exclude them from read receipts.
|
|
7
|
+
- Continue displaying acknowledged server timestamps in the viewer's local timezone.
|
|
8
|
+
|
|
9
|
+
## 0.2.1
|
|
10
|
+
|
|
11
|
+
- Show outgoing messages immediately while the request completes, then reconcile the server response and realtime echo without duplicates.
|
|
12
|
+
- Clear the composer immediately and restore its draft after a failed send when the user has not typed a replacement.
|
|
13
|
+
|
|
3
14
|
## 0.2.0
|
|
4
15
|
|
|
5
16
|
- Require ConvoKit JavaScript SDK 0.2.x for private Realtime channels and publishable-key discovery.
|
package/PARITY.md
CHANGED
|
@@ -16,6 +16,7 @@ while using Vue-native composition patterns.
|
|
|
16
16
|
| Offset pagination and de-duplication | Yes | Yes | Yes |
|
|
17
17
|
| Search, archived, participant, predicate, sort filters | Yes | Yes | Yes |
|
|
18
18
|
| Realtime messages, typing, reads | Yes | Yes | Yes |
|
|
19
|
+
| Pending state without provisional timestamps or receipts | Yes | Yes | Yes |
|
|
19
20
|
| Read markers and reader resolution | Yes | Yes | Yes |
|
|
20
21
|
| Mark read on load/receive | Yes | Yes | Yes |
|
|
21
22
|
| Typing idle timeout | Yes | Yes | Yes |
|
package/README.md
CHANGED
|
@@ -54,6 +54,10 @@ Vite environment variables, or a shipped browser bundle.
|
|
|
54
54
|
The core SDK uses ConvoKit's managed `https://api.convokit.app` endpoint. Set
|
|
55
55
|
`backendUrl` only for local testing or a self-hosted deployment.
|
|
56
56
|
|
|
57
|
+
SDK-backed conversations render an outgoing message immediately, reconcile it
|
|
58
|
+
with the server response and realtime echo, and restore an unchanged draft if
|
|
59
|
+
the send fails.
|
|
60
|
+
|
|
57
61
|
## Controlled components and slots
|
|
58
62
|
|
|
59
63
|
`ConversationListView`, `MessageListView`, and `ConversationView` accept host
|
|
@@ -83,6 +87,10 @@ Use `useConversationList` and `useConversation` when you want ConvoKit's
|
|
|
83
87
|
pagination, de-duplication, realtime, typing, and read state without the
|
|
84
88
|
default UI. Both return readonly Vue refs plus actions and a `dispose()` method.
|
|
85
89
|
|
|
90
|
+
Optimistic rows display `Sending…` until acknowledgement. The final server
|
|
91
|
+
timestamp is formatted in the viewer's local timezone. Custom message slots can
|
|
92
|
+
use `isConvoKitPendingMessage(message)` to present the same state.
|
|
93
|
+
|
|
86
94
|
## Appearance
|
|
87
95
|
|
|
88
96
|
Wrap any subtree with `ConvoKitThemeProvider`, set `density="compact"`, or use
|
package/dist/index.cjs
CHANGED
|
@@ -32,6 +32,7 @@ __export(index_exports, {
|
|
|
32
32
|
defaultConvoKitTheme: () => defaultConvoKitTheme,
|
|
33
33
|
defaultReadersResolver: () => defaultReadersResolver,
|
|
34
34
|
formatFileSize: () => formatFileSize,
|
|
35
|
+
isConvoKitPendingMessage: () => isConvoKitPendingMessage,
|
|
35
36
|
matchesConversation: () => matchesConversation,
|
|
36
37
|
mergeConversations: () => mergeConversations,
|
|
37
38
|
mergeMessages: () => mergeMessages,
|
|
@@ -75,6 +76,10 @@ var import_vue = require("vue");
|
|
|
75
76
|
|
|
76
77
|
// src/utils.ts
|
|
77
78
|
var import_clsx = require("clsx");
|
|
79
|
+
var pendingMessageIdPrefix = "convokit-pending-";
|
|
80
|
+
function isConvoKitPendingMessage(message) {
|
|
81
|
+
return message.id.startsWith(pendingMessageIdPrefix);
|
|
82
|
+
}
|
|
78
83
|
function cx(...values) {
|
|
79
84
|
return (0, import_clsx.clsx)(values);
|
|
80
85
|
}
|
|
@@ -117,11 +122,15 @@ function mergeMessages(current, incoming) {
|
|
|
117
122
|
const byId = new Map(current.map((message) => [message.id, message]));
|
|
118
123
|
for (const message of incoming) byId.set(message.id, message);
|
|
119
124
|
return [...byId.values()].sort((left, right) => {
|
|
125
|
+
const leftPending = isConvoKitPendingMessage(left);
|
|
126
|
+
const rightPending = isConvoKitPendingMessage(right);
|
|
127
|
+
if (leftPending !== rightPending) return leftPending ? 1 : -1;
|
|
120
128
|
const byTime = left.createdAt.getTime() - right.createdAt.getTime();
|
|
121
129
|
return byTime === 0 ? left.id.localeCompare(right.id) : byTime;
|
|
122
130
|
});
|
|
123
131
|
}
|
|
124
132
|
function readerIdsFor(message, readAtByUserId) {
|
|
133
|
+
if (isConvoKitPendingMessage(message)) return /* @__PURE__ */ new Set();
|
|
125
134
|
return new Set([...readAtByUserId.entries()].filter(([userId, readAt]) => userId !== message.senderId && readAt.getTime() >= message.createdAt.getTime()).map(([userId]) => userId));
|
|
126
135
|
}
|
|
127
136
|
function partClass(part, appearance, defaultClass) {
|
|
@@ -196,6 +205,8 @@ function useConversation(options) {
|
|
|
196
205
|
let sentTyping = false;
|
|
197
206
|
let typingTimer = null;
|
|
198
207
|
let subscriptions = [];
|
|
208
|
+
const pendingIds = /* @__PURE__ */ new Set();
|
|
209
|
+
let pendingSequence = 0;
|
|
199
210
|
const unsubscribe = async () => {
|
|
200
211
|
const active = subscriptions;
|
|
201
212
|
subscriptions = [];
|
|
@@ -219,7 +230,12 @@ function useConversation(options) {
|
|
|
219
230
|
subscriptions = [
|
|
220
231
|
client.onMessage(conversationId, (message) => {
|
|
221
232
|
if (disposed || activeGeneration !== generation) return;
|
|
222
|
-
messages.value
|
|
233
|
+
const pending = messages.value.find((candidate) => pendingIds.has(candidate.id) && message.senderId === client.currentUserId && candidate.text === message.text && candidate.media.length === message.media.length);
|
|
234
|
+
if (pending) pendingIds.delete(pending.id);
|
|
235
|
+
messages.value = mergeMessages(
|
|
236
|
+
pending ? messages.value.filter((candidate) => candidate.id !== pending.id) : messages.value,
|
|
237
|
+
[message]
|
|
238
|
+
);
|
|
223
239
|
if ((options.markReadOnReceive ?? true) && message.senderId !== client.currentUserId) void markRead();
|
|
224
240
|
}, report),
|
|
225
241
|
client.onReadReceipt(conversationId, ({ userId, readAt }) => {
|
|
@@ -243,6 +259,7 @@ function useConversation(options) {
|
|
|
243
259
|
await unsubscribe();
|
|
244
260
|
if (disposed || activeGeneration !== generation) return;
|
|
245
261
|
messages.value = [];
|
|
262
|
+
pendingIds.clear();
|
|
246
263
|
conversation.value = null;
|
|
247
264
|
typingUserIds.value = /* @__PURE__ */ new Set();
|
|
248
265
|
readAtByUserId.value = /* @__PURE__ */ new Map();
|
|
@@ -282,7 +299,7 @@ function useConversation(options) {
|
|
|
282
299
|
const page = await client.getMessages({
|
|
283
300
|
conversationId,
|
|
284
301
|
limit: messagePageSize,
|
|
285
|
-
offset: messages.value.length
|
|
302
|
+
offset: messages.value.filter((message) => !pendingIds.has(message.id)).length
|
|
286
303
|
});
|
|
287
304
|
if (disposed || activeGeneration !== generation) return;
|
|
288
305
|
messages.value = mergeMessages(messages.value, page);
|
|
@@ -313,6 +330,18 @@ function useConversation(options) {
|
|
|
313
330
|
const normalizedText = text?.trim();
|
|
314
331
|
if (!normalizedText && (!media || media.length === 0)) return null;
|
|
315
332
|
if (isSending.value) return null;
|
|
333
|
+
const pendingId = `convokit-pending-${Date.now()}-${++pendingSequence}`;
|
|
334
|
+
const pendingMessage = {
|
|
335
|
+
id: pendingId,
|
|
336
|
+
conversationId: (0, import_vue2.toValue)(options.conversationId),
|
|
337
|
+
senderId: (0, import_vue2.toValue)(options.client).currentUserId,
|
|
338
|
+
text: normalizedText ?? null,
|
|
339
|
+
media: media ?? [],
|
|
340
|
+
createdAt: /* @__PURE__ */ new Date(),
|
|
341
|
+
updatedAt: null
|
|
342
|
+
};
|
|
343
|
+
pendingIds.add(pendingId);
|
|
344
|
+
messages.value = mergeMessages(messages.value, [pendingMessage]);
|
|
316
345
|
isSending.value = true;
|
|
317
346
|
error.value = null;
|
|
318
347
|
const activeGeneration = generation;
|
|
@@ -322,11 +351,21 @@ function useConversation(options) {
|
|
|
322
351
|
...normalizedText ? { text: normalizedText } : {},
|
|
323
352
|
...media?.length ? { media } : {}
|
|
324
353
|
});
|
|
325
|
-
|
|
354
|
+
pendingIds.delete(pendingId);
|
|
355
|
+
if (!disposed && activeGeneration === generation) {
|
|
356
|
+
messages.value = mergeMessages(
|
|
357
|
+
messages.value.filter((candidate) => candidate.id !== pendingId),
|
|
358
|
+
[message]
|
|
359
|
+
);
|
|
360
|
+
}
|
|
326
361
|
await updateTyping(false);
|
|
327
362
|
return message;
|
|
328
363
|
} catch (cause) {
|
|
329
|
-
|
|
364
|
+
pendingIds.delete(pendingId);
|
|
365
|
+
if (!disposed) {
|
|
366
|
+
messages.value = messages.value.filter((candidate) => candidate.id !== pendingId);
|
|
367
|
+
error.value = cause;
|
|
368
|
+
}
|
|
330
369
|
return null;
|
|
331
370
|
} finally {
|
|
332
371
|
if (!disposed && activeGeneration === generation) isSending.value = false;
|
|
@@ -490,7 +529,8 @@ var MessageListView = (0, import_vue4.defineComponent)({
|
|
|
490
529
|
const renderMessage = (message, index) => {
|
|
491
530
|
const isCurrentUser = message.senderId === props.currentUserId;
|
|
492
531
|
const sender = participants.value.get(message.senderId);
|
|
493
|
-
const
|
|
532
|
+
const isPending = isConvoKitPendingMessage(message);
|
|
533
|
+
const readerIds = isPending ? /* @__PURE__ */ new Set() : props.readersResolver ? props.readersResolver(message) : readerIdsFor(message, props.readAtByUserId);
|
|
494
534
|
const slotProps = { message, chronologicalIndex: index, isCurrentUser, sender, readerIds };
|
|
495
535
|
const custom = slots.message?.(slotProps);
|
|
496
536
|
if (custom) return (0, import_vue4.h)("div", { key: message.id, role: "listitem" }, custom);
|
|
@@ -523,12 +563,12 @@ var MessageListView = (0, import_vue4.defineComponent)({
|
|
|
523
563
|
!isCurrentUser ? (0, import_vue4.h)("strong", { class: "ckui-message-sender" }, sender?.name || message.senderId) : null,
|
|
524
564
|
message.text ? (0, import_vue4.h)("div", { class: "ckui-message-text" }, message.text) : null,
|
|
525
565
|
...mediaNodes,
|
|
526
|
-
(0, import_vue4.h)("
|
|
527
|
-
props.formatTime(message.createdAt),
|
|
528
|
-
isCurrentUser ? readerIds.size > 0 ? (0, import_vue4.h)(import_vue3.CheckCheck, { size: 14, "aria-label": "Read" }) : (0, import_vue4.h)(import_vue3.Check, { size: 14, "aria-label": "Delivered" }) : null
|
|
566
|
+
(0, import_vue4.h)("span", { class: "ckui-message-time" }, [
|
|
567
|
+
isPending ? "Sending\u2026" : props.formatTime(message.createdAt),
|
|
568
|
+
isCurrentUser && !isPending ? readerIds.size > 0 ? (0, import_vue4.h)(import_vue3.CheckCheck, { size: 14, "aria-label": "Read" }) : (0, import_vue4.h)(import_vue3.Check, { size: 14, "aria-label": "Delivered" }) : null
|
|
529
569
|
])
|
|
530
570
|
]),
|
|
531
|
-
isCurrentUser ? slots["read-receipt"]?.(receiptSlotProps) ?? (0, import_vue4.h)("div", {
|
|
571
|
+
isCurrentUser && !isPending ? slots["read-receipt"]?.(receiptSlotProps) ?? (0, import_vue4.h)("div", {
|
|
532
572
|
class: partClass("receipt", currentAppearance, "ckui-read-receipt"),
|
|
533
573
|
style: partStyle("receipt", currentAppearance)
|
|
534
574
|
}, readerIds.size > 0 ? `Read by ${readerIds.size}` : "Delivered") : null
|
|
@@ -665,7 +705,9 @@ var ConversationView = (0, import_vue6.defineComponent)({
|
|
|
665
705
|
...props.styles ? { styles: props.styles } : {}
|
|
666
706
|
});
|
|
667
707
|
const draft = () => props.modelValue ?? internalDraft.value;
|
|
708
|
+
let latestDraft = draft();
|
|
668
709
|
const setDraft = (value) => {
|
|
710
|
+
latestDraft = value;
|
|
669
711
|
if (props.modelValue === void 0) internalDraft.value = value;
|
|
670
712
|
props.onDraftChange?.(value);
|
|
671
713
|
emit("update:modelValue", value);
|
|
@@ -673,12 +715,14 @@ var ConversationView = (0, import_vue6.defineComponent)({
|
|
|
673
715
|
void props.onTypingChange?.(isTyping);
|
|
674
716
|
};
|
|
675
717
|
const submit = async () => {
|
|
676
|
-
const
|
|
718
|
+
const originalDraft = draft();
|
|
719
|
+
const text = originalDraft.trim();
|
|
677
720
|
if (!text || props.isSending || submitting.value) return;
|
|
678
721
|
submitting.value = true;
|
|
722
|
+
setDraft("");
|
|
679
723
|
try {
|
|
680
724
|
const shouldClear = await props.onSendMessage(text);
|
|
681
|
-
if (shouldClear
|
|
725
|
+
if (shouldClear === false && latestDraft.length === 0) setDraft(originalDraft);
|
|
682
726
|
} finally {
|
|
683
727
|
submitting.value = false;
|
|
684
728
|
}
|
|
@@ -771,7 +815,6 @@ var ConversationView = (0, import_vue6.defineComponent)({
|
|
|
771
815
|
class: cx(!props.unstyled && "ckui-composer__input", props.classNames?.input, props.composerProps?.class),
|
|
772
816
|
style: [props.styles?.input, props.composerProps?.style],
|
|
773
817
|
value: draft(),
|
|
774
|
-
disabled: props.isSending || submitting.value,
|
|
775
818
|
onInput: (event) => {
|
|
776
819
|
const handler = props.composerProps?.onInput;
|
|
777
820
|
if (typeof handler === "function") handler(event);
|
|
@@ -1394,6 +1437,7 @@ function useConvoKitTheme() {
|
|
|
1394
1437
|
defaultConvoKitTheme,
|
|
1395
1438
|
defaultReadersResolver,
|
|
1396
1439
|
formatFileSize,
|
|
1440
|
+
isConvoKitPendingMessage,
|
|
1397
1441
|
matchesConversation,
|
|
1398
1442
|
mergeConversations,
|
|
1399
1443
|
mergeMessages,
|