@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/dist/index.d.cts CHANGED
@@ -1,4 +1,4 @@
1
- import { Conversation as Conversation$1, MessageListOptions, Message, MessageMedia, RealtimeConnectionHandlers, RealtimeSubscription, MessageEvent, MessageDeletedEvent, ReadEvent, TypingEvent, ConvoKitClient } from '@convokitapp/sdk';
1
+ import { Conversation as Conversation$1, MessageListOptions, Message, SendMessageInput, RealtimeConnectionHandlers, RealtimeSubscription, MessageEvent, MessageDeletedEvent, ReadEvent, TypingEvent, MessageMedia, ConvoKitClient } from '@convokitapp/sdk';
2
2
  import * as vue from 'vue';
3
3
  import { CSSProperties, HTMLAttributes, Ref, VNodeChild, PropType, MaybeRefOrGetter, TextareaHTMLAttributes } from 'vue';
4
4
 
@@ -16,17 +16,15 @@ interface ConvoKitUiClient {
16
16
  getMessages(options: MessageListOptions): Promise<Message[]>;
17
17
  /** Complete authorized message, including related media absent from raw row events. */
18
18
  getMessage(id: string): Promise<Message>;
19
- sendMessage(input: {
20
- conversationId: string;
21
- text?: string;
22
- media?: MessageMedia[];
23
- }): Promise<Message>;
19
+ sendMessage(input: SendMessageInput): Promise<Message>;
24
20
  markConversationRead(conversationId: string): Promise<void>;
25
21
  sendTyping(input: {
26
22
  conversationId: string;
27
23
  isTyping: boolean;
28
24
  }): Promise<void>;
29
25
  onConnectionEvent(handlers: RealtimeConnectionHandlers): RealtimeSubscription;
26
+ /** Notify on inbox mutations AND on initial subscription/reconnect. No room data in the signal. */
27
+ onInboxChanged(handler: () => void, onError?: (error: Error) => void): RealtimeSubscription;
30
28
  onMessage(conversationId: string, handler: (event: MessageEvent) => void, onError?: (error: Error) => void): RealtimeSubscription;
31
29
  onMessageDeleted(conversationId: string, handler: (event: MessageDeletedEvent) => void, onError?: (error: Error) => void): RealtimeSubscription;
32
30
  onReadReceipt(conversationId: string, handler: (event: ReadEvent) => void, onError?: (error: Error) => void): RealtimeSubscription;
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { Conversation as Conversation$1, MessageListOptions, Message, MessageMedia, RealtimeConnectionHandlers, RealtimeSubscription, MessageEvent, MessageDeletedEvent, ReadEvent, TypingEvent, ConvoKitClient } from '@convokitapp/sdk';
1
+ import { Conversation as Conversation$1, MessageListOptions, Message, SendMessageInput, RealtimeConnectionHandlers, RealtimeSubscription, MessageEvent, MessageDeletedEvent, ReadEvent, TypingEvent, MessageMedia, ConvoKitClient } from '@convokitapp/sdk';
2
2
  import * as vue from 'vue';
3
3
  import { CSSProperties, HTMLAttributes, Ref, VNodeChild, PropType, MaybeRefOrGetter, TextareaHTMLAttributes } from 'vue';
4
4
 
@@ -16,17 +16,15 @@ interface ConvoKitUiClient {
16
16
  getMessages(options: MessageListOptions): Promise<Message[]>;
17
17
  /** Complete authorized message, including related media absent from raw row events. */
18
18
  getMessage(id: string): Promise<Message>;
19
- sendMessage(input: {
20
- conversationId: string;
21
- text?: string;
22
- media?: MessageMedia[];
23
- }): Promise<Message>;
19
+ sendMessage(input: SendMessageInput): Promise<Message>;
24
20
  markConversationRead(conversationId: string): Promise<void>;
25
21
  sendTyping(input: {
26
22
  conversationId: string;
27
23
  isTyping: boolean;
28
24
  }): Promise<void>;
29
25
  onConnectionEvent(handlers: RealtimeConnectionHandlers): RealtimeSubscription;
26
+ /** Notify on inbox mutations AND on initial subscription/reconnect. No room data in the signal. */
27
+ onInboxChanged(handler: () => void, onError?: (error: Error) => void): RealtimeSubscription;
30
28
  onMessage(conversationId: string, handler: (event: MessageEvent) => void, onError?: (error: Error) => void): RealtimeSubscription;
31
29
  onMessageDeleted(conversationId: string, handler: (event: MessageDeletedEvent) => void, onError?: (error: Error) => void): RealtimeSubscription;
32
30
  onReadReceipt(conversationId: string, handler: (event: ReadEvent) => void, onError?: (error: Error) => void): RealtimeSubscription;
package/dist/index.js CHANGED
@@ -5,6 +5,10 @@ function createConvoKitUiClient(client) {
5
5
  return client.connected ? client.realtime : null;
6
6
  },
7
7
  onConnectionEvent: (handlers) => client.realtime.onConnectionEvent(handlers),
8
+ onInboxChanged: (handler, onError) => client.realtime.onInboxChanged(client.clientId, {
9
+ onEvent: handler,
10
+ ...onError ? { onError } : {}
11
+ }),
8
12
  onMessageDeleted: (conversationId, handler, onError) => client.realtime.onMessageDeleted(conversationId, {
9
13
  onEvent: handler,
10
14
  ...onError ? { onError } : {}
@@ -155,6 +159,7 @@ import {
155
159
  import { computed, getCurrentScope, onScopeDispose, shallowRef, toValue, watch } from "vue";
156
160
 
157
161
  // src/conversation-store.ts
162
+ import { createClientMessageId } from "@convokitapp/sdk";
158
163
  function version(message) {
159
164
  return message.updatedAt?.getTime() ?? message.createdAt.getTime();
160
165
  }
@@ -221,7 +226,7 @@ var ConversationStore = class {
221
226
  // Keep tombstones until an explicit reload/session change, including across refreshes.
222
227
  deleted = /* @__PURE__ */ new Set();
223
228
  sendRevision;
224
- pendingSequence = 0;
229
+ activeSend;
225
230
  refreshQueued = false;
226
231
  typingTimers = /* @__PURE__ */ new Map();
227
232
  ownTypingTimer;
@@ -236,6 +241,10 @@ var ConversationStore = class {
236
241
  };
237
242
  };
238
243
  patch(patch) {
244
+ if (patch.messages && this.activeSend) {
245
+ for (const message of patch.messages) this.confirmSend(message);
246
+ if (this.activeSend.confirmed) patch.messages = patch.messages.filter((message) => message.id !== this.activeSend.pending.id);
247
+ }
239
248
  this.state = { ...this.state, ...patch };
240
249
  for (const listener of this.listeners) listener();
241
250
  }
@@ -279,6 +288,7 @@ var ConversationStore = class {
279
288
  this.hydrationPool.queued.clear();
280
289
  this.deleted.clear();
281
290
  this.sendRevision = void 0;
291
+ this.activeSend = void 0;
282
292
  this.refreshQueued = false;
283
293
  }
284
294
  dispose = () => {
@@ -319,6 +329,14 @@ var ConversationStore = class {
319
329
  onError: report
320
330
  }));
321
331
  if (!data) return;
332
+ add(() => this.client.onInboxChanged(() => {
333
+ if (this.alive(generation)) this.queueRefresh();
334
+ }, (cause) => {
335
+ if (this.alive(generation)) {
336
+ report(cause);
337
+ this.queueRefresh();
338
+ }
339
+ }));
322
340
  add(() => this.client.onMessage(this.room, (event) => this.onMessage(event, generation), report));
323
341
  add(() => this.client.onMessageDeleted(this.room, ({ id, conversationId }) => {
324
342
  if (!this.alive(generation) || conversationId !== this.room || !id.trim()) return;
@@ -375,9 +393,19 @@ var ConversationStore = class {
375
393
  record(message, insert, revision, complete) {
376
394
  const existing = this.state.messages.find((item) => item.id === message.id);
377
395
  if (existing && version(existing) > version(message)) return;
396
+ if (this.confirmSend(message) && !complete && !message.media.length) {
397
+ message = { ...message, media: this.activeSend.pending.media };
398
+ this.confirmSend(message);
399
+ }
378
400
  this.changes.set(message.id, { revision, message, insert, complete });
379
401
  if (existing || insert && hasContent(message)) this.patch({ messages: mergeMessages(this.state.messages, [message]) });
380
402
  }
403
+ confirmSend(message) {
404
+ const send = this.activeSend;
405
+ if (!send || !this.validMessage(message) || message.senderId !== this.user || message.clientMessageId !== send.pending.clientMessageId) return false;
406
+ send.confirmed = send.confirmed ? newest(send.confirmed, message) : message;
407
+ return true;
408
+ }
381
409
  currentHydration(job) {
382
410
  return this.alive(job.generation) && !this.deleted.has(job.message.id) && this.hydrations.get(job.message.id) === job;
383
411
  }
@@ -623,9 +651,11 @@ var ConversationStore = class {
623
651
  const generation = this.generation;
624
652
  const revision = this.revision;
625
653
  this.sendRevision = revision;
626
- const pendingId = `convokit-pending-${Date.now()}-${++this.pendingSequence}`;
654
+ const clientMessageId = createClientMessageId();
655
+ const pendingId = `convokit-pending-${clientMessageId}`;
627
656
  const pending = {
628
657
  id: pendingId,
658
+ clientMessageId,
629
659
  conversationId: this.room,
630
660
  senderId: this.user,
631
661
  text: normalized || null,
@@ -633,15 +663,19 @@ var ConversationStore = class {
633
663
  createdAt: /* @__PURE__ */ new Date(),
634
664
  updatedAt: null
635
665
  };
666
+ const send = { pending };
667
+ this.activeSend = send;
636
668
  this.patch({ messages: mergeMessages(this.state.messages, [pending]), isSending: true, error: null });
637
669
  try {
638
670
  const message = await this.client.sendMessage({
639
671
  conversationId: this.room,
672
+ clientMessageId,
640
673
  ...normalized ? { text: normalized } : {},
641
674
  ...media?.length ? { media } : {}
642
675
  });
643
676
  if (!this.alive(generation)) return null;
644
677
  if (!this.validMessage(message) || message.senderId !== this.user) throw new Error("Send response belongs to a different room or sender");
678
+ if (message.clientMessageId && message.clientMessageId !== clientMessageId) throw new Error("Send response belongs to a different send");
645
679
  const live = this.changes.get(message.id);
646
680
  const existing = this.state.messages.find((item) => item.id === message.id);
647
681
  let latest = existing ? newest(message, existing, live?.complete !== false) : message;
@@ -656,12 +690,17 @@ var ConversationStore = class {
656
690
  } catch (cause) {
657
691
  if (this.alive(generation)) {
658
692
  this.patch({ messages: this.state.messages.filter((item) => item.id !== pendingId) });
693
+ if (send.confirmed) {
694
+ void this.updateTyping(false);
695
+ return send.confirmed;
696
+ }
659
697
  this.fail(cause, generation);
660
698
  }
661
699
  return null;
662
700
  } finally {
663
701
  if (this.alive(generation)) {
664
702
  this.sendRevision = void 0;
703
+ this.activeSend = void 0;
665
704
  this.patch({ isSending: false });
666
705
  }
667
706
  }
@@ -889,13 +928,13 @@ var MessageListView = defineComponent2({
889
928
  ...mediaNodes,
890
929
  h2("span", { class: "ckui-message-time" }, [
891
930
  isPending ? "Sending\u2026" : props.formatTime(message.createdAt),
892
- isCurrentUser && !isPending ? readerIds.size > 0 ? h2(CheckCheck, { size: 14, "aria-label": "Read" }) : h2(Check, { size: 14, "aria-label": "Delivered" }) : null
931
+ isCurrentUser && !isPending ? readerIds.size > 0 ? h2(CheckCheck, { size: 14, "aria-label": "Read" }) : h2(Check, { size: 14, "aria-label": "Sent" }) : null
893
932
  ])
894
933
  ]),
895
934
  isCurrentUser && !isPending ? slots["read-receipt"]?.(receiptSlotProps) ?? h2("div", {
896
935
  class: partClass("receipt", currentAppearance, "ckui-read-receipt"),
897
936
  style: partStyle("receipt", currentAppearance)
898
- }, readerIds.size > 0 ? `Read by ${readerIds.size}` : "Delivered") : null
937
+ }, readerIds.size > 0 ? `Read by ${readerIds.size}` : "Sent") : null
899
938
  ])
900
939
  ]);
901
940
  };
@@ -1380,6 +1419,9 @@ var ConversationListStore = class {
1380
1419
  lifecycleGeneration = 0;
1381
1420
  disposed = true;
1382
1421
  lifecycle;
1422
+ inbox;
1423
+ refreshQueued = false;
1424
+ refreshing = false;
1383
1425
  listeners = /* @__PURE__ */ new Set();
1384
1426
  getSnapshot = () => this.state;
1385
1427
  subscribe = (listener) => {
@@ -1397,6 +1439,7 @@ var ConversationListStore = class {
1397
1439
  }
1398
1440
  start = (autoLoad = true) => {
1399
1441
  if (!this.owner || this.options.client.sessionIdentity !== this.owner) return;
1442
+ if (!this.disposed) return;
1400
1443
  this.disposed = false;
1401
1444
  const lifecycleGeneration = ++this.lifecycleGeneration;
1402
1445
  try {
@@ -1409,6 +1452,17 @@ var ConversationListStore = class {
1409
1452
  });
1410
1453
  if (this.alive()) this.lifecycle = subscription;
1411
1454
  else void subscription.unsubscribe().catch(() => void 0);
1455
+ if (!this.alive()) return;
1456
+ const inbox = this.options.client.onInboxChanged(() => {
1457
+ if (this.alive() && lifecycleGeneration === this.lifecycleGeneration) this.queueRefresh();
1458
+ }, (cause) => {
1459
+ if (this.alive() && lifecycleGeneration === this.lifecycleGeneration) {
1460
+ this.patch({ error: cause });
1461
+ this.queueRefresh();
1462
+ }
1463
+ });
1464
+ if (this.alive()) this.inbox = inbox;
1465
+ else void inbox.unsubscribe().catch(() => void 0);
1412
1466
  if (autoLoad) void this.loadInitial();
1413
1467
  } catch (cause) {
1414
1468
  if (this.alive()) this.patch({ error: cause });
@@ -1421,6 +1475,10 @@ var ConversationListStore = class {
1421
1475
  const subscription = this.lifecycle;
1422
1476
  this.lifecycle = void 0;
1423
1477
  if (subscription) void subscription.unsubscribe().catch(() => void 0);
1478
+ if (this.inbox) void this.inbox.unsubscribe().catch(() => void 0);
1479
+ this.inbox = void 0;
1480
+ this.refreshQueued = false;
1481
+ this.refreshing = false;
1424
1482
  this.source = [];
1425
1483
  this.offset = 0;
1426
1484
  this.patch(blank2(this.state.filter));
@@ -1459,6 +1517,7 @@ var ConversationListStore = class {
1459
1517
  loadInitial = async () => {
1460
1518
  if (!this.alive()) return;
1461
1519
  const generation = ++this.generation;
1520
+ this.refreshing = false;
1462
1521
  this.source = [];
1463
1522
  this.offset = 0;
1464
1523
  this.patch({ ...blank2(this.state.filter), isInitialLoading: true });
@@ -1467,12 +1526,67 @@ var ConversationListStore = class {
1467
1526
  } catch (cause) {
1468
1527
  this.fail(cause, generation);
1469
1528
  } finally {
1470
- if (this.alive(generation)) this.patch({ isInitialLoading: false, hasLoaded: true });
1529
+ if (this.alive(generation)) {
1530
+ this.patch({ isInitialLoading: false, hasLoaded: true });
1531
+ this.flushRefresh();
1532
+ }
1533
+ }
1534
+ };
1535
+ queueRefresh() {
1536
+ this.refreshQueued = true;
1537
+ this.flushRefresh();
1538
+ }
1539
+ flushRefresh() {
1540
+ const lifecycle = this.lifecycleGeneration;
1541
+ void Promise.resolve().then(() => {
1542
+ if (!this.alive() || lifecycle !== this.lifecycleGeneration || !this.refreshQueued || this.refreshing || this.state.isInitialLoading || this.state.isLoadingMore) return;
1543
+ this.refreshQueued = false;
1544
+ void this.refresh();
1545
+ });
1546
+ }
1547
+ /** Replace the loaded window atomically, retaining filters and rows during transient failures. */
1548
+ refresh = async () => {
1549
+ if (!this.alive()) return;
1550
+ if (this.refreshing || this.state.isInitialLoading || this.state.isLoadingMore) {
1551
+ this.refreshQueued = true;
1552
+ return;
1553
+ }
1554
+ if (!this.state.hasLoaded) return this.loadInitial();
1555
+ const generation = this.generation;
1556
+ const filter = this.state.filter;
1557
+ const target = Math.max(this.pageSize, this.offset);
1558
+ const compare2 = (a, b) => a.createdAt.getTime() - b.createdAt.getTime() || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
1559
+ const boundary = this.options.pageLoader ? void 0 : this.source.reduce((oldest, row) => !oldest || compare2(row, oldest) < 0 ? row : oldest, void 0);
1560
+ this.refreshing = true;
1561
+ this.patch({ error: null });
1562
+ try {
1563
+ let rows = [], offset = 0, hasMore = true;
1564
+ while (this.alive(generation)) {
1565
+ 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 });
1566
+ if (!this.alive(generation)) return;
1567
+ if (page.length > this.pageSize || page.some((row) => !row.id.trim())) throw new Error("Invalid conversation page");
1568
+ const merged = mergeConversations(rows, page);
1569
+ if (page.length === this.pageSize && merged.length === rows.length) throw new Error("Conversation pagination did not advance");
1570
+ rows = merged;
1571
+ offset += page.length;
1572
+ hasMore = page.length === this.pageSize;
1573
+ if (!hasMore || offset >= target && applyConversationFilter(rows, this.state.filter).length > 0 && (!boundary || page.some((row) => compare2(row, boundary) <= 0))) break;
1574
+ }
1575
+ if (!this.alive(generation)) return;
1576
+ this.source = rows;
1577
+ this.offset = offset;
1578
+ this.patch({ conversations: applyConversationFilter(rows, this.state.filter), hasMore });
1579
+ } catch (cause) {
1580
+ this.fail(cause, generation);
1581
+ } finally {
1582
+ if (this.alive(generation)) {
1583
+ this.refreshing = false;
1584
+ this.flushRefresh();
1585
+ }
1471
1586
  }
1472
1587
  };
1473
- refresh = () => this.loadInitial();
1474
1588
  loadMore = async () => {
1475
- if (!this.alive() || this.state.isInitialLoading || this.state.isLoadingMore || !this.state.hasMore) return;
1589
+ if (!this.alive() || this.refreshing || this.state.isInitialLoading || this.state.isLoadingMore || !this.state.hasMore) return;
1476
1590
  const generation = this.generation;
1477
1591
  this.patch({ isLoadingMore: true, error: null });
1478
1592
  try {
@@ -1480,7 +1594,10 @@ var ConversationListStore = class {
1480
1594
  } catch (cause) {
1481
1595
  this.fail(cause, generation);
1482
1596
  } finally {
1483
- if (this.alive(generation)) this.patch({ isLoadingMore: false });
1597
+ if (this.alive(generation)) {
1598
+ this.patch({ isLoadingMore: false });
1599
+ this.flushRefresh();
1600
+ }
1484
1601
  }
1485
1602
  };
1486
1603
  setFilter = async (filter) => {