@convokitapp/vue-ui 0.2.1 → 0.3.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/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,
@@ -45,12 +46,21 @@ module.exports = __toCommonJS(index_exports);
45
46
  // src/client.ts
46
47
  function createConvoKitUiClient(client) {
47
48
  return {
49
+ get sessionIdentity() {
50
+ return client.connected ? client.realtime : null;
51
+ },
52
+ onConnectionEvent: (handlers) => client.realtime.onConnectionEvent(handlers),
53
+ onMessageDeleted: (conversationId, handler, onError) => client.realtime.onMessageDeleted(conversationId, {
54
+ onEvent: handler,
55
+ ...onError ? { onError } : {}
56
+ }),
48
57
  get currentUserId() {
49
- return client.currentUserId;
58
+ return client.connected ? client.currentUserId : "";
50
59
  },
51
60
  getConversations: (options) => client.getConversations(options),
52
61
  getConversation: (conversationId) => client.getConversation(conversationId),
53
62
  getMessages: (options) => client.getMessages(options),
63
+ getMessage: (id) => client.getMessage(id),
54
64
  sendMessage: (input) => client.sendMessage(input),
55
65
  markConversationRead: (conversationId) => client.markConversationRead(conversationId),
56
66
  sendTyping: (input) => client.sendTyping(input),
@@ -71,12 +81,17 @@ function createConvoKitUiClient(client) {
71
81
 
72
82
  // src/components/avatar.ts
73
83
  var import_reka_ui = require("reka-ui");
74
- var import_vue = require("vue");
84
+ var import_vue2 = require("vue");
75
85
 
76
86
  // src/utils.ts
77
87
  var import_clsx = require("clsx");
88
+ var import_vue = require("vue");
89
+ var pendingMessageIdPrefix = "convokit-pending-";
90
+ function isConvoKitPendingMessage(message) {
91
+ return message.id.startsWith(pendingMessageIdPrefix);
92
+ }
78
93
  function cx(...values) {
79
- return (0, import_clsx.clsx)(values);
94
+ return (0, import_clsx.clsx)(values.map(import_vue.normalizeClass));
80
95
  }
81
96
  function requestedParticipantIds(filter) {
82
97
  const values = filter.participantIds ?? [];
@@ -117,11 +132,15 @@ function mergeMessages(current, incoming) {
117
132
  const byId = new Map(current.map((message) => [message.id, message]));
118
133
  for (const message of incoming) byId.set(message.id, message);
119
134
  return [...byId.values()].sort((left, right) => {
135
+ const leftPending = isConvoKitPendingMessage(left);
136
+ const rightPending = isConvoKitPendingMessage(right);
137
+ if (leftPending !== rightPending) return leftPending ? 1 : -1;
120
138
  const byTime = left.createdAt.getTime() - right.createdAt.getTime();
121
139
  return byTime === 0 ? left.id.localeCompare(right.id) : byTime;
122
140
  });
123
141
  }
124
142
  function readerIdsFor(message, readAtByUserId) {
143
+ if (isConvoKitPendingMessage(message)) return /* @__PURE__ */ new Set();
125
144
  return new Set([...readAtByUserId.entries()].filter(([userId, readAt]) => userId !== message.senderId && readAt.getTime() >= message.createdAt.getTime()).map(([userId]) => userId));
126
145
  }
127
146
  function partClass(part, appearance, defaultClass) {
@@ -145,7 +164,7 @@ function initials(value) {
145
164
  }
146
165
 
147
166
  // src/components/avatar.ts
148
- var ConvoKitAvatar = (0, import_vue.defineComponent)({
167
+ var ConvoKitAvatar = (0, import_vue2.defineComponent)({
149
168
  name: "ConvoKitAvatar",
150
169
  inheritAttrs: false,
151
170
  props: {
@@ -153,13 +172,13 @@ var ConvoKitAvatar = (0, import_vue.defineComponent)({
153
172
  src: { type: String, default: null }
154
173
  },
155
174
  setup(props, { attrs }) {
156
- return () => (0, import_vue.h)(import_reka_ui.AvatarRoot, {
175
+ return () => (0, import_vue2.h)(import_reka_ui.AvatarRoot, {
157
176
  ...attrs,
158
177
  class: cx("ckui-avatar", attrs.class)
159
178
  }, {
160
179
  default: () => [
161
- props.src ? (0, import_vue.h)(import_reka_ui.AvatarImage, { class: "ckui-avatar__image", src: props.src, alt: "" }) : null,
162
- (0, import_vue.h)(import_reka_ui.AvatarFallback, {
180
+ props.src ? (0, import_vue2.h)(import_reka_ui.AvatarImage, { class: "ckui-avatar__image", src: props.src, alt: "" }) : null,
181
+ (0, import_vue2.h)(import_reka_ui.AvatarFallback, {
163
182
  class: "ckui-avatar__fallback",
164
183
  ...props.src ? { delayMs: 300 } : {}
165
184
  }, () => initials(props.name))
@@ -169,251 +188,588 @@ var ConvoKitAvatar = (0, import_vue.defineComponent)({
169
188
  });
170
189
 
171
190
  // src/components/conversation.ts
172
- var import_vue5 = require("@lucide/vue");
173
- var import_vue6 = require("vue");
191
+ var import_vue6 = require("@lucide/vue");
192
+ var import_vue7 = require("vue");
174
193
 
175
194
  // src/composables/use-conversation.ts
176
- var import_vue2 = require("vue");
177
- function useConversation(options) {
178
- const messagePageSize = options.messagePageSize ?? 30;
179
- const typingTimeoutMs = options.typingTimeoutMs ?? 3e3;
180
- if (!(0, import_vue2.toValue)(options.conversationId).trim()) throw new TypeError("conversationId is required");
181
- if (!Number.isInteger(messagePageSize) || messagePageSize <= 0) throw new RangeError("messagePageSize must be a positive integer");
182
- if (!Number.isFinite(typingTimeoutMs) || typingTimeoutMs < 0) throw new RangeError("typingTimeoutMs must be non-negative");
183
- const conversation = (0, import_vue2.shallowRef)(null);
184
- const messages = (0, import_vue2.shallowRef)([]);
185
- const typingUserIds = (0, import_vue2.shallowRef)(/* @__PURE__ */ new Set());
186
- const readAtByUserId = (0, import_vue2.shallowRef)(/* @__PURE__ */ new Map());
187
- const isInitialLoading = (0, import_vue2.ref)(false);
188
- const isLoadingOlder = (0, import_vue2.ref)(false);
189
- const isSending = (0, import_vue2.ref)(false);
190
- const hasOlderMessages = (0, import_vue2.ref)(true);
191
- const hasLoaded = (0, import_vue2.ref)(false);
192
- const error = (0, import_vue2.shallowRef)(null);
193
- const currentUserId = (0, import_vue2.computed)(() => (0, import_vue2.toValue)(options.client).currentUserId);
194
- let generation = 0;
195
- let disposed = false;
196
- let sentTyping = false;
197
- let typingTimer = null;
198
- let subscriptions = [];
199
- const pendingIds = /* @__PURE__ */ new Set();
200
- let pendingSequence = 0;
201
- const unsubscribe = async () => {
202
- const active = subscriptions;
203
- subscriptions = [];
204
- await Promise.all(active.map((subscription) => subscription.unsubscribe()));
195
+ var import_vue3 = require("vue");
196
+
197
+ // src/conversation-store.ts
198
+ function version(message) {
199
+ return message.updatedAt?.getTime() ?? message.createdAt.getTime();
200
+ }
201
+ function hasContent(message) {
202
+ return !!message.text?.trim() || message.media.length > 0;
203
+ }
204
+ function newest(current, incoming, incomingComplete = true) {
205
+ return version(current) > version(incoming) || !incomingComplete && version(current) === version(incoming) ? current : incoming;
206
+ }
207
+ function compare(left, right) {
208
+ return left.createdAt.getTime() - right.createdAt.getTime() || (left.id < right.id ? -1 : left.id > right.id ? 1 : 0);
209
+ }
210
+ function blank(currentUserId = "") {
211
+ return {
212
+ conversation: null,
213
+ messages: [],
214
+ typingUserIds: /* @__PURE__ */ new Set(),
215
+ readAtByUserId: /* @__PURE__ */ new Map(),
216
+ isInitialLoading: false,
217
+ isLoadingOlder: false,
218
+ isReconciling: false,
219
+ isSending: false,
220
+ hasOlderMessages: true,
221
+ hasLoaded: false,
222
+ error: null,
223
+ currentUserId
205
224
  };
206
- const markRead = async () => {
207
- const client = (0, import_vue2.toValue)(options.client);
208
- const conversationId = (0, import_vue2.toValue)(options.conversationId);
209
- try {
210
- await client.markConversationRead(conversationId);
211
- if (disposed) return;
212
- readAtByUserId.value = new Map(readAtByUserId.value).set(client.currentUserId, /* @__PURE__ */ new Date());
213
- } catch (cause) {
214
- if (!disposed) error.value = cause;
225
+ }
226
+ var ConversationStore = class {
227
+ constructor(options) {
228
+ this.options = options;
229
+ this.client = options.client;
230
+ this.room = options.conversationId.trim();
231
+ this.pageSize = options.messagePageSize ?? 30;
232
+ this.typingTimeout = options.typingTimeoutMs ?? 3e3;
233
+ if (!this.room) throw new TypeError("conversationId is required");
234
+ if (!Number.isInteger(this.pageSize) || this.pageSize < 1 || this.pageSize > 100) {
235
+ throw new RangeError("messagePageSize must be an integer between 1 and 100");
236
+ }
237
+ if (!Number.isFinite(this.typingTimeout) || this.typingTimeout < 0) {
238
+ throw new RangeError("typingTimeoutMs must be non-negative");
215
239
  }
240
+ this.owner = this.client.sessionIdentity;
241
+ this.user = this.owner ? this.client.currentUserId : "";
242
+ this.state = blank(this.user);
243
+ }
244
+ options;
245
+ client;
246
+ room;
247
+ owner;
248
+ user;
249
+ pageSize;
250
+ typingTimeout;
251
+ state;
252
+ listeners = /* @__PURE__ */ new Set();
253
+ subscriptions = [];
254
+ disposed = true;
255
+ generation = 0;
256
+ cursor;
257
+ revision = 0;
258
+ changes = /* @__PURE__ */ new Map();
259
+ hydrations = /* @__PURE__ */ new Map();
260
+ hydrationPool = { running: /* @__PURE__ */ new Set(), queued: /* @__PURE__ */ new Map() };
261
+ // Keep tombstones until an explicit reload/session change, including across refreshes.
262
+ deleted = /* @__PURE__ */ new Set();
263
+ sendRevision;
264
+ pendingSequence = 0;
265
+ refreshQueued = false;
266
+ typingTimers = /* @__PURE__ */ new Map();
267
+ ownTypingTimer;
268
+ sentTyping = false;
269
+ typingRevision = 0;
270
+ lastTypingSentAt = -Infinity;
271
+ getSnapshot = () => this.state;
272
+ subscribe = (listener) => {
273
+ this.listeners.add(listener);
274
+ return () => {
275
+ this.listeners.delete(listener);
276
+ };
216
277
  };
217
- const subscribe = (activeGeneration, client, conversationId) => {
218
- const report = (cause) => {
219
- if (!disposed && activeGeneration === generation) error.value = cause;
278
+ patch(patch) {
279
+ this.state = { ...this.state, ...patch };
280
+ for (const listener of this.listeners) listener();
281
+ }
282
+ alive(generation = this.generation) {
283
+ return !this.disposed && generation === this.generation && this.owner !== null && this.client.sessionIdentity === this.owner;
284
+ }
285
+ start = (autoLoad = true) => {
286
+ if (this.owner === null || this.client.sessionIdentity !== this.owner) return;
287
+ this.disposed = false;
288
+ this.patch({ currentUserId: this.user });
289
+ if (autoLoad) void this.loadInitial();
290
+ else {
291
+ try {
292
+ this.attach(this.generation, false);
293
+ } catch {
294
+ }
295
+ }
296
+ };
297
+ detach() {
298
+ const active = this.subscriptions;
299
+ this.subscriptions = [];
300
+ for (const subscription of active) void subscription.unsubscribe().catch(() => void 0);
301
+ }
302
+ clearTyping() {
303
+ for (const timer of this.typingTimers.values()) clearTimeout(timer);
304
+ this.typingTimers.clear();
305
+ clearTimeout(this.ownTypingTimer);
306
+ this.ownTypingTimer = void 0;
307
+ this.sentTyping = false;
308
+ this.typingRevision++;
309
+ this.lastTypingSentAt = -Infinity;
310
+ this.patch({ typingUserIds: /* @__PURE__ */ new Set() });
311
+ }
312
+ clear() {
313
+ this.generation++;
314
+ this.detach();
315
+ this.clearTyping();
316
+ this.cursor = void 0;
317
+ this.changes.clear();
318
+ this.hydrations.clear();
319
+ this.hydrationPool.queued.clear();
320
+ this.deleted.clear();
321
+ this.sendRevision = void 0;
322
+ this.refreshQueued = false;
323
+ }
324
+ dispose = () => {
325
+ if (this.alive() && this.sentTyping) {
326
+ void this.client.sendTyping({ conversationId: this.room, isTyping: false }).catch(() => void 0);
327
+ }
328
+ this.disposed = true;
329
+ this.clear();
330
+ this.patch(blank());
331
+ };
332
+ fail(cause, generation, history = false) {
333
+ if (!this.alive(generation)) return;
334
+ const status = typeof cause === "object" && cause !== null && "status" in cause ? cause.status : void 0;
335
+ if (history && (status === 401 || status === 403 || status === 404)) {
336
+ this.clear();
337
+ this.patch({ ...blank(this.user), error: cause, hasLoaded: true, hasOlderMessages: false });
338
+ } else this.patch({ error: cause });
339
+ }
340
+ attach(generation, data = true) {
341
+ const report = (cause) => this.fail(cause, generation);
342
+ const add = (create) => {
343
+ if (!this.alive(generation)) return;
344
+ const subscription = create();
345
+ if (this.alive(generation)) this.subscriptions.push(subscription);
346
+ else void subscription.unsubscribe().catch(() => void 0);
220
347
  };
221
- subscriptions = [
222
- client.onMessage(conversationId, (message) => {
223
- if (disposed || activeGeneration !== generation) return;
224
- const pending = messages.value.find((candidate) => pendingIds.has(candidate.id) && message.senderId === client.currentUserId && candidate.text === message.text && candidate.media.length === message.media.length);
225
- if (pending) pendingIds.delete(pending.id);
226
- messages.value = mergeMessages(
227
- pending ? messages.value.filter((candidate) => candidate.id !== pending.id) : messages.value,
228
- [message]
229
- );
230
- if ((options.markReadOnReceive ?? true) && message.senderId !== client.currentUserId) void markRead();
231
- }, report),
232
- client.onReadReceipt(conversationId, ({ userId, readAt }) => {
233
- if (disposed || activeGeneration !== generation) return;
234
- readAtByUserId.value = new Map(readAtByUserId.value).set(userId, readAt);
235
- }, report),
236
- client.onTyping(conversationId, ({ userId, isTyping }) => {
237
- if (disposed || activeGeneration !== generation || userId === client.currentUserId) return;
238
- const next = new Set(typingUserIds.value);
239
- if (isTyping) next.add(userId);
240
- else next.delete(userId);
241
- typingUserIds.value = next;
242
- }, report)
243
- ];
348
+ try {
349
+ add(() => this.client.onConnectionEvent({
350
+ onEvent: ({ topic, status }) => {
351
+ if (!data || !this.alive(generation) || topic !== `messages:${this.room}` && topic !== `conversation:${this.room}`) return;
352
+ if (status === "SUBSCRIBED") this.queueRefresh();
353
+ else this.clearTyping();
354
+ },
355
+ onSessionEnded: () => {
356
+ if (this.disposed || generation !== this.generation) return;
357
+ this.dispose();
358
+ },
359
+ onError: report
360
+ }));
361
+ if (!data) return;
362
+ add(() => this.client.onMessage(this.room, (event) => this.onMessage(event, generation), report));
363
+ add(() => this.client.onMessageDeleted(this.room, ({ id, conversationId }) => {
364
+ if (!this.alive(generation) || conversationId !== this.room || !id.trim()) return;
365
+ this.removeMessage(id);
366
+ }, report));
367
+ add(() => this.client.onReadReceipt(this.room, ({ userId, readAt }) => {
368
+ if (this.alive(generation)) this.mergeReads([[userId, readAt]]);
369
+ }, report));
370
+ add(() => this.client.onTyping(this.room, ({ userId, isTyping }) => {
371
+ if (!this.alive(generation) || !userId.trim() || userId === this.user) return;
372
+ clearTimeout(this.typingTimers.get(userId));
373
+ this.typingTimers.delete(userId);
374
+ const next = new Set(this.state.typingUserIds);
375
+ if (isTyping) {
376
+ next.add(userId);
377
+ this.typingTimers.set(userId, setTimeout(() => {
378
+ this.typingTimers.delete(userId);
379
+ if (!this.alive(generation)) return;
380
+ const remaining = new Set(this.state.typingUserIds);
381
+ remaining.delete(userId);
382
+ this.patch({ typingUserIds: remaining });
383
+ }, this.typingTimeout));
384
+ } else next.delete(userId);
385
+ this.patch({ typingUserIds: next });
386
+ }, report));
387
+ } catch (cause) {
388
+ this.detach();
389
+ this.fail(cause, generation);
390
+ throw cause;
391
+ }
392
+ }
393
+ validMessage(message) {
394
+ return message.conversationId === this.room && !!message.id.trim() && !!message.senderId.trim() && !isConvoKitPendingMessage(message) && Number.isFinite(message.createdAt.getTime()) && Number.isFinite(version(message));
395
+ }
396
+ onMessage(event, generation) {
397
+ const { message, type } = event;
398
+ if (!this.alive(generation) || type !== "insert" && type !== "update" || !this.validMessage(message) || this.deleted.has(message.id)) return;
399
+ const existing = this.state.messages.find((item) => item.id === message.id);
400
+ const known = existing ?? this.changes.get(message.id)?.message;
401
+ if (known && version(message) < version(known)) return;
402
+ const insert = type === "insert" || this.changes.get(message.id)?.insert === true;
403
+ if (!existing && !insert && type === "update" && !this.state.isInitialLoading && !this.state.isLoadingOlder && !this.state.isReconciling) return;
404
+ const revision = ++this.revision;
405
+ const provisional = existing && !message.media.length ? { ...message, media: existing.media } : message;
406
+ this.record(provisional, insert, revision, false);
407
+ const job = { revision, generation, message, insert };
408
+ this.hydrations.set(message.id, job);
409
+ this.hydrationPool.queued.set(message.id, job);
410
+ this.drainHydration();
411
+ if (type === "insert" && !existing && message.senderId !== this.user && (this.options.markReadOnReceive ?? true)) {
412
+ void this.markRead();
413
+ }
414
+ }
415
+ record(message, insert, revision, complete) {
416
+ const existing = this.state.messages.find((item) => item.id === message.id);
417
+ if (existing && version(existing) > version(message)) return;
418
+ this.changes.set(message.id, { revision, message, insert, complete });
419
+ if (existing || insert && hasContent(message)) this.patch({ messages: mergeMessages(this.state.messages, [message]) });
420
+ }
421
+ currentHydration(job) {
422
+ return this.alive(job.generation) && !this.deleted.has(job.message.id) && this.hydrations.get(job.message.id) === job;
423
+ }
424
+ removeMessage(id) {
425
+ this.deleted.add(id);
426
+ this.changes.delete(id);
427
+ this.hydrations.delete(id);
428
+ this.hydrationPool.queued.delete(id);
429
+ this.patch({ messages: this.state.messages.filter((message) => message.id !== id) });
430
+ }
431
+ drainHydration() {
432
+ const pool = this.hydrationPool;
433
+ for (const [id, job] of pool.queued) {
434
+ if (pool.running.size >= 8) break;
435
+ if (pool.running.has(id)) continue;
436
+ pool.queued.delete(id);
437
+ if (!this.currentHydration(job)) continue;
438
+ pool.running.add(id);
439
+ void this.hydrate(job, pool);
440
+ }
441
+ }
442
+ async hydrate(job, pool) {
443
+ const id = job.message.id;
444
+ try {
445
+ if (!this.currentHydration(job)) return;
446
+ const full = await this.client.getMessage(id);
447
+ if (!this.currentHydration(job)) return;
448
+ if (!this.validMessage(full) || full.id !== id || full.senderId !== job.message.senderId || version(full) < version(job.message)) {
449
+ throw new Error("Complete message response does not match the observed resource/revision");
450
+ }
451
+ this.record(full, job.insert, job.revision, true);
452
+ } catch (cause) {
453
+ if (!this.currentHydration(job)) return;
454
+ const status = typeof cause === "object" && cause !== null && "status" in cause ? cause.status : void 0;
455
+ if (status === 404) this.removeMessage(id);
456
+ this.fail(cause, job.generation);
457
+ this.queueRefresh();
458
+ } finally {
459
+ if (this.hydrations.get(id) === job) this.hydrations.delete(id);
460
+ pool.running.delete(id);
461
+ queueMicrotask(() => {
462
+ if (this.hydrationPool === pool) this.drainHydration();
463
+ });
464
+ }
465
+ }
466
+ mergeReads(entries) {
467
+ const next = new Map(this.state.readAtByUserId);
468
+ for (const [userId, readAt] of entries) {
469
+ if (!userId.trim() || !Number.isFinite(readAt.getTime())) continue;
470
+ if (readAt.getTime() > (next.get(userId)?.getTime() ?? -Infinity)) next.set(userId, readAt);
471
+ }
472
+ this.patch({ readAtByUserId: next });
473
+ }
474
+ validatePage(page, before) {
475
+ if (page.length > this.pageSize) throw new Error("Message page exceeds the requested limit");
476
+ let previous = before;
477
+ for (const message of page) {
478
+ if (!this.validMessage(message) || previous && compare(message, previous) >= 0) {
479
+ throw new Error("Message history must contain distinct, room-scoped rows in newest-first cursor order");
480
+ }
481
+ previous = message;
482
+ }
483
+ }
484
+ fetchPage(before) {
485
+ return this.client.getMessages({
486
+ conversationId: this.room,
487
+ limit: this.pageSize,
488
+ ...before ? { beforeCreatedAt: before.createdAt, beforeId: before.id } : {}
489
+ });
490
+ }
491
+ overlay(rows, revision) {
492
+ const byId = new Map(rows.filter((message) => !this.deleted.has(message.id)).map((message) => [message.id, message]));
493
+ for (const [id, change] of this.changes) {
494
+ if (change.revision > revision && !this.deleted.has(id) && (change.insert || byId.has(id))) {
495
+ const current = byId.get(id);
496
+ if (current || change.complete || hasContent(change.message)) {
497
+ byId.set(id, current ? newest(current, change.message, change.complete) : change.message);
498
+ }
499
+ }
500
+ }
501
+ for (const message of this.state.messages) {
502
+ if (isConvoKitPendingMessage(message)) byId.set(message.id, message);
503
+ }
504
+ return mergeMessages([], [...byId.values()]);
505
+ }
506
+ prune(revision) {
507
+ const safeRevision = Math.min(revision, this.sendRevision ?? Infinity);
508
+ for (const [id, change] of this.changes) if (change.revision <= safeRevision) this.changes.delete(id);
509
+ for (const [id, job] of this.hydrations) if (job.revision <= revision) {
510
+ this.hydrations.delete(id);
511
+ this.hydrationPool.queued.delete(id);
512
+ }
513
+ }
514
+ loadInitial = async () => {
515
+ if (!this.alive()) return;
516
+ this.clear();
517
+ const generation = this.generation;
518
+ this.patch({ ...blank(this.user), isInitialLoading: true });
519
+ const revision = this.revision;
520
+ try {
521
+ this.attach(generation);
522
+ if (!this.alive(generation)) return;
523
+ const [conversation, page] = await Promise.all([this.client.getConversation(this.room), this.fetchPage()]);
524
+ if (!this.alive(generation)) return;
525
+ if (conversation.id !== this.room) throw new Error("Conversation response belongs to a different room");
526
+ this.validatePage(page);
527
+ this.cursor = page.at(-1);
528
+ this.patch({ conversation, messages: this.overlay(page, revision), hasOlderMessages: page.length === this.pageSize });
529
+ this.mergeReads(conversation.participants.flatMap((participant) => participant.lastReadAt ? [[participant.appUserId, participant.lastReadAt]] : []));
530
+ this.prune(revision);
531
+ if (this.options.markReadOnLoad ?? true) await this.markRead();
532
+ } catch (cause) {
533
+ this.fail(cause, generation, true);
534
+ } finally {
535
+ if (this.alive(generation)) {
536
+ this.patch({ isInitialLoading: false, hasLoaded: true });
537
+ this.flushRefresh();
538
+ }
539
+ }
244
540
  };
245
- const loadInitial = async () => {
246
- const client = (0, import_vue2.toValue)(options.client);
247
- const conversationId = (0, import_vue2.toValue)(options.conversationId).trim();
248
- if (!conversationId) throw new TypeError("conversationId is required");
249
- const activeGeneration = ++generation;
250
- await unsubscribe();
251
- if (disposed || activeGeneration !== generation) return;
252
- messages.value = [];
253
- pendingIds.clear();
254
- conversation.value = null;
255
- typingUserIds.value = /* @__PURE__ */ new Set();
256
- readAtByUserId.value = /* @__PURE__ */ new Map();
257
- hasOlderMessages.value = true;
258
- error.value = null;
259
- isInitialLoading.value = true;
260
- isLoadingOlder.value = false;
261
- subscribe(activeGeneration, client, conversationId);
541
+ queueRefresh() {
542
+ this.refreshQueued = true;
543
+ this.flushRefresh();
544
+ }
545
+ flushRefresh() {
546
+ const generation = this.generation;
547
+ void Promise.resolve().then(() => {
548
+ if (!this.alive(generation) || !this.refreshQueued || this.state.isInitialLoading || this.state.isLoadingOlder || this.state.isReconciling) return;
549
+ this.refreshQueued = false;
550
+ void this.refresh();
551
+ });
552
+ }
553
+ /** Re-fetch the entire viewed range atomically; a first-page-only refresh loses history. */
554
+ refresh = async () => {
555
+ if (!this.alive()) return;
556
+ if (this.state.isInitialLoading || this.state.isLoadingOlder || this.state.isReconciling) {
557
+ this.refreshQueued = true;
558
+ return;
559
+ }
560
+ if (!this.state.hasLoaded || !this.subscriptions.length) return this.loadInitial();
561
+ const generation = this.generation;
562
+ const revision = this.revision;
563
+ const observed = this.state.messages.filter((message) => !isConvoKitPendingMessage(message)).concat([...this.changes.values()].filter((change) => change.insert).map((change) => change.message));
564
+ const boundary = observed.reduce((oldest, message) => !oldest || compare(message, oldest) < 0 ? message : oldest, this.cursor);
565
+ const previouslyExhausted = !this.state.hasOlderMessages;
566
+ this.patch({ isReconciling: true, error: null });
262
567
  try {
263
- const [nextConversation, page] = await Promise.all([
264
- client.getConversation(conversationId),
265
- client.getMessages({ conversationId, limit: messagePageSize, offset: 0 })
266
- ]);
267
- if (disposed || activeGeneration !== generation) return;
268
- conversation.value = nextConversation;
269
- readAtByUserId.value = new Map(nextConversation.participants.flatMap((participant) => participant.lastReadAt ? [[participant.appUserId, participant.lastReadAt]] : []));
270
- messages.value = mergeMessages([], page);
271
- hasOlderMessages.value = page.length === messagePageSize;
272
- if (options.markReadOnLoad ?? true) await markRead();
568
+ const conversation = await this.client.getConversation(this.room);
569
+ if (!this.alive(generation)) return;
570
+ if (conversation.id !== this.room) throw new Error("Conversation response belongs to a different room");
571
+ const rows = [];
572
+ let before;
573
+ let hasOlder = true;
574
+ while (this.alive(generation)) {
575
+ const page = await this.fetchPage(before);
576
+ if (!this.alive(generation)) return;
577
+ this.validatePage(page, before);
578
+ rows.push(...page);
579
+ before = page.at(-1) ?? before;
580
+ hasOlder = page.length === this.pageSize;
581
+ if (!hasOlder || !boundary || !previouslyExhausted && before && compare(before, boundary) < 0) break;
582
+ }
583
+ this.cursor = before;
584
+ const reconciled = this.overlay(rows, revision);
585
+ const survivingIds = new Set(reconciled.map((message) => message.id));
586
+ 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));
587
+ for (const id of known) if (!survivingIds.has(id)) {
588
+ this.deleted.add(id);
589
+ this.changes.delete(id);
590
+ this.hydrations.delete(id);
591
+ this.hydrationPool.queued.delete(id);
592
+ }
593
+ this.patch({ conversation, messages: reconciled, hasOlderMessages: hasOlder });
594
+ this.mergeReads(conversation.participants.flatMap((participant) => participant.lastReadAt ? [[participant.appUserId, participant.lastReadAt]] : []));
595
+ this.prune(revision);
273
596
  } catch (cause) {
274
- if (!disposed && activeGeneration === generation) error.value = cause;
597
+ this.fail(cause, generation, true);
275
598
  } finally {
276
- if (!disposed && activeGeneration === generation) {
277
- isInitialLoading.value = false;
278
- hasLoaded.value = true;
599
+ if (this.alive(generation)) {
600
+ this.patch({ isReconciling: false });
601
+ this.flushRefresh();
279
602
  }
280
603
  }
281
604
  };
282
- const loadOlderMessages = async () => {
283
- if (isInitialLoading.value || isLoadingOlder.value || !hasOlderMessages.value) return;
284
- const activeGeneration = generation;
285
- const client = (0, import_vue2.toValue)(options.client);
286
- const conversationId = (0, import_vue2.toValue)(options.conversationId);
287
- isLoadingOlder.value = true;
288
- error.value = null;
605
+ loadOlderMessages = async () => {
606
+ if (!this.alive() || !this.state.hasLoaded || this.state.isInitialLoading || this.state.isLoadingOlder || this.state.isReconciling || !this.state.hasOlderMessages) return;
607
+ const generation = this.generation;
608
+ const revision = this.revision;
609
+ const cursor = this.cursor;
610
+ this.patch({ isLoadingOlder: true, error: null });
289
611
  try {
290
- const page = await client.getMessages({
291
- conversationId,
292
- limit: messagePageSize,
293
- offset: messages.value.filter((message) => !pendingIds.has(message.id)).length
612
+ const page = await this.fetchPage(cursor);
613
+ if (!this.alive(generation)) return;
614
+ this.validatePage(page, cursor);
615
+ this.cursor = page.at(-1) ?? cursor;
616
+ this.patch({
617
+ messages: this.overlay(mergeMessages(page, this.state.messages), revision),
618
+ hasOlderMessages: page.length === this.pageSize
294
619
  });
295
- if (disposed || activeGeneration !== generation) return;
296
- messages.value = mergeMessages(messages.value, page);
297
- hasOlderMessages.value = page.length === messagePageSize;
298
620
  } catch (cause) {
299
- if (!disposed && activeGeneration === generation) error.value = cause;
621
+ this.fail(cause, generation, true);
300
622
  } finally {
301
- if (!disposed && activeGeneration === generation) isLoadingOlder.value = false;
623
+ if (this.alive(generation)) {
624
+ this.patch({ isLoadingOlder: false });
625
+ this.flushRefresh();
626
+ }
302
627
  }
303
628
  };
304
- const updateTyping = async (nextTyping) => {
305
- if (typingTimer) clearTimeout(typingTimer);
306
- if (nextTyping) typingTimer = setTimeout(() => {
307
- void updateTyping(false);
308
- }, typingTimeoutMs);
309
- if (sentTyping === nextTyping) return;
310
- sentTyping = nextTyping;
629
+ markRead = async () => {
630
+ if (!this.alive()) return;
631
+ const generation = this.generation;
311
632
  try {
312
- await (0, import_vue2.toValue)(options.client).sendTyping({
313
- conversationId: (0, import_vue2.toValue)(options.conversationId),
314
- isTyping: nextTyping
315
- });
633
+ await this.client.markConversationRead(this.room);
316
634
  } catch (cause) {
317
- if (!disposed) error.value = cause;
635
+ this.fail(cause, generation);
318
636
  }
319
637
  };
320
- const sendMessage = async ({ text, media }) => {
321
- const normalizedText = text?.trim();
322
- if (!normalizedText && (!media || media.length === 0)) return null;
323
- if (isSending.value) return null;
324
- const pendingId = `convokit-pending-${Date.now()}-${++pendingSequence}`;
325
- const pendingMessage = {
638
+ updateTyping = async (isTyping) => {
639
+ if (!this.alive()) return;
640
+ const generation = this.generation;
641
+ clearTimeout(this.ownTypingTimer);
642
+ if (isTyping) this.ownTypingTimer = setTimeout(() => {
643
+ if (this.alive(generation)) void this.updateTyping(false);
644
+ }, this.typingTimeout);
645
+ const now = performance.now();
646
+ const renew = isTyping && now - this.lastTypingSentAt >= Math.max(1, this.typingTimeout / 2);
647
+ if (this.sentTyping === isTyping && !renew) return;
648
+ const revision = ++this.typingRevision;
649
+ this.sentTyping = isTyping;
650
+ this.lastTypingSentAt = isTyping ? now : -Infinity;
651
+ try {
652
+ await this.client.sendTyping({ conversationId: this.room, isTyping });
653
+ } catch (cause) {
654
+ if (this.alive(generation) && revision === this.typingRevision) {
655
+ this.sentTyping = false;
656
+ this.fail(cause, generation);
657
+ }
658
+ }
659
+ };
660
+ sendMessage = async ({ text, media }) => {
661
+ const normalized = text?.trim();
662
+ if (!this.alive() || this.state.isSending || !normalized && !media?.length) return null;
663
+ const generation = this.generation;
664
+ const revision = this.revision;
665
+ this.sendRevision = revision;
666
+ const pendingId = `convokit-pending-${Date.now()}-${++this.pendingSequence}`;
667
+ const pending = {
326
668
  id: pendingId,
327
- conversationId: (0, import_vue2.toValue)(options.conversationId),
328
- senderId: (0, import_vue2.toValue)(options.client).currentUserId,
329
- text: normalizedText ?? null,
669
+ conversationId: this.room,
670
+ senderId: this.user,
671
+ text: normalized || null,
330
672
  media: media ?? [],
331
673
  createdAt: /* @__PURE__ */ new Date(),
332
674
  updatedAt: null
333
675
  };
334
- pendingIds.add(pendingId);
335
- messages.value = mergeMessages(messages.value, [pendingMessage]);
336
- isSending.value = true;
337
- error.value = null;
338
- const activeGeneration = generation;
676
+ this.patch({ messages: mergeMessages(this.state.messages, [pending]), isSending: true, error: null });
339
677
  try {
340
- const message = await (0, import_vue2.toValue)(options.client).sendMessage({
341
- conversationId: (0, import_vue2.toValue)(options.conversationId),
342
- ...normalizedText ? { text: normalizedText } : {},
678
+ const message = await this.client.sendMessage({
679
+ conversationId: this.room,
680
+ ...normalized ? { text: normalized } : {},
343
681
  ...media?.length ? { media } : {}
344
682
  });
345
- pendingIds.delete(pendingId);
346
- if (!disposed && activeGeneration === generation) {
347
- messages.value = mergeMessages(
348
- messages.value.filter((candidate) => candidate.id !== pendingId),
349
- [message]
350
- );
351
- }
352
- await updateTyping(false);
353
- return message;
683
+ if (!this.alive(generation)) return null;
684
+ if (!this.validMessage(message) || message.senderId !== this.user) throw new Error("Send response belongs to a different room or sender");
685
+ const live = this.changes.get(message.id);
686
+ const existing = this.state.messages.find((item) => item.id === message.id);
687
+ let latest = existing ? newest(message, existing, live?.complete !== false) : message;
688
+ if (live && live.revision > revision) latest = newest(latest, live.message, live.complete);
689
+ if (!this.deleted.has(message.id)) this.changes.set(message.id, { revision: ++this.revision, message: latest, insert: true, complete: true });
690
+ this.patch({ messages: mergeMessages(
691
+ this.state.messages.filter((item) => item.id !== pendingId),
692
+ this.deleted.has(message.id) ? [] : [latest]
693
+ ) });
694
+ void this.updateTyping(false);
695
+ return this.alive(generation) ? latest : null;
354
696
  } catch (cause) {
355
- pendingIds.delete(pendingId);
356
- if (!disposed) {
357
- messages.value = messages.value.filter((candidate) => candidate.id !== pendingId);
358
- error.value = cause;
697
+ if (this.alive(generation)) {
698
+ this.patch({ messages: this.state.messages.filter((item) => item.id !== pendingId) });
699
+ this.fail(cause, generation);
359
700
  }
360
701
  return null;
361
702
  } finally {
362
- if (!disposed && activeGeneration === generation) isSending.value = false;
703
+ if (this.alive(generation)) {
704
+ this.sendRevision = void 0;
705
+ this.patch({ isSending: false });
706
+ }
363
707
  }
364
708
  };
709
+ readerIdsFor = (message) => readerIdsFor(message, this.state.readAtByUserId);
710
+ };
711
+
712
+ // src/composables/use-conversation.ts
713
+ function useConversation(options) {
714
+ const createStore = () => new ConversationStore({
715
+ ...options,
716
+ client: (0, import_vue3.toValue)(options.client),
717
+ conversationId: (0, import_vue3.toValue)(options.conversationId)
718
+ });
719
+ let store = createStore();
720
+ const snapshot = (0, import_vue3.shallowRef)(store.getSnapshot());
721
+ let unsubscribe;
722
+ const stop = (0, import_vue3.watch)(
723
+ () => [(0, import_vue3.toValue)(options.client), (0, import_vue3.toValue)(options.client).sessionIdentity, (0, import_vue3.toValue)(options.conversationId)],
724
+ () => {
725
+ store.dispose();
726
+ unsubscribe?.();
727
+ store = createStore();
728
+ snapshot.value = store.getSnapshot();
729
+ unsubscribe = store.subscribe(() => {
730
+ snapshot.value = store.getSnapshot();
731
+ });
732
+ store.start(options.autoLoad ?? true);
733
+ },
734
+ { immediate: true, flush: "sync" }
735
+ );
736
+ const field = (key) => (0, import_vue3.computed)(() => snapshot.value[key]);
365
737
  const dispose = async () => {
366
- disposed = true;
367
- generation += 1;
368
- if (typingTimer) clearTimeout(typingTimer);
369
- if (sentTyping) {
370
- await (0, import_vue2.toValue)(options.client).sendTyping({
371
- conversationId: (0, import_vue2.toValue)(options.conversationId),
372
- isTyping: false
373
- }).catch(() => void 0);
374
- }
375
- await unsubscribe();
738
+ stop();
739
+ store.dispose();
740
+ unsubscribe?.();
741
+ unsubscribe = void 0;
376
742
  };
377
- if (options.autoLoad ?? true) {
378
- (0, import_vue2.watch)(
379
- () => [(0, import_vue2.toValue)(options.client), (0, import_vue2.toValue)(options.conversationId)],
380
- () => {
381
- disposed = false;
382
- sentTyping = false;
383
- void loadInitial();
384
- },
385
- { immediate: true }
386
- );
387
- }
388
- if ((0, import_vue2.getCurrentScope)()) (0, import_vue2.onScopeDispose)(() => {
743
+ if ((0, import_vue3.getCurrentScope)()) (0, import_vue3.onScopeDispose)(() => {
389
744
  void dispose();
390
745
  });
391
746
  return {
392
- conversation,
393
- messages,
394
- typingUserIds,
395
- readAtByUserId,
396
- isInitialLoading,
397
- isLoadingOlder,
398
- isSending,
399
- hasOlderMessages,
400
- hasLoaded,
401
- error,
402
- currentUserId,
403
- readerIdsFor: (message) => readerIdsFor(message, readAtByUserId.value),
404
- loadInitial,
405
- refresh: loadInitial,
406
- loadOlderMessages,
407
- sendMessage,
408
- markRead,
409
- updateTyping,
747
+ conversation: field("conversation"),
748
+ messages: field("messages"),
749
+ typingUserIds: field("typingUserIds"),
750
+ readAtByUserId: field("readAtByUserId"),
751
+ isInitialLoading: field("isInitialLoading"),
752
+ isLoadingOlder: field("isLoadingOlder"),
753
+ isReconciling: field("isReconciling"),
754
+ isSending: field("isSending"),
755
+ hasOlderMessages: field("hasOlderMessages"),
756
+ hasLoaded: field("hasLoaded"),
757
+ error: field("error"),
758
+ currentUserId: field("currentUserId"),
759
+ readerIdsFor: (message) => store.readerIdsFor(message),
760
+ loadInitial: () => store.loadInitial(),
761
+ refresh: () => store.refresh(),
762
+ loadOlderMessages: () => store.loadOlderMessages(),
763
+ sendMessage: (input) => store.sendMessage(input),
764
+ markRead: () => store.markRead(),
765
+ updateTyping: (isTyping) => store.updateTyping(isTyping),
410
766
  dispose
411
767
  };
412
768
  }
413
769
 
414
770
  // src/components/message-list.ts
415
- var import_vue3 = require("@lucide/vue");
416
- var import_vue4 = require("vue");
771
+ var import_vue4 = require("@lucide/vue");
772
+ var import_vue5 = require("vue");
417
773
  var appearanceProps = {
418
774
  classNames: { type: Object, default: void 0 },
419
775
  styles: { type: Object, default: void 0 },
@@ -427,33 +783,33 @@ function defaultMedia(media, open, imageLoading) {
427
783
  const tag = open ? "button" : "div";
428
784
  const interactive = open ? { type: "button", onClick: open } : {};
429
785
  if (media.type === "image") {
430
- return (0, import_vue4.h)(tag, { ...interactive, class: "ckui-media-card ckui-media-card--image" }, [
431
- media.url ? (0, import_vue4.h)("img", { src: media.url, alt: media.name ?? "Shared image", loading: imageLoading }) : (0, import_vue4.h)("span", { class: "ckui-media-placeholder" }, [(0, import_vue4.h)(import_vue3.ImageOff, { "aria-hidden": "true" }), " Image unavailable"]),
432
- media.name ? (0, import_vue4.h)("span", { class: "ckui-media-name" }, media.name) : null
786
+ return (0, import_vue5.h)(tag, { ...interactive, class: "ckui-media-card ckui-media-card--image" }, [
787
+ media.url ? (0, import_vue5.h)("img", { src: media.url, alt: media.name ?? "Shared image", loading: imageLoading }) : (0, import_vue5.h)("span", { class: "ckui-media-placeholder" }, [(0, import_vue5.h)(import_vue4.ImageOff, { "aria-hidden": "true" }), " Image unavailable"]),
788
+ media.name ? (0, import_vue5.h)("span", { class: "ckui-media-name" }, media.name) : null
433
789
  ]);
434
790
  }
435
791
  if (media.type === "file") {
436
792
  const size = formatFileSize(media.size);
437
- return (0, import_vue4.h)(tag, { ...interactive, class: "ckui-media-card ckui-media-card--file" }, [
438
- (0, import_vue4.h)(import_vue3.FileText, { "aria-hidden": "true" }),
439
- (0, import_vue4.h)("span", [(0, import_vue4.h)("strong", media.name || "Attachment"), size ? (0, import_vue4.h)("small", size) : null]),
440
- open ? (0, import_vue4.h)(import_vue3.Download, { size: 18, "aria-hidden": "true" }) : null
793
+ return (0, import_vue5.h)(tag, { ...interactive, class: "ckui-media-card ckui-media-card--file" }, [
794
+ (0, import_vue5.h)(import_vue4.FileText, { "aria-hidden": "true" }),
795
+ (0, import_vue5.h)("span", [(0, import_vue5.h)("strong", media.name || "Attachment"), size ? (0, import_vue5.h)("small", size) : null]),
796
+ open ? (0, import_vue5.h)(import_vue4.Download, { size: 18, "aria-hidden": "true" }) : null
441
797
  ]);
442
798
  }
443
799
  if (media.type === "location") {
444
800
  const label = media.name || `${media.metadata.lat}, ${media.metadata.lng}`;
445
- return (0, import_vue4.h)(tag, { ...interactive, class: "ckui-media-card ckui-media-card--location" }, [
446
- (0, import_vue4.h)(import_vue3.MapPin, { "aria-hidden": "true" }),
447
- (0, import_vue4.h)("span", [(0, import_vue4.h)("strong", label), (0, import_vue4.h)("small", `${media.metadata.lat}, ${media.metadata.lng}`)])
801
+ return (0, import_vue5.h)(tag, { ...interactive, class: "ckui-media-card ckui-media-card--location" }, [
802
+ (0, import_vue5.h)(import_vue4.MapPin, { "aria-hidden": "true" }),
803
+ (0, import_vue5.h)("span", [(0, import_vue5.h)("strong", label), (0, import_vue5.h)("small", `${media.metadata.lat}, ${media.metadata.lng}`)])
448
804
  ]);
449
805
  }
450
806
  const contact = media.metadata.email || media.metadata.phone || "Contact details";
451
- return (0, import_vue4.h)(tag, { ...interactive, class: "ckui-media-card ckui-media-card--contact" }, [
452
- (0, import_vue4.h)(import_vue3.ContactRound, { "aria-hidden": "true" }),
453
- (0, import_vue4.h)("span", [(0, import_vue4.h)("strong", media.name || "Shared contact"), (0, import_vue4.h)("small", String(contact))])
807
+ return (0, import_vue5.h)(tag, { ...interactive, class: "ckui-media-card ckui-media-card--contact" }, [
808
+ (0, import_vue5.h)(import_vue4.ContactRound, { "aria-hidden": "true" }),
809
+ (0, import_vue5.h)("span", [(0, import_vue5.h)("strong", media.name || "Shared contact"), (0, import_vue5.h)("small", String(contact))])
454
810
  ]);
455
811
  }
456
- var MessageListView = (0, import_vue4.defineComponent)({
812
+ var MessageListView = (0, import_vue5.defineComponent)({
457
813
  name: "MessageListView",
458
814
  inheritAttrs: false,
459
815
  props: {
@@ -477,11 +833,11 @@ var MessageListView = (0, import_vue4.defineComponent)({
477
833
  },
478
834
  emits: ["load-older", "attachment-click"],
479
835
  setup(props, { attrs, emit, slots }) {
480
- const internalElement = (0, import_vue4.ref)(null);
836
+ const internalElement = (0, import_vue5.ref)(null);
481
837
  let requestInFlight = false;
482
838
  let lastRequestedLength = null;
483
839
  let previousMessageCount = 0;
484
- const participants = (0, import_vue4.computed)(() => new Map(props.conversation.participants.flatMap((participant) => [
840
+ const participants = (0, import_vue5.computed)(() => new Map(props.conversation.participants.flatMap((participant) => [
485
841
  [participant.id, participant],
486
842
  [participant.appUserId, participant]
487
843
  ])));
@@ -503,7 +859,7 @@ var MessageListView = (0, import_vue4.defineComponent)({
503
859
  requestInFlight = false;
504
860
  }
505
861
  };
506
- (0, import_vue4.watch)(() => [props.messages.length, props.hasOlderMessages], async ([count, hasOlder]) => {
862
+ (0, import_vue5.watch)(() => [props.messages.length, props.hasOlderMessages], async ([count, hasOlder]) => {
507
863
  const previous = previousMessageCount;
508
864
  if (count !== previousMessageCount || !hasOlder) lastRequestedLength = null;
509
865
  const appended = count > previous;
@@ -512,7 +868,7 @@ var MessageListView = (0, import_vue4.defineComponent)({
512
868
  if (element && props.reverse && props.stickToBottom && appended) {
513
869
  const distanceFromBottom = element.scrollHeight - element.scrollTop - element.clientHeight;
514
870
  if (previous === 0 || distanceFromBottom < 320) {
515
- await (0, import_vue4.nextTick)();
871
+ await (0, import_vue5.nextTick)();
516
872
  element.scrollTop = element.scrollHeight;
517
873
  }
518
874
  }
@@ -520,10 +876,11 @@ var MessageListView = (0, import_vue4.defineComponent)({
520
876
  const renderMessage = (message, index) => {
521
877
  const isCurrentUser = message.senderId === props.currentUserId;
522
878
  const sender = participants.value.get(message.senderId);
523
- const readerIds = props.readersResolver ? props.readersResolver(message) : readerIdsFor(message, props.readAtByUserId);
879
+ const isPending = isConvoKitPendingMessage(message);
880
+ const readerIds = isPending ? /* @__PURE__ */ new Set() : props.readersResolver ? props.readersResolver(message) : readerIdsFor(message, props.readAtByUserId);
524
881
  const slotProps = { message, chronologicalIndex: index, isCurrentUser, sender, readerIds };
525
882
  const custom = slots.message?.(slotProps);
526
- if (custom) return (0, import_vue4.h)("div", { key: message.id, role: "listitem" }, custom);
883
+ if (custom) return (0, import_vue5.h)("div", { key: message.id, role: "listitem" }, custom);
527
884
  const currentAppearance = appearance();
528
885
  const messagePart = isCurrentUser ? "outgoingMessage" : "incomingMessage";
529
886
  const mediaNodes = message.media.map((media, mediaIndex) => {
@@ -531,15 +888,15 @@ var MessageListView = (0, import_vue4.defineComponent)({
531
888
  props.onAttachmentClick?.(media, message);
532
889
  } : void 0;
533
890
  const mediaSlotProps = { media, message, isCurrentUser, ...open ? { open } : {} };
534
- return (0, import_vue4.h)("div", {
891
+ return (0, import_vue5.h)("div", {
535
892
  key: media.id ?? `${media.type}-${mediaIndex}`,
536
893
  class: partClass("media", currentAppearance, "ckui-media"),
537
894
  style: partStyle("media", currentAppearance)
538
895
  }, slots.media?.(mediaSlotProps) ?? [defaultMedia(media, open, props.imageLoading)]);
539
896
  });
540
897
  const receiptSlotProps = { message, readerIds };
541
- return (0, import_vue4.h)("div", { key: message.id, role: "listitem" }, [
542
- (0, import_vue4.h)("article", {
898
+ return (0, import_vue5.h)("div", { key: message.id, role: "listitem" }, [
899
+ (0, import_vue5.h)("article", {
543
900
  class: cx(
544
901
  !props.unstyled && "ckui-message-row",
545
902
  isCurrentUser && !props.unstyled && "ckui-message-row--outgoing",
@@ -549,16 +906,16 @@ var MessageListView = (0, import_vue4.defineComponent)({
549
906
  style: [props.styles?.message, props.styles?.[messagePart]],
550
907
  "data-message-id": message.id
551
908
  }, [
552
- (0, import_vue4.h)("div", { class: "ckui-message-bubble" }, [
553
- !isCurrentUser ? (0, import_vue4.h)("strong", { class: "ckui-message-sender" }, sender?.name || message.senderId) : null,
554
- message.text ? (0, import_vue4.h)("div", { class: "ckui-message-text" }, message.text) : null,
909
+ (0, import_vue5.h)("div", { class: "ckui-message-bubble" }, [
910
+ !isCurrentUser ? (0, import_vue5.h)("strong", { class: "ckui-message-sender" }, sender?.name || message.senderId) : null,
911
+ message.text ? (0, import_vue5.h)("div", { class: "ckui-message-text" }, message.text) : null,
555
912
  ...mediaNodes,
556
- (0, import_vue4.h)("time", { class: "ckui-message-time", datetime: message.createdAt.toISOString() }, [
557
- props.formatTime(message.createdAt),
558
- 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
913
+ (0, import_vue5.h)("span", { class: "ckui-message-time" }, [
914
+ 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
559
916
  ])
560
917
  ]),
561
- isCurrentUser ? slots["read-receipt"]?.(receiptSlotProps) ?? (0, import_vue4.h)("div", {
918
+ isCurrentUser && !isPending ? slots["read-receipt"]?.(receiptSlotProps) ?? (0, import_vue5.h)("div", {
562
919
  class: partClass("receipt", currentAppearance, "ckui-read-receipt"),
563
920
  style: partStyle("receipt", currentAppearance)
564
921
  }, readerIds.size > 0 ? `Read by ${readerIds.size}` : "Delivered") : null
@@ -569,34 +926,34 @@ var MessageListView = (0, import_vue4.defineComponent)({
569
926
  const currentAppearance = appearance();
570
927
  const children = [];
571
928
  if (props.isLoadingOlder) {
572
- children.push(slots["loading-older"]?.() ?? (0, import_vue4.h)("div", {
929
+ children.push(slots["loading-older"]?.() ?? (0, import_vue5.h)("div", {
573
930
  class: partClass("loading", currentAppearance, "ckui-inline-state"),
574
931
  style: partStyle("loading", currentAppearance),
575
932
  role: "status"
576
- }, [(0, import_vue4.h)(import_vue3.LoaderCircle, { class: "ckui-spin", "aria-hidden": "true" }), " Loading older messages\u2026"]));
933
+ }, [(0, import_vue5.h)(import_vue4.LoaderCircle, { class: "ckui-spin", "aria-hidden": "true" }), " Loading older messages\u2026"]));
577
934
  }
578
935
  if (props.error) {
579
936
  const retry = props.onLoadOlder ? () => {
580
937
  void requestOlder();
581
938
  } : void 0;
582
- children.push(slots.error?.({ error: props.error, ...retry ? { retry } : {} }) ?? (0, import_vue4.h)("div", {
939
+ children.push(slots.error?.({ error: props.error, ...retry ? { retry } : {} }) ?? (0, import_vue5.h)("div", {
583
940
  class: partClass("error", currentAppearance, "ckui-inline-state ckui-state--error"),
584
941
  style: partStyle("error", currentAppearance),
585
942
  role: "alert"
586
943
  }, [
587
- (0, import_vue4.h)("span", errorMessage(props.error)),
588
- retry ? (0, import_vue4.h)("button", { type: "button", class: "ckui-link-button", onClick: retry }, "Retry") : null
944
+ (0, import_vue5.h)("span", errorMessage(props.error)),
945
+ retry ? (0, import_vue5.h)("button", { type: "button", class: "ckui-link-button", onClick: retry }, "Retry") : null
589
946
  ]));
590
947
  }
591
948
  if (props.messages.length === 0 && !props.isLoadingOlder) {
592
- children.push(slots.empty?.() ?? (0, import_vue4.h)("div", {
949
+ children.push(slots.empty?.() ?? (0, import_vue5.h)("div", {
593
950
  class: partClass("empty", currentAppearance, "ckui-state"),
594
951
  style: partStyle("empty", currentAppearance)
595
- }, [(0, import_vue4.h)(import_vue3.MessageCircle, { "aria-hidden": "true" }), " No messages yet"]));
952
+ }, [(0, import_vue5.h)(import_vue4.MessageCircle, { "aria-hidden": "true" }), " No messages yet"]));
596
953
  } else {
597
954
  children.push(...props.messages.map(renderMessage));
598
955
  }
599
- return (0, import_vue4.h)("div", {
956
+ return (0, import_vue5.h)("div", {
600
957
  ...attrs,
601
958
  ref: (element) => {
602
959
  internalElement.value = element;
@@ -671,7 +1028,7 @@ function typingLabel(userIds, displayNameForUser) {
671
1028
  if (names.length === 2) return `${names[0]} and ${names[1]} are typing\u2026`;
672
1029
  return `${names[0]} and ${names.length - 1} others are typing\u2026`;
673
1030
  }
674
- var ConversationView = (0, import_vue6.defineComponent)({
1031
+ var ConversationView = (0, import_vue7.defineComponent)({
675
1032
  name: "ConversationView",
676
1033
  inheritAttrs: false,
677
1034
  props: viewProps,
@@ -686,8 +1043,8 @@ var ConversationView = (0, import_vue6.defineComponent)({
686
1043
  "update:modelValue"
687
1044
  ],
688
1045
  setup(props, { attrs, emit, slots }) {
689
- const internalDraft = (0, import_vue6.ref)(props.defaultDraft);
690
- const submitting = (0, import_vue6.ref)(false);
1046
+ const internalDraft = (0, import_vue7.ref)(props.defaultDraft);
1047
+ const submitting = (0, import_vue7.ref)(false);
691
1048
  const appearance = () => ({
692
1049
  density: props.density,
693
1050
  unstyled: props.unstyled,
@@ -737,23 +1094,23 @@ var ConversationView = (0, import_vue6.defineComponent)({
737
1094
  ...props.onBack ? { onBack: goBack } : {},
738
1095
  ...props.onRefresh ? { onRefresh: refresh } : {}
739
1096
  };
740
- return slots.header?.(slotProps) ?? (0, import_vue6.h)("header", {
1097
+ return slots.header?.(slotProps) ?? (0, import_vue7.h)("header", {
741
1098
  class: partClass("header", appearance(), "ckui-conversation-header"),
742
1099
  style: partStyle("header", appearance())
743
1100
  }, [
744
- props.onBack ? (0, import_vue6.h)("button", {
1101
+ props.onBack ? (0, import_vue7.h)("button", {
745
1102
  type: "button",
746
1103
  "aria-label": "Back",
747
1104
  onClick: goBack,
748
1105
  class: partClass("button", appearance(), "ckui-icon-button"),
749
1106
  style: partStyle("button", appearance())
750
- }, [(0, import_vue6.h)(import_vue5.ArrowLeft, { size: 20, "aria-hidden": "true" })]) : null,
751
- (0, import_vue6.h)(ConvoKitAvatar, { name: props.conversation.displayTitle, src: props.conversation.imageUrl }),
752
- (0, import_vue6.h)("div", { class: "ckui-conversation-header__body" }, [
753
- (0, import_vue6.h)("strong", props.conversation.displayTitle),
754
- (0, import_vue6.h)("span", `${props.conversation.participants.length} participant${props.conversation.participants.length === 1 ? "" : "s"}`)
1107
+ }, [(0, import_vue7.h)(import_vue6.ArrowLeft, { size: 20, "aria-hidden": "true" })]) : null,
1108
+ (0, import_vue7.h)(ConvoKitAvatar, { name: props.conversation.displayTitle, src: props.conversation.imageUrl }),
1109
+ (0, import_vue7.h)("div", { class: "ckui-conversation-header__body" }, [
1110
+ (0, import_vue7.h)("strong", props.conversation.displayTitle),
1111
+ (0, import_vue7.h)("span", `${props.conversation.participants.length} participant${props.conversation.participants.length === 1 ? "" : "s"}`)
755
1112
  ]),
756
- props.onRefresh ? (0, import_vue6.h)("button", {
1113
+ props.onRefresh ? (0, import_vue7.h)("button", {
757
1114
  type: "button",
758
1115
  "aria-label": "Refresh conversation",
759
1116
  onClick: () => {
@@ -761,12 +1118,12 @@ var ConversationView = (0, import_vue6.defineComponent)({
761
1118
  },
762
1119
  class: partClass("button", appearance(), "ckui-icon-button"),
763
1120
  style: partStyle("button", appearance())
764
- }, [(0, import_vue6.h)(import_vue5.RefreshCw, { size: 18, "aria-hidden": "true" })]) : null
1121
+ }, [(0, import_vue7.h)(import_vue6.RefreshCw, { size: 18, "aria-hidden": "true" })]) : null
765
1122
  ]);
766
1123
  };
767
1124
  const renderTyping = () => {
768
1125
  const slotProps = { userIds: props.typingUserIds, displayNameForUser: nameForUser };
769
- return slots["typing-indicator"]?.(slotProps) ?? (0, import_vue6.h)("div", {
1126
+ return slots["typing-indicator"]?.(slotProps) ?? (0, import_vue7.h)("div", {
770
1127
  class: partClass("typing", appearance(), "ckui-typing"),
771
1128
  style: partStyle("typing", appearance()),
772
1129
  "aria-live": "polite"
@@ -782,7 +1139,7 @@ var ConversationView = (0, import_vue6.defineComponent)({
782
1139
  },
783
1140
  ...props.onAddAttachment ? { addAttachment } : {}
784
1141
  };
785
- return slots.composer?.(slotProps) ?? (0, import_vue6.h)("form", {
1142
+ return slots.composer?.(slotProps) ?? (0, import_vue7.h)("form", {
786
1143
  class: partClass("composer", appearance(), "ckui-composer"),
787
1144
  style: partStyle("composer", appearance()),
788
1145
  onSubmit: (event) => {
@@ -790,14 +1147,14 @@ var ConversationView = (0, import_vue6.defineComponent)({
790
1147
  void submit();
791
1148
  }
792
1149
  }, [
793
- props.onAddAttachment ? (0, import_vue6.h)("button", {
1150
+ props.onAddAttachment ? (0, import_vue7.h)("button", {
794
1151
  type: "button",
795
1152
  "aria-label": "Add attachment",
796
1153
  onClick: addAttachment,
797
1154
  class: partClass("button", appearance(), "ckui-icon-button"),
798
1155
  style: partStyle("button", appearance())
799
- }, [(0, import_vue6.h)(import_vue5.Paperclip, { size: 20, "aria-hidden": "true" })]) : null,
800
- (0, import_vue6.h)("textarea", {
1156
+ }, [(0, import_vue7.h)(import_vue6.Paperclip, { size: 20, "aria-hidden": "true" })]) : null,
1157
+ (0, import_vue7.h)("textarea", {
801
1158
  ...props.composerProps,
802
1159
  rows: props.composerProps?.rows ?? 1,
803
1160
  placeholder: props.composerPlaceholder,
@@ -820,38 +1177,38 @@ var ConversationView = (0, import_vue6.defineComponent)({
820
1177
  }
821
1178
  }
822
1179
  }),
823
- (0, import_vue6.h)("button", {
1180
+ (0, import_vue7.h)("button", {
824
1181
  type: "submit",
825
1182
  "aria-label": "Send message",
826
1183
  disabled: !draft().trim() || props.isSending || submitting.value,
827
1184
  class: partClass("button", appearance(), "ckui-send-button"),
828
1185
  style: partStyle("button", appearance())
829
- }, [props.isSending || submitting.value ? (0, import_vue6.h)(import_vue5.LoaderCircle, { class: "ckui-spin", size: 18, "aria-hidden": "true" }) : (0, import_vue6.h)(import_vue5.Send, { size: 18, "aria-hidden": "true" })])
1186
+ }, [props.isSending || submitting.value ? (0, import_vue7.h)(import_vue6.LoaderCircle, { class: "ckui-spin", size: 18, "aria-hidden": "true" }) : (0, import_vue7.h)(import_vue6.Send, { size: 18, "aria-hidden": "true" })])
830
1187
  ]);
831
1188
  };
832
1189
  return () => {
833
1190
  if (props.isInitialLoading && props.messages.length === 0) {
834
- return (0, import_vue6.h)("div", {
1191
+ return (0, import_vue7.h)("div", {
835
1192
  ...attrs,
836
1193
  class: cx(!props.unstyled && "ckui ckui-conversation", props.classNames?.root, attrs.class),
837
1194
  style: [props.styles?.root, attrs.style],
838
1195
  "data-density": props.density
839
- }, slots.loading?.() ?? (0, import_vue6.h)("div", {
1196
+ }, slots.loading?.() ?? (0, import_vue7.h)("div", {
840
1197
  class: partClass("loading", appearance(), "ckui-state"),
841
1198
  style: partStyle("loading", appearance()),
842
1199
  role: "status"
843
- }, [(0, import_vue6.h)(import_vue5.LoaderCircle, { class: "ckui-spin", "aria-hidden": "true" }), " Loading conversation\u2026"]));
1200
+ }, [(0, import_vue7.h)(import_vue6.LoaderCircle, { class: "ckui-spin", "aria-hidden": "true" }), " Loading conversation\u2026"]));
844
1201
  }
845
1202
  const children = [renderHeader()];
846
1203
  if (props.error) {
847
1204
  const retry = props.onRefresh ? () => {
848
1205
  void refresh();
849
1206
  } : void 0;
850
- children.push(slots.error?.({ error: props.error, ...retry ? { retry } : {} }) ?? (0, import_vue6.h)("div", {
1207
+ children.push(slots.error?.({ error: props.error, ...retry ? { retry } : {} }) ?? (0, import_vue7.h)("div", {
851
1208
  class: partClass("error", appearance(), "ckui-conversation-error"),
852
1209
  style: partStyle("error", appearance()),
853
1210
  role: "alert"
854
- }, [(0, import_vue6.h)("span", errorMessage(props.error)), retry ? (0, import_vue6.h)("button", { type: "button", class: "ckui-link-button", onClick: retry }, "Retry") : null]));
1211
+ }, [(0, import_vue7.h)("span", errorMessage(props.error)), retry ? (0, import_vue7.h)("button", { type: "button", class: "ckui-link-button", onClick: retry }, "Retry") : null]));
855
1212
  }
856
1213
  const messageSlots = {
857
1214
  ...slots.message ? { message: slots.message } : {},
@@ -861,7 +1218,7 @@ var ConversationView = (0, import_vue6.defineComponent)({
861
1218
  ...slots["loading-older"] ? { "loading-older": slots["loading-older"] } : {},
862
1219
  ...slots["message-error"] ? { error: slots["message-error"] } : {}
863
1220
  };
864
- children.push((0, import_vue6.h)(MessageListView, {
1221
+ children.push((0, import_vue7.h)(MessageListView, {
865
1222
  conversation: props.conversation,
866
1223
  messages: props.messages,
867
1224
  currentUserId: props.currentUserId,
@@ -885,7 +1242,7 @@ var ConversationView = (0, import_vue6.defineComponent)({
885
1242
  unstyled: props.unstyled
886
1243
  }, messageSlots));
887
1244
  children.push(renderTyping(), renderComposer());
888
- return (0, import_vue6.h)("section", {
1245
+ return (0, import_vue7.h)("section", {
889
1246
  ...attrs,
890
1247
  class: cx(!props.unstyled && "ckui ckui-conversation", props.classNames?.root, attrs.class),
891
1248
  style: [props.styles?.root, attrs.style],
@@ -895,7 +1252,7 @@ var ConversationView = (0, import_vue6.defineComponent)({
895
1252
  };
896
1253
  }
897
1254
  });
898
- var Conversation = (0, import_vue6.defineComponent)({
1255
+ var Conversation = (0, import_vue7.defineComponent)({
899
1256
  name: "Conversation",
900
1257
  inheritAttrs: false,
901
1258
  props: {
@@ -925,7 +1282,7 @@ var Conversation = (0, import_vue6.defineComponent)({
925
1282
  autoLoad: props.autoLoad
926
1283
  });
927
1284
  expose({ controller });
928
- (0, import_vue6.watchEffect)(() => {
1285
+ (0, import_vue7.watchEffect)(() => {
929
1286
  emit("controller-change", controller);
930
1287
  });
931
1288
  return () => {
@@ -934,16 +1291,16 @@ var Conversation = (0, import_vue6.defineComponent)({
934
1291
  const retry = () => {
935
1292
  void controller.refresh();
936
1293
  };
937
- return (0, import_vue6.h)("div", {
1294
+ return (0, import_vue7.h)("div", {
938
1295
  ...attrs,
939
1296
  class: cx(!props.unstyled && "ckui ckui-conversation", props.classNames?.root, attrs.class),
940
1297
  style: [props.styles?.root, attrs.style],
941
1298
  "data-density": props.density
942
- }, controller.error.value ? slots.error?.({ error: controller.error.value, retry }) ?? (0, import_vue6.h)("div", { class: "ckui-state ckui-state--error", role: "alert" }, [
943
- (0, import_vue6.h)("span", errorMessage(controller.error.value)),
944
- (0, import_vue6.h)("button", { type: "button", class: "ckui-link-button", onClick: retry }, "Try again")
945
- ]) : slots.loading?.() ?? (0, import_vue6.h)("div", { class: "ckui-state", role: "status" }, [
946
- (0, import_vue6.h)(import_vue5.LoaderCircle, { class: "ckui-spin", "aria-hidden": "true" }),
1299
+ }, controller.error.value ? slots.error?.({ error: controller.error.value, retry }) ?? (0, import_vue7.h)("div", { class: "ckui-state ckui-state--error", role: "alert" }, [
1300
+ (0, import_vue7.h)("span", errorMessage(controller.error.value)),
1301
+ (0, import_vue7.h)("button", { type: "button", class: "ckui-link-button", onClick: retry }, "Try again")
1302
+ ]) : slots.loading?.() ?? (0, import_vue7.h)("div", { class: "ckui-state", role: "status" }, [
1303
+ (0, import_vue7.h)(import_vue6.LoaderCircle, { class: "ckui-spin", "aria-hidden": "true" }),
947
1304
  " Loading conversation\u2026"
948
1305
  ]));
949
1306
  }
@@ -972,7 +1329,7 @@ var Conversation = (0, import_vue6.defineComponent)({
972
1329
  error: _error,
973
1330
  ...forwarded
974
1331
  } = props;
975
- return (0, import_vue6.h)(ConversationView, {
1332
+ return (0, import_vue7.h)(ConversationView, {
976
1333
  ...attrs,
977
1334
  ...forwarded,
978
1335
  conversation: loadedConversation,
@@ -1011,110 +1368,192 @@ var Conversation = (0, import_vue6.defineComponent)({
1011
1368
  });
1012
1369
 
1013
1370
  // src/components/conversation-list.ts
1014
- var import_vue8 = require("@lucide/vue");
1015
- var import_vue9 = require("vue");
1371
+ var import_vue9 = require("@lucide/vue");
1372
+ var import_vue10 = require("vue");
1016
1373
 
1017
1374
  // src/composables/use-conversation-list.ts
1018
- var import_vue7 = require("vue");
1019
- function useConversationList(options) {
1020
- const pageSize = options.pageSize ?? 30;
1021
- if (!Number.isInteger(pageSize) || pageSize <= 0) throw new RangeError("pageSize must be a positive integer");
1022
- const source = (0, import_vue7.shallowRef)([]);
1023
- const filter = (0, import_vue7.shallowRef)(options.initialFilter ?? {});
1024
- const isInitialLoading = (0, import_vue7.ref)(false);
1025
- const isLoadingMore = (0, import_vue7.ref)(false);
1026
- const hasMore = (0, import_vue7.ref)(true);
1027
- const hasLoaded = (0, import_vue7.ref)(false);
1028
- const error = (0, import_vue7.shallowRef)(null);
1029
- const conversations = (0, import_vue7.computed)(() => applyConversationFilter(source.value, filter.value));
1030
- let offset = 0;
1031
- let generation = 0;
1032
- let disposed = false;
1033
- const loadUntilVisible = async (activeGeneration, activeFilter) => {
1034
- const visibleBefore = applyConversationFilter(source.value, activeFilter).length;
1035
- while (true) {
1036
- const request = { limit: pageSize, offset, filter: activeFilter };
1037
- const page = options.pageLoader ? await options.pageLoader(request) : await (0, import_vue7.toValue)(options.client).getConversations({
1038
- limit: pageSize,
1039
- offset: request.offset,
1040
- archived: activeFilter.archived ?? false
1375
+ var import_vue8 = require("vue");
1376
+
1377
+ // src/conversation-list-store.ts
1378
+ function blank2(filter) {
1379
+ return { conversations: [], filter, isInitialLoading: false, isLoadingMore: false, hasMore: true, hasLoaded: false, error: null };
1380
+ }
1381
+ var ConversationListStore = class {
1382
+ constructor(options) {
1383
+ this.options = options;
1384
+ this.owner = options.client.sessionIdentity;
1385
+ this.pageSize = options.pageSize ?? 30;
1386
+ if (!Number.isInteger(this.pageSize) || this.pageSize < 1 || this.pageSize > 100) {
1387
+ throw new RangeError("pageSize must be an integer between 1 and 100");
1388
+ }
1389
+ this.state = blank2(options.initialFilter ?? {});
1390
+ }
1391
+ options;
1392
+ owner;
1393
+ pageSize;
1394
+ state;
1395
+ source = [];
1396
+ offset = 0;
1397
+ generation = 0;
1398
+ lifecycleGeneration = 0;
1399
+ disposed = true;
1400
+ lifecycle;
1401
+ listeners = /* @__PURE__ */ new Set();
1402
+ getSnapshot = () => this.state;
1403
+ subscribe = (listener) => {
1404
+ this.listeners.add(listener);
1405
+ return () => {
1406
+ this.listeners.delete(listener);
1407
+ };
1408
+ };
1409
+ patch(patch) {
1410
+ this.state = { ...this.state, ...patch };
1411
+ for (const listener of this.listeners) listener();
1412
+ }
1413
+ alive(generation = this.generation) {
1414
+ return !this.disposed && generation === this.generation && this.owner !== null && this.options.client.sessionIdentity === this.owner;
1415
+ }
1416
+ start = (autoLoad = true) => {
1417
+ if (!this.owner || this.options.client.sessionIdentity !== this.owner) return;
1418
+ this.disposed = false;
1419
+ const lifecycleGeneration = ++this.lifecycleGeneration;
1420
+ try {
1421
+ const subscription = this.options.client.onConnectionEvent({
1422
+ onEvent: () => {
1423
+ },
1424
+ onSessionEnded: () => {
1425
+ if (!this.disposed && lifecycleGeneration === this.lifecycleGeneration) this.dispose();
1426
+ }
1041
1427
  });
1042
- if (disposed || activeGeneration !== generation) return;
1043
- offset += page.length;
1044
- hasMore.value = page.length === pageSize;
1045
- source.value = mergeConversations(source.value, page);
1046
- if (!hasMore.value || applyConversationFilter(source.value, activeFilter).length > visibleBefore) return;
1428
+ if (this.alive()) this.lifecycle = subscription;
1429
+ else void subscription.unsubscribe().catch(() => void 0);
1430
+ if (autoLoad) void this.loadInitial();
1431
+ } catch (cause) {
1432
+ if (this.alive()) this.patch({ error: cause });
1047
1433
  }
1048
1434
  };
1049
- const loadInitialFor = async (activeFilter = filter.value) => {
1050
- const activeGeneration = ++generation;
1051
- source.value = [];
1052
- offset = 0;
1053
- hasMore.value = true;
1054
- isInitialLoading.value = true;
1055
- isLoadingMore.value = false;
1056
- error.value = null;
1435
+ dispose = () => {
1436
+ this.disposed = true;
1437
+ this.generation++;
1438
+ this.lifecycleGeneration++;
1439
+ const subscription = this.lifecycle;
1440
+ this.lifecycle = void 0;
1441
+ if (subscription) void subscription.unsubscribe().catch(() => void 0);
1442
+ this.source = [];
1443
+ this.offset = 0;
1444
+ this.patch(blank2(this.state.filter));
1445
+ };
1446
+ fail(cause, generation) {
1447
+ if (!this.alive(generation)) return;
1448
+ const status = typeof cause === "object" && cause !== null && "status" in cause ? cause.status : void 0;
1449
+ if (status === 401 || status === 403 || status === 404) {
1450
+ this.source = [];
1451
+ this.offset = 0;
1452
+ this.patch({ conversations: [], hasMore: false });
1453
+ }
1454
+ this.patch({ error: cause });
1455
+ }
1456
+ async loadUntilVisible(generation, filter) {
1457
+ const visibleBefore = applyConversationFilter(this.source, filter).length;
1458
+ while (this.alive(generation)) {
1459
+ const request = { limit: this.pageSize, offset: this.offset, filter };
1460
+ const page = this.options.pageLoader ? await this.options.pageLoader(request) : await this.options.client.getConversations({ limit: this.pageSize, offset: this.offset, archived: filter.archived ?? false });
1461
+ if (!this.alive(generation)) return;
1462
+ if (page.length > this.pageSize || page.some((conversation) => !conversation.id.trim())) {
1463
+ throw new Error("Invalid conversation page");
1464
+ }
1465
+ const merged = mergeConversations(this.source, page);
1466
+ if (page.length === this.pageSize && merged.length === this.source.length) {
1467
+ throw new Error("Conversation pagination did not advance");
1468
+ }
1469
+ this.offset += page.length;
1470
+ this.source = merged;
1471
+ const visible = applyConversationFilter(merged, filter);
1472
+ const hasMore = page.length === this.pageSize;
1473
+ this.patch({ conversations: visible, hasMore });
1474
+ if (!hasMore || visible.length > visibleBefore) return;
1475
+ }
1476
+ }
1477
+ loadInitial = async () => {
1478
+ if (!this.alive()) return;
1479
+ const generation = ++this.generation;
1480
+ this.source = [];
1481
+ this.offset = 0;
1482
+ this.patch({ ...blank2(this.state.filter), isInitialLoading: true });
1057
1483
  try {
1058
- await loadUntilVisible(activeGeneration, activeFilter);
1484
+ await this.loadUntilVisible(generation, this.state.filter);
1059
1485
  } catch (cause) {
1060
- if (!disposed && activeGeneration === generation) error.value = cause;
1486
+ this.fail(cause, generation);
1061
1487
  } finally {
1062
- if (!disposed && activeGeneration === generation) {
1063
- isInitialLoading.value = false;
1064
- hasLoaded.value = true;
1065
- }
1488
+ if (this.alive(generation)) this.patch({ isInitialLoading: false, hasLoaded: true });
1066
1489
  }
1067
1490
  };
1068
- const loadMore = async () => {
1069
- if (isInitialLoading.value || isLoadingMore.value || !hasMore.value) return;
1070
- const activeGeneration = generation;
1071
- isLoadingMore.value = true;
1072
- error.value = null;
1491
+ refresh = () => this.loadInitial();
1492
+ loadMore = async () => {
1493
+ if (!this.alive() || this.state.isInitialLoading || this.state.isLoadingMore || !this.state.hasMore) return;
1494
+ const generation = this.generation;
1495
+ this.patch({ isLoadingMore: true, error: null });
1073
1496
  try {
1074
- await loadUntilVisible(activeGeneration, filter.value);
1497
+ await this.loadUntilVisible(generation, this.state.filter);
1075
1498
  } catch (cause) {
1076
- if (!disposed && activeGeneration === generation) error.value = cause;
1499
+ this.fail(cause, generation);
1077
1500
  } finally {
1078
- if (!disposed && activeGeneration === generation) isLoadingMore.value = false;
1501
+ if (this.alive(generation)) this.patch({ isLoadingMore: false });
1079
1502
  }
1080
1503
  };
1081
- const setFilter = async (nextFilter) => {
1082
- const previousArchived = filter.value.archived ?? false;
1083
- filter.value = nextFilter;
1084
- error.value = null;
1085
- if ((nextFilter.archived ?? false) !== previousArchived || !hasLoaded.value) {
1086
- await loadInitialFor(nextFilter);
1087
- } else if (applyConversationFilter(source.value, nextFilter).length === 0 && hasMore.value) {
1088
- await loadMore();
1089
- }
1504
+ setFilter = async (filter) => {
1505
+ if (!this.alive()) return;
1506
+ const reload = this.options.pageLoader || !this.state.hasLoaded || this.state.isInitialLoading || this.state.isLoadingMore || (filter.archived ?? false) !== (this.state.filter.archived ?? false);
1507
+ this.patch({ filter, conversations: applyConversationFilter(this.source, filter), error: null });
1508
+ if (reload) await this.loadInitial();
1509
+ else if (!this.state.conversations.length && this.state.hasMore) await this.loadMore();
1090
1510
  };
1091
- const setQuery = (query) => setFilter({ ...filter.value, query });
1511
+ setQuery = (query) => this.setFilter({ ...this.state.filter, query });
1512
+ };
1513
+
1514
+ // src/composables/use-conversation-list.ts
1515
+ function useConversationList(options) {
1516
+ const createStore = () => new ConversationListStore({ ...options, client: (0, import_vue8.toValue)(options.client) });
1517
+ let store = createStore();
1518
+ const snapshot = (0, import_vue8.shallowRef)(store.getSnapshot());
1519
+ let unsubscribe;
1520
+ const stop = (0, import_vue8.watch)(
1521
+ () => [(0, import_vue8.toValue)(options.client), (0, import_vue8.toValue)(options.client).sessionIdentity],
1522
+ () => {
1523
+ store.dispose();
1524
+ unsubscribe?.();
1525
+ store = createStore();
1526
+ snapshot.value = store.getSnapshot();
1527
+ unsubscribe = store.subscribe(() => {
1528
+ snapshot.value = store.getSnapshot();
1529
+ });
1530
+ store.start(options.autoLoad ?? true);
1531
+ },
1532
+ { immediate: true, flush: "sync" }
1533
+ );
1534
+ const field = (key) => (0, import_vue8.computed)(() => snapshot.value[key]);
1092
1535
  const dispose = async () => {
1093
- disposed = true;
1094
- generation += 1;
1536
+ stop();
1537
+ store.dispose();
1538
+ unsubscribe?.();
1539
+ unsubscribe = void 0;
1095
1540
  };
1096
- if (options.autoLoad ?? true) {
1097
- (0, import_vue7.watch)(() => (0, import_vue7.toValue)(options.client), () => {
1098
- disposed = false;
1099
- void loadInitialFor(filter.value);
1100
- }, { immediate: true });
1101
- }
1102
- if ((0, import_vue7.getCurrentScope)()) (0, import_vue7.onScopeDispose)(() => {
1541
+ if ((0, import_vue8.getCurrentScope)()) (0, import_vue8.onScopeDispose)(() => {
1103
1542
  void dispose();
1104
1543
  });
1105
1544
  return {
1106
- conversations,
1107
- filter,
1108
- isInitialLoading,
1109
- isLoadingMore,
1110
- hasMore,
1111
- hasLoaded,
1112
- error,
1113
- loadInitial: loadInitialFor,
1114
- refresh: loadInitialFor,
1115
- loadMore,
1116
- setFilter,
1117
- setQuery,
1545
+ conversations: field("conversations"),
1546
+ filter: field("filter"),
1547
+ isInitialLoading: field("isInitialLoading"),
1548
+ isLoadingMore: field("isLoadingMore"),
1549
+ hasMore: field("hasMore"),
1550
+ hasLoaded: field("hasLoaded"),
1551
+ error: field("error"),
1552
+ loadInitial: () => store.loadInitial(),
1553
+ refresh: () => store.refresh(),
1554
+ loadMore: () => store.loadMore(),
1555
+ setFilter: (filter) => store.setFilter(filter),
1556
+ setQuery: (query) => store.setQuery(query),
1118
1557
  dispose
1119
1558
  };
1120
1559
  }
@@ -1141,7 +1580,7 @@ var listViewProps = {
1141
1580
  paginationThreshold: { type: Number, default: 240 },
1142
1581
  ariaLabel: { type: String, default: "Conversations" }
1143
1582
  };
1144
- var ConversationListView = (0, import_vue9.defineComponent)({
1583
+ var ConversationListView = (0, import_vue10.defineComponent)({
1145
1584
  name: "ConversationListView",
1146
1585
  inheritAttrs: false,
1147
1586
  props: listViewProps,
@@ -1151,7 +1590,7 @@ var ConversationListView = (0, import_vue9.defineComponent)({
1151
1590
  "load-more": () => true
1152
1591
  },
1153
1592
  setup(props, { attrs, emit, slots }) {
1154
- const internalElement = (0, import_vue9.ref)(null);
1593
+ const internalElement = (0, import_vue10.ref)(null);
1155
1594
  let requestInFlight = false;
1156
1595
  let lastRequestedLength = null;
1157
1596
  const appearance = () => ({
@@ -1181,36 +1620,36 @@ var ConversationListView = (0, import_vue9.defineComponent)({
1181
1620
  const renderContent = () => {
1182
1621
  const currentAppearance = appearance();
1183
1622
  if (props.isInitialLoading && props.conversations.length === 0) {
1184
- return slots["initial-loading"]?.() ?? (0, import_vue9.h)("div", {
1623
+ return slots["initial-loading"]?.() ?? (0, import_vue10.h)("div", {
1185
1624
  class: partClass("loading", currentAppearance, "ckui-state"),
1186
1625
  style: partStyle("loading", currentAppearance),
1187
1626
  role: "status"
1188
- }, [(0, import_vue9.h)(import_vue8.LoaderCircle, { class: "ckui-spin", "aria-hidden": "true" }), " Loading conversations\u2026"]);
1627
+ }, [(0, import_vue10.h)(import_vue9.LoaderCircle, { class: "ckui-spin", "aria-hidden": "true" }), " Loading conversations\u2026"]);
1189
1628
  }
1190
1629
  if (props.error && props.conversations.length === 0) {
1191
1630
  const retry = props.onRefresh ? () => {
1192
1631
  void refresh();
1193
1632
  } : void 0;
1194
- return slots.error?.({ error: props.error, ...retry ? { retry } : {} }) ?? (0, import_vue9.h)("div", {
1633
+ return slots.error?.({ error: props.error, ...retry ? { retry } : {} }) ?? (0, import_vue10.h)("div", {
1195
1634
  class: partClass("error", currentAppearance, "ckui-state ckui-state--error"),
1196
1635
  style: partStyle("error", currentAppearance),
1197
1636
  role: "alert"
1198
1637
  }, [
1199
- (0, import_vue9.h)("span", errorMessage(props.error)),
1200
- retry ? (0, import_vue9.h)("button", { type: "button", class: "ckui-link-button", onClick: retry }, "Try again") : null
1638
+ (0, import_vue10.h)("span", errorMessage(props.error)),
1639
+ retry ? (0, import_vue10.h)("button", { type: "button", class: "ckui-link-button", onClick: retry }, "Try again") : null
1201
1640
  ]);
1202
1641
  }
1203
1642
  if (props.conversations.length === 0) {
1204
- return slots.empty?.() ?? (0, import_vue9.h)("div", {
1643
+ return slots.empty?.() ?? (0, import_vue10.h)("div", {
1205
1644
  class: partClass("empty", currentAppearance, "ckui-state"),
1206
1645
  style: partStyle("empty", currentAppearance)
1207
- }, [(0, import_vue9.h)(import_vue8.Inbox, { "aria-hidden": "true" }), " No conversations yet"]);
1646
+ }, [(0, import_vue10.h)(import_vue9.Inbox, { "aria-hidden": "true" }), " No conversations yet"]);
1208
1647
  }
1209
1648
  const children = props.conversations.flatMap((conversation, index) => {
1210
1649
  const selected = props.selectedConversationId === conversation.id;
1211
1650
  const select = () => selectConversation(conversation);
1212
1651
  const slotProps = { conversation, index, selected, select };
1213
- const item = slots["conversation-item"]?.(slotProps) ?? (0, import_vue9.h)("button", {
1652
+ const item = slots["conversation-item"]?.(slotProps) ?? (0, import_vue10.h)("button", {
1214
1653
  type: "button",
1215
1654
  "data-selected": selected || void 0,
1216
1655
  "aria-current": selected ? "true" : void 0,
@@ -1218,54 +1657,54 @@ var ConversationListView = (0, import_vue9.defineComponent)({
1218
1657
  class: partClass("listItem", currentAppearance, "ckui-conversation-item"),
1219
1658
  style: partStyle("listItem", currentAppearance)
1220
1659
  }, [
1221
- (0, import_vue9.h)(ConvoKitAvatar, {
1660
+ (0, import_vue10.h)(ConvoKitAvatar, {
1222
1661
  name: conversation.displayTitle,
1223
1662
  src: conversation.imageUrl,
1224
1663
  class: partClass("avatar", currentAppearance, ""),
1225
1664
  style: partStyle("avatar", currentAppearance)
1226
1665
  }),
1227
- (0, import_vue9.h)("span", { class: "ckui-conversation-item__body" }, [
1228
- (0, import_vue9.h)("strong", conversation.displayTitle),
1229
- (0, import_vue9.h)("span", conversation.participants.map((participant) => participant.name).join(", ") || conversation.description || "No participants")
1666
+ (0, import_vue10.h)("span", { class: "ckui-conversation-item__body" }, [
1667
+ (0, import_vue10.h)("strong", conversation.displayTitle),
1668
+ (0, import_vue10.h)("span", conversation.participants.map((participant) => participant.name).join(", ") || conversation.description || "No participants")
1230
1669
  ]),
1231
- (0, import_vue9.h)(import_vue8.ChevronRight, { size: 18, "aria-hidden": "true" })
1670
+ (0, import_vue10.h)(import_vue9.ChevronRight, { size: 18, "aria-hidden": "true" })
1232
1671
  ]);
1233
- const nodes = [(0, import_vue9.h)("div", { key: conversation.id, role: "listitem" }, [item])];
1672
+ const nodes = [(0, import_vue10.h)("div", { key: conversation.id, role: "listitem" }, [item])];
1234
1673
  if (index < props.conversations.length - 1) {
1235
- nodes.push((0, import_vue9.h)("div", { key: `${conversation.id}-separator` }, slots.separator?.({ index }) ?? (0, import_vue9.h)("div", { class: "ckui-separator" })));
1674
+ nodes.push((0, import_vue10.h)("div", { key: `${conversation.id}-separator` }, slots.separator?.({ index }) ?? (0, import_vue10.h)("div", { class: "ckui-separator" })));
1236
1675
  }
1237
1676
  return nodes;
1238
1677
  });
1239
1678
  if (props.error) {
1240
- children.push(slots.error?.({ error: props.error, retry: requestMore }) ?? (0, import_vue9.h)("div", {
1679
+ children.push(slots.error?.({ error: props.error, retry: requestMore }) ?? (0, import_vue10.h)("div", {
1241
1680
  class: partClass("error", currentAppearance, "ckui-inline-state ckui-state--error"),
1242
1681
  style: partStyle("error", currentAppearance),
1243
1682
  role: "alert"
1244
- }, [(0, import_vue9.h)("span", errorMessage(props.error)), (0, import_vue9.h)("button", { type: "button", class: "ckui-link-button", onClick: () => {
1683
+ }, [(0, import_vue10.h)("span", errorMessage(props.error)), (0, import_vue10.h)("button", { type: "button", class: "ckui-link-button", onClick: () => {
1245
1684
  void requestMore();
1246
1685
  } }, "Retry")]));
1247
1686
  } else if (props.isLoadingMore) {
1248
- children.push(slots["load-more"]?.() ?? (0, import_vue9.h)("div", {
1687
+ children.push(slots["load-more"]?.() ?? (0, import_vue10.h)("div", {
1249
1688
  class: partClass("loading", currentAppearance, "ckui-inline-state"),
1250
1689
  style: partStyle("loading", currentAppearance),
1251
1690
  role: "status"
1252
- }, [(0, import_vue9.h)(import_vue8.LoaderCircle, { class: "ckui-spin", "aria-hidden": "true" }), " Loading more\u2026"]));
1691
+ }, [(0, import_vue10.h)(import_vue9.LoaderCircle, { class: "ckui-spin", "aria-hidden": "true" }), " Loading more\u2026"]));
1253
1692
  }
1254
- return (0, import_vue9.h)("div", {
1693
+ return (0, import_vue10.h)("div", {
1255
1694
  role: "list",
1256
1695
  class: partClass("list", currentAppearance, "ckui-conversation-list__items"),
1257
1696
  style: partStyle("list", currentAppearance)
1258
1697
  }, children);
1259
1698
  };
1260
- return () => (0, import_vue9.h)("div", {
1699
+ return () => (0, import_vue10.h)("div", {
1261
1700
  ...attrs,
1262
1701
  class: cx(!props.unstyled && "ckui ckui-conversation-list", props.classNames?.root, attrs.class),
1263
1702
  style: [props.styles?.root, attrs.style],
1264
1703
  "data-density": props.density
1265
1704
  }, [
1266
- props.onRefresh ? (0, import_vue9.h)("div", { class: "ckui-conversation-list__toolbar" }, [
1267
- (0, import_vue9.h)("span", props.ariaLabel),
1268
- (0, import_vue9.h)("button", {
1705
+ props.onRefresh ? (0, import_vue10.h)("div", { class: "ckui-conversation-list__toolbar" }, [
1706
+ (0, import_vue10.h)("span", props.ariaLabel),
1707
+ (0, import_vue10.h)("button", {
1269
1708
  type: "button",
1270
1709
  "aria-label": "Refresh conversations",
1271
1710
  class: partClass("button", appearance(), "ckui-icon-button"),
@@ -1273,9 +1712,9 @@ var ConversationListView = (0, import_vue9.defineComponent)({
1273
1712
  onClick: () => {
1274
1713
  void refresh();
1275
1714
  }
1276
- }, [(0, import_vue9.h)(import_vue8.RefreshCw, { size: 17, "aria-hidden": "true" })])
1715
+ }, [(0, import_vue10.h)(import_vue9.RefreshCw, { size: 17, "aria-hidden": "true" })])
1277
1716
  ]) : null,
1278
- (0, import_vue9.h)("div", {
1717
+ (0, import_vue10.h)("div", {
1279
1718
  ref: (element) => {
1280
1719
  internalElement.value = element;
1281
1720
  if (props.scrollElement) props.scrollElement.value = element;
@@ -1293,7 +1732,7 @@ var ConversationListView = (0, import_vue9.defineComponent)({
1293
1732
  ]);
1294
1733
  }
1295
1734
  });
1296
- var ConversationList = (0, import_vue9.defineComponent)({
1735
+ var ConversationList = (0, import_vue10.defineComponent)({
1297
1736
  name: "ConversationList",
1298
1737
  inheritAttrs: false,
1299
1738
  props: {
@@ -1316,7 +1755,7 @@ var ConversationList = (0, import_vue9.defineComponent)({
1316
1755
  autoLoad: props.autoLoad
1317
1756
  });
1318
1757
  expose({ controller });
1319
- (0, import_vue9.watchEffect)(() => {
1758
+ (0, import_vue10.watchEffect)(() => {
1320
1759
  emit("controller-change", controller);
1321
1760
  });
1322
1761
  return () => {
@@ -1336,7 +1775,7 @@ var ConversationList = (0, import_vue9.defineComponent)({
1336
1775
  error: _error,
1337
1776
  ...forwarded
1338
1777
  } = props;
1339
- return (0, import_vue9.h)(ConversationListView, {
1778
+ return (0, import_vue10.h)(ConversationListView, {
1340
1779
  ...attrs,
1341
1780
  ...forwarded,
1342
1781
  conversations: controller.conversations.value,
@@ -1355,7 +1794,7 @@ var ConversationList = (0, import_vue9.defineComponent)({
1355
1794
  });
1356
1795
 
1357
1796
  // src/theme.ts
1358
- var import_vue10 = require("vue");
1797
+ var import_vue11 = require("vue");
1359
1798
  var defaultConvoKitTheme = {
1360
1799
  background: "#fafafa",
1361
1800
  surface: "#ffffff",
@@ -1372,8 +1811,8 @@ var defaultConvoKitTheme = {
1372
1811
  fontFamily: 'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'
1373
1812
  };
1374
1813
  var themeKey = /* @__PURE__ */ Symbol("ConvoKitTheme");
1375
- var defaultThemeRef = (0, import_vue10.computed)(() => defaultConvoKitTheme);
1376
- var ConvoKitThemeProvider = (0, import_vue10.defineComponent)({
1814
+ var defaultThemeRef = (0, import_vue11.computed)(() => defaultConvoKitTheme);
1815
+ var ConvoKitThemeProvider = (0, import_vue11.defineComponent)({
1377
1816
  name: "ConvoKitThemeProvider",
1378
1817
  inheritAttrs: false,
1379
1818
  props: {
@@ -1382,9 +1821,9 @@ var ConvoKitThemeProvider = (0, import_vue10.defineComponent)({
1382
1821
  style: { type: [String, Array, Object], default: void 0 }
1383
1822
  },
1384
1823
  setup(props, { attrs, slots }) {
1385
- const parent = (0, import_vue10.inject)(themeKey, defaultThemeRef);
1386
- const value = (0, import_vue10.computed)(() => ({ ...parent.value, ...props.theme }));
1387
- (0, import_vue10.provide)(themeKey, value);
1824
+ const parent = (0, import_vue11.inject)(themeKey, defaultThemeRef);
1825
+ const value = (0, import_vue11.computed)(() => ({ ...parent.value, ...props.theme }));
1826
+ (0, import_vue11.provide)(themeKey, value);
1388
1827
  return () => {
1389
1828
  const theme = value.value;
1390
1829
  const variables = {
@@ -1402,7 +1841,7 @@ var ConvoKitThemeProvider = (0, import_vue10.defineComponent)({
1402
1841
  "--ckui-avatar-size": theme.avatarSize,
1403
1842
  "--ckui-font": theme.fontFamily
1404
1843
  };
1405
- return (0, import_vue10.h)("div", {
1844
+ return (0, import_vue11.h)("div", {
1406
1845
  ...attrs,
1407
1846
  class: cx("ckui-theme", props.class, attrs.class),
1408
1847
  style: [variables, props.style, attrs.style]
@@ -1411,7 +1850,7 @@ var ConvoKitThemeProvider = (0, import_vue10.defineComponent)({
1411
1850
  }
1412
1851
  });
1413
1852
  function useConvoKitTheme() {
1414
- return (0, import_vue10.inject)(themeKey, defaultThemeRef);
1853
+ return (0, import_vue11.inject)(themeKey, defaultThemeRef);
1415
1854
  }
1416
1855
  // Annotate the CommonJS export names for ESM import in node:
1417
1856
  0 && (module.exports = {
@@ -1427,6 +1866,7 @@ function useConvoKitTheme() {
1427
1866
  defaultConvoKitTheme,
1428
1867
  defaultReadersResolver,
1429
1868
  formatFileSize,
1869
+ isConvoKitPendingMessage,
1430
1870
  matchesConversation,
1431
1871
  mergeConversations,
1432
1872
  mergeMessages,