@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 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 = mergeMessages(messages.value, [message]);
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
- if (!disposed && activeGeneration === generation) messages.value = mergeMessages(messages.value, [message]);
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
- if (!disposed) error.value = cause;
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 readerIds = props.readersResolver ? props.readersResolver(message) : readerIdsFor(message, props.readAtByUserId);
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)("time", { class: "ckui-message-time", datetime: message.createdAt.toISOString() }, [
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 text = draft().trim();
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 !== false) setDraft("");
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,