@convokitapp/vue-ui 0.3.0 → 0.4.1

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,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.1
4
+
5
+ - Describe a confirmed outgoing message without readers as `Sent` instead of
6
+ `Delivered` in the default receipt text and status icon label. ConvoKit does
7
+ not report recipient delivery; pending and read states are unchanged.
8
+
9
+ ## 0.4.0
10
+
11
+ - Reconcile private inbox invalidations automatically, retaining filters and
12
+ loaded pages without clearing visible data on transient failures.
13
+ - Reconcile open rooms after user/room cascade changes.
14
+ - Replace optimistic bubbles by exact client send IDs before HTTP acknowledgement;
15
+ keep confirmed sends successful after a lost response, without restoring deleted rows.
16
+ - Custom adapters must implement `onInboxChanged` (including initial/rejoin signals)
17
+ and forward `clientMessageId`. Requires core 0.4.0 and the coordinated backend.
18
+
3
19
  ## 0.3.0
4
20
 
5
21
  - Ignore late typing failures after newer input or connection interruption.
package/README.md CHANGED
@@ -11,9 +11,9 @@ React package: system typography, separated rows, subtle borders, compact
11
11
  actions, and restrained radii. It remains framework-CSS independent and does
12
12
  not require Tailwind in the host application.
13
13
 
14
- ## 0.3.0 session and history recovery
14
+ ## 0.4.0 live inbox, sending and recovery
15
15
 
16
- This release requires JavaScript SDK 0.3.x and the coordinated backend. The core
16
+ This release requires JavaScript SDK 0.4.x and the coordinated backend. The core
17
17
  SDK discovers current private room/app topics automatically and rejoins when
18
18
  membership changes; no additional customer configuration is needed.
19
19
 
@@ -46,9 +46,9 @@ membership changes; no additional customer configuration is needed.
46
46
  monotonically. A successful mark-read request does not fabricate a timestamp
47
47
  from the device clock. Remote typing expires and clears on disconnect.
48
48
  - Pending sends are bound to their original room/session. Reconciliation uses
49
- the returned message ID, never a guess based on identical text or file count.
50
- Without server-provided request IDs, a Realtime echo can briefly coexist with
51
- its pending row until the HTTP acknowledgement identifies it.
49
+ the exact clientMessageId, sender and room, never identical text or file count.
50
+ A matching live/history row replaces its pending bubble immediately, even
51
+ before HTTP acknowledgement; a later lost response cannot fail a confirmed send.
52
52
  - Custom `ConvoKitUiClient` adapters must implement `sessionIdentity`,
53
53
  `onConnectionEvent` (including `onSessionEnded`), typed `onMessage`,
54
54
  `onMessageDeleted`, `getMessage(id)`, and paired message cursors. The message
@@ -61,9 +61,17 @@ The managed API default and customer token-provider workflow are unchanged.
61
61
  The SDK discovers the Supabase URL and publishable key automatically; customers
62
62
  do not configure those values or ship a backend secret.
63
63
 
64
- The inbox still uses the existing paged REST contract. Session eviction is
65
- implemented, but live inbox/cascade invalidation requires the separate backend
66
- event contract; refresh or reopen the inbox after those changes.
64
+ SDK-backed components consume private inbox invalidations automatically,
65
+ including after reconnect, while preserving filters and the loaded page window.
66
+ It also correlates pending messages with live/history rows using the exact
67
+ `clientMessageId`, so a live-first delivery never produces two bubbles.
68
+ These changes require the paired 0.4.0 core and coordinated backend update.
69
+
70
+ For 0.4.0, custom adapters must add
71
+ `onInboxChanged(handler, onError?)`, notifying on committed changes and every
72
+ initial/reconnected subscription. Forward `clientMessageId` unchanged in sends
73
+ and parsed messages. Default SDK adapters do this for you; no component props
74
+ or demo-specific reconciliation code is required.
67
75
 
68
76
  ## Install published packages
69
77
 
package/dist/index.cjs CHANGED
@@ -50,6 +50,10 @@ function createConvoKitUiClient(client) {
50
50
  return client.connected ? client.realtime : null;
51
51
  },
52
52
  onConnectionEvent: (handlers) => client.realtime.onConnectionEvent(handlers),
53
+ onInboxChanged: (handler, onError) => client.realtime.onInboxChanged(client.clientId, {
54
+ onEvent: handler,
55
+ ...onError ? { onError } : {}
56
+ }),
53
57
  onMessageDeleted: (conversationId, handler, onError) => client.realtime.onMessageDeleted(conversationId, {
54
58
  onEvent: handler,
55
59
  ...onError ? { onError } : {}
@@ -195,6 +199,7 @@ var import_vue7 = require("vue");
195
199
  var import_vue3 = require("vue");
196
200
 
197
201
  // src/conversation-store.ts
202
+ var import_sdk = require("@convokitapp/sdk");
198
203
  function version(message) {
199
204
  return message.updatedAt?.getTime() ?? message.createdAt.getTime();
200
205
  }
@@ -261,7 +266,7 @@ var ConversationStore = class {
261
266
  // Keep tombstones until an explicit reload/session change, including across refreshes.
262
267
  deleted = /* @__PURE__ */ new Set();
263
268
  sendRevision;
264
- pendingSequence = 0;
269
+ activeSend;
265
270
  refreshQueued = false;
266
271
  typingTimers = /* @__PURE__ */ new Map();
267
272
  ownTypingTimer;
@@ -276,6 +281,10 @@ var ConversationStore = class {
276
281
  };
277
282
  };
278
283
  patch(patch) {
284
+ if (patch.messages && this.activeSend) {
285
+ for (const message of patch.messages) this.confirmSend(message);
286
+ if (this.activeSend.confirmed) patch.messages = patch.messages.filter((message) => message.id !== this.activeSend.pending.id);
287
+ }
279
288
  this.state = { ...this.state, ...patch };
280
289
  for (const listener of this.listeners) listener();
281
290
  }
@@ -319,6 +328,7 @@ var ConversationStore = class {
319
328
  this.hydrationPool.queued.clear();
320
329
  this.deleted.clear();
321
330
  this.sendRevision = void 0;
331
+ this.activeSend = void 0;
322
332
  this.refreshQueued = false;
323
333
  }
324
334
  dispose = () => {
@@ -359,6 +369,14 @@ var ConversationStore = class {
359
369
  onError: report
360
370
  }));
361
371
  if (!data) return;
372
+ add(() => this.client.onInboxChanged(() => {
373
+ if (this.alive(generation)) this.queueRefresh();
374
+ }, (cause) => {
375
+ if (this.alive(generation)) {
376
+ report(cause);
377
+ this.queueRefresh();
378
+ }
379
+ }));
362
380
  add(() => this.client.onMessage(this.room, (event) => this.onMessage(event, generation), report));
363
381
  add(() => this.client.onMessageDeleted(this.room, ({ id, conversationId }) => {
364
382
  if (!this.alive(generation) || conversationId !== this.room || !id.trim()) return;
@@ -415,9 +433,19 @@ var ConversationStore = class {
415
433
  record(message, insert, revision, complete) {
416
434
  const existing = this.state.messages.find((item) => item.id === message.id);
417
435
  if (existing && version(existing) > version(message)) return;
436
+ if (this.confirmSend(message) && !complete && !message.media.length) {
437
+ message = { ...message, media: this.activeSend.pending.media };
438
+ this.confirmSend(message);
439
+ }
418
440
  this.changes.set(message.id, { revision, message, insert, complete });
419
441
  if (existing || insert && hasContent(message)) this.patch({ messages: mergeMessages(this.state.messages, [message]) });
420
442
  }
443
+ confirmSend(message) {
444
+ const send = this.activeSend;
445
+ if (!send || !this.validMessage(message) || message.senderId !== this.user || message.clientMessageId !== send.pending.clientMessageId) return false;
446
+ send.confirmed = send.confirmed ? newest(send.confirmed, message) : message;
447
+ return true;
448
+ }
421
449
  currentHydration(job) {
422
450
  return this.alive(job.generation) && !this.deleted.has(job.message.id) && this.hydrations.get(job.message.id) === job;
423
451
  }
@@ -663,9 +691,11 @@ var ConversationStore = class {
663
691
  const generation = this.generation;
664
692
  const revision = this.revision;
665
693
  this.sendRevision = revision;
666
- const pendingId = `convokit-pending-${Date.now()}-${++this.pendingSequence}`;
694
+ const clientMessageId = (0, import_sdk.createClientMessageId)();
695
+ const pendingId = `convokit-pending-${clientMessageId}`;
667
696
  const pending = {
668
697
  id: pendingId,
698
+ clientMessageId,
669
699
  conversationId: this.room,
670
700
  senderId: this.user,
671
701
  text: normalized || null,
@@ -673,15 +703,19 @@ var ConversationStore = class {
673
703
  createdAt: /* @__PURE__ */ new Date(),
674
704
  updatedAt: null
675
705
  };
706
+ const send = { pending };
707
+ this.activeSend = send;
676
708
  this.patch({ messages: mergeMessages(this.state.messages, [pending]), isSending: true, error: null });
677
709
  try {
678
710
  const message = await this.client.sendMessage({
679
711
  conversationId: this.room,
712
+ clientMessageId,
680
713
  ...normalized ? { text: normalized } : {},
681
714
  ...media?.length ? { media } : {}
682
715
  });
683
716
  if (!this.alive(generation)) return null;
684
717
  if (!this.validMessage(message) || message.senderId !== this.user) throw new Error("Send response belongs to a different room or sender");
718
+ if (message.clientMessageId && message.clientMessageId !== clientMessageId) throw new Error("Send response belongs to a different send");
685
719
  const live = this.changes.get(message.id);
686
720
  const existing = this.state.messages.find((item) => item.id === message.id);
687
721
  let latest = existing ? newest(message, existing, live?.complete !== false) : message;
@@ -696,12 +730,17 @@ var ConversationStore = class {
696
730
  } catch (cause) {
697
731
  if (this.alive(generation)) {
698
732
  this.patch({ messages: this.state.messages.filter((item) => item.id !== pendingId) });
733
+ if (send.confirmed) {
734
+ void this.updateTyping(false);
735
+ return send.confirmed;
736
+ }
699
737
  this.fail(cause, generation);
700
738
  }
701
739
  return null;
702
740
  } finally {
703
741
  if (this.alive(generation)) {
704
742
  this.sendRevision = void 0;
743
+ this.activeSend = void 0;
705
744
  this.patch({ isSending: false });
706
745
  }
707
746
  }
@@ -912,13 +951,13 @@ var MessageListView = (0, import_vue5.defineComponent)({
912
951
  ...mediaNodes,
913
952
  (0, import_vue5.h)("span", { class: "ckui-message-time" }, [
914
953
  isPending ? "Sending\u2026" : props.formatTime(message.createdAt),
915
- 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": "Delivered" }) : null
954
+ 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
916
955
  ])
917
956
  ]),
918
957
  isCurrentUser && !isPending ? slots["read-receipt"]?.(receiptSlotProps) ?? (0, import_vue5.h)("div", {
919
958
  class: partClass("receipt", currentAppearance, "ckui-read-receipt"),
920
959
  style: partStyle("receipt", currentAppearance)
921
- }, readerIds.size > 0 ? `Read by ${readerIds.size}` : "Delivered") : null
960
+ }, readerIds.size > 0 ? `Read by ${readerIds.size}` : "Sent") : null
922
961
  ])
923
962
  ]);
924
963
  };
@@ -1398,6 +1437,9 @@ var ConversationListStore = class {
1398
1437
  lifecycleGeneration = 0;
1399
1438
  disposed = true;
1400
1439
  lifecycle;
1440
+ inbox;
1441
+ refreshQueued = false;
1442
+ refreshing = false;
1401
1443
  listeners = /* @__PURE__ */ new Set();
1402
1444
  getSnapshot = () => this.state;
1403
1445
  subscribe = (listener) => {
@@ -1415,6 +1457,7 @@ var ConversationListStore = class {
1415
1457
  }
1416
1458
  start = (autoLoad = true) => {
1417
1459
  if (!this.owner || this.options.client.sessionIdentity !== this.owner) return;
1460
+ if (!this.disposed) return;
1418
1461
  this.disposed = false;
1419
1462
  const lifecycleGeneration = ++this.lifecycleGeneration;
1420
1463
  try {
@@ -1427,6 +1470,17 @@ var ConversationListStore = class {
1427
1470
  });
1428
1471
  if (this.alive()) this.lifecycle = subscription;
1429
1472
  else void subscription.unsubscribe().catch(() => void 0);
1473
+ if (!this.alive()) return;
1474
+ const inbox = this.options.client.onInboxChanged(() => {
1475
+ if (this.alive() && lifecycleGeneration === this.lifecycleGeneration) this.queueRefresh();
1476
+ }, (cause) => {
1477
+ if (this.alive() && lifecycleGeneration === this.lifecycleGeneration) {
1478
+ this.patch({ error: cause });
1479
+ this.queueRefresh();
1480
+ }
1481
+ });
1482
+ if (this.alive()) this.inbox = inbox;
1483
+ else void inbox.unsubscribe().catch(() => void 0);
1430
1484
  if (autoLoad) void this.loadInitial();
1431
1485
  } catch (cause) {
1432
1486
  if (this.alive()) this.patch({ error: cause });
@@ -1439,6 +1493,10 @@ var ConversationListStore = class {
1439
1493
  const subscription = this.lifecycle;
1440
1494
  this.lifecycle = void 0;
1441
1495
  if (subscription) void subscription.unsubscribe().catch(() => void 0);
1496
+ if (this.inbox) void this.inbox.unsubscribe().catch(() => void 0);
1497
+ this.inbox = void 0;
1498
+ this.refreshQueued = false;
1499
+ this.refreshing = false;
1442
1500
  this.source = [];
1443
1501
  this.offset = 0;
1444
1502
  this.patch(blank2(this.state.filter));
@@ -1477,6 +1535,7 @@ var ConversationListStore = class {
1477
1535
  loadInitial = async () => {
1478
1536
  if (!this.alive()) return;
1479
1537
  const generation = ++this.generation;
1538
+ this.refreshing = false;
1480
1539
  this.source = [];
1481
1540
  this.offset = 0;
1482
1541
  this.patch({ ...blank2(this.state.filter), isInitialLoading: true });
@@ -1485,12 +1544,67 @@ var ConversationListStore = class {
1485
1544
  } catch (cause) {
1486
1545
  this.fail(cause, generation);
1487
1546
  } finally {
1488
- if (this.alive(generation)) this.patch({ isInitialLoading: false, hasLoaded: true });
1547
+ if (this.alive(generation)) {
1548
+ this.patch({ isInitialLoading: false, hasLoaded: true });
1549
+ this.flushRefresh();
1550
+ }
1551
+ }
1552
+ };
1553
+ queueRefresh() {
1554
+ this.refreshQueued = true;
1555
+ this.flushRefresh();
1556
+ }
1557
+ flushRefresh() {
1558
+ const lifecycle = this.lifecycleGeneration;
1559
+ void Promise.resolve().then(() => {
1560
+ if (!this.alive() || lifecycle !== this.lifecycleGeneration || !this.refreshQueued || this.refreshing || this.state.isInitialLoading || this.state.isLoadingMore) return;
1561
+ this.refreshQueued = false;
1562
+ void this.refresh();
1563
+ });
1564
+ }
1565
+ /** Replace the loaded window atomically, retaining filters and rows during transient failures. */
1566
+ refresh = async () => {
1567
+ if (!this.alive()) return;
1568
+ if (this.refreshing || this.state.isInitialLoading || this.state.isLoadingMore) {
1569
+ this.refreshQueued = true;
1570
+ return;
1571
+ }
1572
+ if (!this.state.hasLoaded) return this.loadInitial();
1573
+ const generation = this.generation;
1574
+ const filter = this.state.filter;
1575
+ const target = Math.max(this.pageSize, this.offset);
1576
+ const compare2 = (a, b) => a.createdAt.getTime() - b.createdAt.getTime() || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
1577
+ const boundary = this.options.pageLoader ? void 0 : this.source.reduce((oldest, row) => !oldest || compare2(row, oldest) < 0 ? row : oldest, void 0);
1578
+ this.refreshing = true;
1579
+ this.patch({ error: null });
1580
+ try {
1581
+ let rows = [], offset = 0, hasMore = true;
1582
+ while (this.alive(generation)) {
1583
+ const page = this.options.pageLoader ? await this.options.pageLoader({ limit: this.pageSize, offset, filter }) : await this.options.client.getConversations({ limit: this.pageSize, offset, archived: filter.archived ?? false });
1584
+ if (!this.alive(generation)) return;
1585
+ if (page.length > this.pageSize || page.some((row) => !row.id.trim())) throw new Error("Invalid conversation page");
1586
+ const merged = mergeConversations(rows, page);
1587
+ if (page.length === this.pageSize && merged.length === rows.length) throw new Error("Conversation pagination did not advance");
1588
+ rows = merged;
1589
+ offset += page.length;
1590
+ hasMore = page.length === this.pageSize;
1591
+ if (!hasMore || offset >= target && applyConversationFilter(rows, this.state.filter).length > 0 && (!boundary || page.some((row) => compare2(row, boundary) <= 0))) break;
1592
+ }
1593
+ if (!this.alive(generation)) return;
1594
+ this.source = rows;
1595
+ this.offset = offset;
1596
+ this.patch({ conversations: applyConversationFilter(rows, this.state.filter), hasMore });
1597
+ } catch (cause) {
1598
+ this.fail(cause, generation);
1599
+ } finally {
1600
+ if (this.alive(generation)) {
1601
+ this.refreshing = false;
1602
+ this.flushRefresh();
1603
+ }
1489
1604
  }
1490
1605
  };
1491
- refresh = () => this.loadInitial();
1492
1606
  loadMore = async () => {
1493
- if (!this.alive() || this.state.isInitialLoading || this.state.isLoadingMore || !this.state.hasMore) return;
1607
+ if (!this.alive() || this.refreshing || this.state.isInitialLoading || this.state.isLoadingMore || !this.state.hasMore) return;
1494
1608
  const generation = this.generation;
1495
1609
  this.patch({ isLoadingMore: true, error: null });
1496
1610
  try {
@@ -1498,7 +1612,10 @@ var ConversationListStore = class {
1498
1612
  } catch (cause) {
1499
1613
  this.fail(cause, generation);
1500
1614
  } finally {
1501
- if (this.alive(generation)) this.patch({ isLoadingMore: false });
1615
+ if (this.alive(generation)) {
1616
+ this.patch({ isLoadingMore: false });
1617
+ this.flushRefresh();
1618
+ }
1502
1619
  }
1503
1620
  };
1504
1621
  setFilter = async (filter) => {