@convokitapp/vue-ui 0.2.2 → 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
@@ -46,12 +46,21 @@ module.exports = __toCommonJS(index_exports);
46
46
  // src/client.ts
47
47
  function createConvoKitUiClient(client) {
48
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
+ }),
49
57
  get currentUserId() {
50
- return client.currentUserId;
58
+ return client.connected ? client.currentUserId : "";
51
59
  },
52
60
  getConversations: (options) => client.getConversations(options),
53
61
  getConversation: (conversationId) => client.getConversation(conversationId),
54
62
  getMessages: (options) => client.getMessages(options),
63
+ getMessage: (id) => client.getMessage(id),
55
64
  sendMessage: (input) => client.sendMessage(input),
56
65
  markConversationRead: (conversationId) => client.markConversationRead(conversationId),
57
66
  sendTyping: (input) => client.sendTyping(input),
@@ -72,16 +81,17 @@ function createConvoKitUiClient(client) {
72
81
 
73
82
  // src/components/avatar.ts
74
83
  var import_reka_ui = require("reka-ui");
75
- var import_vue = require("vue");
84
+ var import_vue2 = require("vue");
76
85
 
77
86
  // src/utils.ts
78
87
  var import_clsx = require("clsx");
88
+ var import_vue = require("vue");
79
89
  var pendingMessageIdPrefix = "convokit-pending-";
80
90
  function isConvoKitPendingMessage(message) {
81
91
  return message.id.startsWith(pendingMessageIdPrefix);
82
92
  }
83
93
  function cx(...values) {
84
- return (0, import_clsx.clsx)(values);
94
+ return (0, import_clsx.clsx)(values.map(import_vue.normalizeClass));
85
95
  }
86
96
  function requestedParticipantIds(filter) {
87
97
  const values = filter.participantIds ?? [];
@@ -154,7 +164,7 @@ function initials(value) {
154
164
  }
155
165
 
156
166
  // src/components/avatar.ts
157
- var ConvoKitAvatar = (0, import_vue.defineComponent)({
167
+ var ConvoKitAvatar = (0, import_vue2.defineComponent)({
158
168
  name: "ConvoKitAvatar",
159
169
  inheritAttrs: false,
160
170
  props: {
@@ -162,13 +172,13 @@ var ConvoKitAvatar = (0, import_vue.defineComponent)({
162
172
  src: { type: String, default: null }
163
173
  },
164
174
  setup(props, { attrs }) {
165
- return () => (0, import_vue.h)(import_reka_ui.AvatarRoot, {
175
+ return () => (0, import_vue2.h)(import_reka_ui.AvatarRoot, {
166
176
  ...attrs,
167
177
  class: cx("ckui-avatar", attrs.class)
168
178
  }, {
169
179
  default: () => [
170
- props.src ? (0, import_vue.h)(import_reka_ui.AvatarImage, { class: "ckui-avatar__image", src: props.src, alt: "" }) : null,
171
- (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, {
172
182
  class: "ckui-avatar__fallback",
173
183
  ...props.src ? { delayMs: 300 } : {}
174
184
  }, () => initials(props.name))
@@ -178,251 +188,588 @@ var ConvoKitAvatar = (0, import_vue.defineComponent)({
178
188
  });
179
189
 
180
190
  // src/components/conversation.ts
181
- var import_vue5 = require("@lucide/vue");
182
- var import_vue6 = require("vue");
191
+ var import_vue6 = require("@lucide/vue");
192
+ var import_vue7 = require("vue");
183
193
 
184
194
  // src/composables/use-conversation.ts
185
- var import_vue2 = require("vue");
186
- function useConversation(options) {
187
- const messagePageSize = options.messagePageSize ?? 30;
188
- const typingTimeoutMs = options.typingTimeoutMs ?? 3e3;
189
- if (!(0, import_vue2.toValue)(options.conversationId).trim()) throw new TypeError("conversationId is required");
190
- if (!Number.isInteger(messagePageSize) || messagePageSize <= 0) throw new RangeError("messagePageSize must be a positive integer");
191
- if (!Number.isFinite(typingTimeoutMs) || typingTimeoutMs < 0) throw new RangeError("typingTimeoutMs must be non-negative");
192
- const conversation = (0, import_vue2.shallowRef)(null);
193
- const messages = (0, import_vue2.shallowRef)([]);
194
- const typingUserIds = (0, import_vue2.shallowRef)(/* @__PURE__ */ new Set());
195
- const readAtByUserId = (0, import_vue2.shallowRef)(/* @__PURE__ */ new Map());
196
- const isInitialLoading = (0, import_vue2.ref)(false);
197
- const isLoadingOlder = (0, import_vue2.ref)(false);
198
- const isSending = (0, import_vue2.ref)(false);
199
- const hasOlderMessages = (0, import_vue2.ref)(true);
200
- const hasLoaded = (0, import_vue2.ref)(false);
201
- const error = (0, import_vue2.shallowRef)(null);
202
- const currentUserId = (0, import_vue2.computed)(() => (0, import_vue2.toValue)(options.client).currentUserId);
203
- let generation = 0;
204
- let disposed = false;
205
- let sentTyping = false;
206
- let typingTimer = null;
207
- let subscriptions = [];
208
- const pendingIds = /* @__PURE__ */ new Set();
209
- let pendingSequence = 0;
210
- const unsubscribe = async () => {
211
- const active = subscriptions;
212
- subscriptions = [];
213
- 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
214
224
  };
215
- const markRead = async () => {
216
- const client = (0, import_vue2.toValue)(options.client);
217
- const conversationId = (0, import_vue2.toValue)(options.conversationId);
218
- try {
219
- await client.markConversationRead(conversationId);
220
- if (disposed) return;
221
- readAtByUserId.value = new Map(readAtByUserId.value).set(client.currentUserId, /* @__PURE__ */ new Date());
222
- } catch (cause) {
223
- 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");
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
+ };
277
+ };
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);
224
327
  }
328
+ this.disposed = true;
329
+ this.clear();
330
+ this.patch(blank());
225
331
  };
226
- const subscribe = (activeGeneration, client, conversationId) => {
227
- const report = (cause) => {
228
- if (!disposed && activeGeneration === generation) error.value = cause;
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);
229
347
  };
230
- subscriptions = [
231
- client.onMessage(conversationId, (message) => {
232
- if (disposed || activeGeneration !== generation) return;
233
- const pending = messages.value.find((candidate) => pendingIds.has(candidate.id) && message.senderId === client.currentUserId && candidate.text === message.text && candidate.media.length === message.media.length);
234
- if (pending) pendingIds.delete(pending.id);
235
- messages.value = mergeMessages(
236
- pending ? messages.value.filter((candidate) => candidate.id !== pending.id) : messages.value,
237
- [message]
238
- );
239
- if ((options.markReadOnReceive ?? true) && message.senderId !== client.currentUserId) void markRead();
240
- }, report),
241
- client.onReadReceipt(conversationId, ({ userId, readAt }) => {
242
- if (disposed || activeGeneration !== generation) return;
243
- readAtByUserId.value = new Map(readAtByUserId.value).set(userId, readAt);
244
- }, report),
245
- client.onTyping(conversationId, ({ userId, isTyping }) => {
246
- if (disposed || activeGeneration !== generation || userId === client.currentUserId) return;
247
- const next = new Set(typingUserIds.value);
248
- if (isTyping) next.add(userId);
249
- else next.delete(userId);
250
- typingUserIds.value = next;
251
- }, report)
252
- ];
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
+ }
253
540
  };
254
- const loadInitial = async () => {
255
- const client = (0, import_vue2.toValue)(options.client);
256
- const conversationId = (0, import_vue2.toValue)(options.conversationId).trim();
257
- if (!conversationId) throw new TypeError("conversationId is required");
258
- const activeGeneration = ++generation;
259
- await unsubscribe();
260
- if (disposed || activeGeneration !== generation) return;
261
- messages.value = [];
262
- pendingIds.clear();
263
- conversation.value = null;
264
- typingUserIds.value = /* @__PURE__ */ new Set();
265
- readAtByUserId.value = /* @__PURE__ */ new Map();
266
- hasOlderMessages.value = true;
267
- error.value = null;
268
- isInitialLoading.value = true;
269
- isLoadingOlder.value = false;
270
- 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 });
271
567
  try {
272
- const [nextConversation, page] = await Promise.all([
273
- client.getConversation(conversationId),
274
- client.getMessages({ conversationId, limit: messagePageSize, offset: 0 })
275
- ]);
276
- if (disposed || activeGeneration !== generation) return;
277
- conversation.value = nextConversation;
278
- readAtByUserId.value = new Map(nextConversation.participants.flatMap((participant) => participant.lastReadAt ? [[participant.appUserId, participant.lastReadAt]] : []));
279
- messages.value = mergeMessages([], page);
280
- hasOlderMessages.value = page.length === messagePageSize;
281
- 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);
282
596
  } catch (cause) {
283
- if (!disposed && activeGeneration === generation) error.value = cause;
597
+ this.fail(cause, generation, true);
284
598
  } finally {
285
- if (!disposed && activeGeneration === generation) {
286
- isInitialLoading.value = false;
287
- hasLoaded.value = true;
599
+ if (this.alive(generation)) {
600
+ this.patch({ isReconciling: false });
601
+ this.flushRefresh();
288
602
  }
289
603
  }
290
604
  };
291
- const loadOlderMessages = async () => {
292
- if (isInitialLoading.value || isLoadingOlder.value || !hasOlderMessages.value) return;
293
- const activeGeneration = generation;
294
- const client = (0, import_vue2.toValue)(options.client);
295
- const conversationId = (0, import_vue2.toValue)(options.conversationId);
296
- isLoadingOlder.value = true;
297
- 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 });
298
611
  try {
299
- const page = await client.getMessages({
300
- conversationId,
301
- limit: messagePageSize,
302
- 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
303
619
  });
304
- if (disposed || activeGeneration !== generation) return;
305
- messages.value = mergeMessages(messages.value, page);
306
- hasOlderMessages.value = page.length === messagePageSize;
307
620
  } catch (cause) {
308
- if (!disposed && activeGeneration === generation) error.value = cause;
621
+ this.fail(cause, generation, true);
309
622
  } finally {
310
- if (!disposed && activeGeneration === generation) isLoadingOlder.value = false;
623
+ if (this.alive(generation)) {
624
+ this.patch({ isLoadingOlder: false });
625
+ this.flushRefresh();
626
+ }
311
627
  }
312
628
  };
313
- const updateTyping = async (nextTyping) => {
314
- if (typingTimer) clearTimeout(typingTimer);
315
- if (nextTyping) typingTimer = setTimeout(() => {
316
- void updateTyping(false);
317
- }, typingTimeoutMs);
318
- if (sentTyping === nextTyping) return;
319
- sentTyping = nextTyping;
629
+ markRead = async () => {
630
+ if (!this.alive()) return;
631
+ const generation = this.generation;
320
632
  try {
321
- await (0, import_vue2.toValue)(options.client).sendTyping({
322
- conversationId: (0, import_vue2.toValue)(options.conversationId),
323
- isTyping: nextTyping
324
- });
633
+ await this.client.markConversationRead(this.room);
325
634
  } catch (cause) {
326
- if (!disposed) error.value = cause;
635
+ this.fail(cause, generation);
327
636
  }
328
637
  };
329
- const sendMessage = async ({ text, media }) => {
330
- const normalizedText = text?.trim();
331
- if (!normalizedText && (!media || media.length === 0)) return null;
332
- if (isSending.value) return null;
333
- const pendingId = `convokit-pending-${Date.now()}-${++pendingSequence}`;
334
- 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 = {
335
668
  id: pendingId,
336
- conversationId: (0, import_vue2.toValue)(options.conversationId),
337
- senderId: (0, import_vue2.toValue)(options.client).currentUserId,
338
- text: normalizedText ?? null,
669
+ conversationId: this.room,
670
+ senderId: this.user,
671
+ text: normalized || null,
339
672
  media: media ?? [],
340
673
  createdAt: /* @__PURE__ */ new Date(),
341
674
  updatedAt: null
342
675
  };
343
- pendingIds.add(pendingId);
344
- messages.value = mergeMessages(messages.value, [pendingMessage]);
345
- isSending.value = true;
346
- error.value = null;
347
- const activeGeneration = generation;
676
+ this.patch({ messages: mergeMessages(this.state.messages, [pending]), isSending: true, error: null });
348
677
  try {
349
- const message = await (0, import_vue2.toValue)(options.client).sendMessage({
350
- conversationId: (0, import_vue2.toValue)(options.conversationId),
351
- ...normalizedText ? { text: normalizedText } : {},
678
+ const message = await this.client.sendMessage({
679
+ conversationId: this.room,
680
+ ...normalized ? { text: normalized } : {},
352
681
  ...media?.length ? { media } : {}
353
682
  });
354
- pendingIds.delete(pendingId);
355
- if (!disposed && activeGeneration === generation) {
356
- messages.value = mergeMessages(
357
- messages.value.filter((candidate) => candidate.id !== pendingId),
358
- [message]
359
- );
360
- }
361
- await updateTyping(false);
362
- 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;
363
696
  } catch (cause) {
364
- pendingIds.delete(pendingId);
365
- if (!disposed) {
366
- messages.value = messages.value.filter((candidate) => candidate.id !== pendingId);
367
- 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);
368
700
  }
369
701
  return null;
370
702
  } finally {
371
- if (!disposed && activeGeneration === generation) isSending.value = false;
703
+ if (this.alive(generation)) {
704
+ this.sendRevision = void 0;
705
+ this.patch({ isSending: false });
706
+ }
372
707
  }
373
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]);
374
737
  const dispose = async () => {
375
- disposed = true;
376
- generation += 1;
377
- if (typingTimer) clearTimeout(typingTimer);
378
- if (sentTyping) {
379
- await (0, import_vue2.toValue)(options.client).sendTyping({
380
- conversationId: (0, import_vue2.toValue)(options.conversationId),
381
- isTyping: false
382
- }).catch(() => void 0);
383
- }
384
- await unsubscribe();
738
+ stop();
739
+ store.dispose();
740
+ unsubscribe?.();
741
+ unsubscribe = void 0;
385
742
  };
386
- if (options.autoLoad ?? true) {
387
- (0, import_vue2.watch)(
388
- () => [(0, import_vue2.toValue)(options.client), (0, import_vue2.toValue)(options.conversationId)],
389
- () => {
390
- disposed = false;
391
- sentTyping = false;
392
- void loadInitial();
393
- },
394
- { immediate: true }
395
- );
396
- }
397
- if ((0, import_vue2.getCurrentScope)()) (0, import_vue2.onScopeDispose)(() => {
743
+ if ((0, import_vue3.getCurrentScope)()) (0, import_vue3.onScopeDispose)(() => {
398
744
  void dispose();
399
745
  });
400
746
  return {
401
- conversation,
402
- messages,
403
- typingUserIds,
404
- readAtByUserId,
405
- isInitialLoading,
406
- isLoadingOlder,
407
- isSending,
408
- hasOlderMessages,
409
- hasLoaded,
410
- error,
411
- currentUserId,
412
- readerIdsFor: (message) => readerIdsFor(message, readAtByUserId.value),
413
- loadInitial,
414
- refresh: loadInitial,
415
- loadOlderMessages,
416
- sendMessage,
417
- markRead,
418
- 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),
419
766
  dispose
420
767
  };
421
768
  }
422
769
 
423
770
  // src/components/message-list.ts
424
- var import_vue3 = require("@lucide/vue");
425
- var import_vue4 = require("vue");
771
+ var import_vue4 = require("@lucide/vue");
772
+ var import_vue5 = require("vue");
426
773
  var appearanceProps = {
427
774
  classNames: { type: Object, default: void 0 },
428
775
  styles: { type: Object, default: void 0 },
@@ -436,33 +783,33 @@ function defaultMedia(media, open, imageLoading) {
436
783
  const tag = open ? "button" : "div";
437
784
  const interactive = open ? { type: "button", onClick: open } : {};
438
785
  if (media.type === "image") {
439
- return (0, import_vue4.h)(tag, { ...interactive, class: "ckui-media-card ckui-media-card--image" }, [
440
- 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"]),
441
- 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
442
789
  ]);
443
790
  }
444
791
  if (media.type === "file") {
445
792
  const size = formatFileSize(media.size);
446
- return (0, import_vue4.h)(tag, { ...interactive, class: "ckui-media-card ckui-media-card--file" }, [
447
- (0, import_vue4.h)(import_vue3.FileText, { "aria-hidden": "true" }),
448
- (0, import_vue4.h)("span", [(0, import_vue4.h)("strong", media.name || "Attachment"), size ? (0, import_vue4.h)("small", size) : null]),
449
- 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
450
797
  ]);
451
798
  }
452
799
  if (media.type === "location") {
453
800
  const label = media.name || `${media.metadata.lat}, ${media.metadata.lng}`;
454
- return (0, import_vue4.h)(tag, { ...interactive, class: "ckui-media-card ckui-media-card--location" }, [
455
- (0, import_vue4.h)(import_vue3.MapPin, { "aria-hidden": "true" }),
456
- (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}`)])
457
804
  ]);
458
805
  }
459
806
  const contact = media.metadata.email || media.metadata.phone || "Contact details";
460
- return (0, import_vue4.h)(tag, { ...interactive, class: "ckui-media-card ckui-media-card--contact" }, [
461
- (0, import_vue4.h)(import_vue3.ContactRound, { "aria-hidden": "true" }),
462
- (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))])
463
810
  ]);
464
811
  }
465
- var MessageListView = (0, import_vue4.defineComponent)({
812
+ var MessageListView = (0, import_vue5.defineComponent)({
466
813
  name: "MessageListView",
467
814
  inheritAttrs: false,
468
815
  props: {
@@ -486,11 +833,11 @@ var MessageListView = (0, import_vue4.defineComponent)({
486
833
  },
487
834
  emits: ["load-older", "attachment-click"],
488
835
  setup(props, { attrs, emit, slots }) {
489
- const internalElement = (0, import_vue4.ref)(null);
836
+ const internalElement = (0, import_vue5.ref)(null);
490
837
  let requestInFlight = false;
491
838
  let lastRequestedLength = null;
492
839
  let previousMessageCount = 0;
493
- 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) => [
494
841
  [participant.id, participant],
495
842
  [participant.appUserId, participant]
496
843
  ])));
@@ -512,7 +859,7 @@ var MessageListView = (0, import_vue4.defineComponent)({
512
859
  requestInFlight = false;
513
860
  }
514
861
  };
515
- (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]) => {
516
863
  const previous = previousMessageCount;
517
864
  if (count !== previousMessageCount || !hasOlder) lastRequestedLength = null;
518
865
  const appended = count > previous;
@@ -521,7 +868,7 @@ var MessageListView = (0, import_vue4.defineComponent)({
521
868
  if (element && props.reverse && props.stickToBottom && appended) {
522
869
  const distanceFromBottom = element.scrollHeight - element.scrollTop - element.clientHeight;
523
870
  if (previous === 0 || distanceFromBottom < 320) {
524
- await (0, import_vue4.nextTick)();
871
+ await (0, import_vue5.nextTick)();
525
872
  element.scrollTop = element.scrollHeight;
526
873
  }
527
874
  }
@@ -533,7 +880,7 @@ var MessageListView = (0, import_vue4.defineComponent)({
533
880
  const readerIds = isPending ? /* @__PURE__ */ new Set() : props.readersResolver ? props.readersResolver(message) : readerIdsFor(message, props.readAtByUserId);
534
881
  const slotProps = { message, chronologicalIndex: index, isCurrentUser, sender, readerIds };
535
882
  const custom = slots.message?.(slotProps);
536
- 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);
537
884
  const currentAppearance = appearance();
538
885
  const messagePart = isCurrentUser ? "outgoingMessage" : "incomingMessage";
539
886
  const mediaNodes = message.media.map((media, mediaIndex) => {
@@ -541,15 +888,15 @@ var MessageListView = (0, import_vue4.defineComponent)({
541
888
  props.onAttachmentClick?.(media, message);
542
889
  } : void 0;
543
890
  const mediaSlotProps = { media, message, isCurrentUser, ...open ? { open } : {} };
544
- return (0, import_vue4.h)("div", {
891
+ return (0, import_vue5.h)("div", {
545
892
  key: media.id ?? `${media.type}-${mediaIndex}`,
546
893
  class: partClass("media", currentAppearance, "ckui-media"),
547
894
  style: partStyle("media", currentAppearance)
548
895
  }, slots.media?.(mediaSlotProps) ?? [defaultMedia(media, open, props.imageLoading)]);
549
896
  });
550
897
  const receiptSlotProps = { message, readerIds };
551
- return (0, import_vue4.h)("div", { key: message.id, role: "listitem" }, [
552
- (0, import_vue4.h)("article", {
898
+ return (0, import_vue5.h)("div", { key: message.id, role: "listitem" }, [
899
+ (0, import_vue5.h)("article", {
553
900
  class: cx(
554
901
  !props.unstyled && "ckui-message-row",
555
902
  isCurrentUser && !props.unstyled && "ckui-message-row--outgoing",
@@ -559,16 +906,16 @@ var MessageListView = (0, import_vue4.defineComponent)({
559
906
  style: [props.styles?.message, props.styles?.[messagePart]],
560
907
  "data-message-id": message.id
561
908
  }, [
562
- (0, import_vue4.h)("div", { class: "ckui-message-bubble" }, [
563
- !isCurrentUser ? (0, import_vue4.h)("strong", { class: "ckui-message-sender" }, sender?.name || message.senderId) : null,
564
- 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,
565
912
  ...mediaNodes,
566
- (0, import_vue4.h)("span", { class: "ckui-message-time" }, [
913
+ (0, import_vue5.h)("span", { class: "ckui-message-time" }, [
567
914
  isPending ? "Sending\u2026" : props.formatTime(message.createdAt),
568
- isCurrentUser && !isPending ? readerIds.size > 0 ? (0, import_vue4.h)(import_vue3.CheckCheck, { size: 14, "aria-label": "Read" }) : (0, import_vue4.h)(import_vue3.Check, { size: 14, "aria-label": "Delivered" }) : null
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
569
916
  ])
570
917
  ]),
571
- isCurrentUser && !isPending ? slots["read-receipt"]?.(receiptSlotProps) ?? (0, import_vue4.h)("div", {
918
+ isCurrentUser && !isPending ? slots["read-receipt"]?.(receiptSlotProps) ?? (0, import_vue5.h)("div", {
572
919
  class: partClass("receipt", currentAppearance, "ckui-read-receipt"),
573
920
  style: partStyle("receipt", currentAppearance)
574
921
  }, readerIds.size > 0 ? `Read by ${readerIds.size}` : "Delivered") : null
@@ -579,34 +926,34 @@ var MessageListView = (0, import_vue4.defineComponent)({
579
926
  const currentAppearance = appearance();
580
927
  const children = [];
581
928
  if (props.isLoadingOlder) {
582
- children.push(slots["loading-older"]?.() ?? (0, import_vue4.h)("div", {
929
+ children.push(slots["loading-older"]?.() ?? (0, import_vue5.h)("div", {
583
930
  class: partClass("loading", currentAppearance, "ckui-inline-state"),
584
931
  style: partStyle("loading", currentAppearance),
585
932
  role: "status"
586
- }, [(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"]));
587
934
  }
588
935
  if (props.error) {
589
936
  const retry = props.onLoadOlder ? () => {
590
937
  void requestOlder();
591
938
  } : void 0;
592
- 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", {
593
940
  class: partClass("error", currentAppearance, "ckui-inline-state ckui-state--error"),
594
941
  style: partStyle("error", currentAppearance),
595
942
  role: "alert"
596
943
  }, [
597
- (0, import_vue4.h)("span", errorMessage(props.error)),
598
- 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
599
946
  ]));
600
947
  }
601
948
  if (props.messages.length === 0 && !props.isLoadingOlder) {
602
- children.push(slots.empty?.() ?? (0, import_vue4.h)("div", {
949
+ children.push(slots.empty?.() ?? (0, import_vue5.h)("div", {
603
950
  class: partClass("empty", currentAppearance, "ckui-state"),
604
951
  style: partStyle("empty", currentAppearance)
605
- }, [(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"]));
606
953
  } else {
607
954
  children.push(...props.messages.map(renderMessage));
608
955
  }
609
- return (0, import_vue4.h)("div", {
956
+ return (0, import_vue5.h)("div", {
610
957
  ...attrs,
611
958
  ref: (element) => {
612
959
  internalElement.value = element;
@@ -681,7 +1028,7 @@ function typingLabel(userIds, displayNameForUser) {
681
1028
  if (names.length === 2) return `${names[0]} and ${names[1]} are typing\u2026`;
682
1029
  return `${names[0]} and ${names.length - 1} others are typing\u2026`;
683
1030
  }
684
- var ConversationView = (0, import_vue6.defineComponent)({
1031
+ var ConversationView = (0, import_vue7.defineComponent)({
685
1032
  name: "ConversationView",
686
1033
  inheritAttrs: false,
687
1034
  props: viewProps,
@@ -696,8 +1043,8 @@ var ConversationView = (0, import_vue6.defineComponent)({
696
1043
  "update:modelValue"
697
1044
  ],
698
1045
  setup(props, { attrs, emit, slots }) {
699
- const internalDraft = (0, import_vue6.ref)(props.defaultDraft);
700
- const submitting = (0, import_vue6.ref)(false);
1046
+ const internalDraft = (0, import_vue7.ref)(props.defaultDraft);
1047
+ const submitting = (0, import_vue7.ref)(false);
701
1048
  const appearance = () => ({
702
1049
  density: props.density,
703
1050
  unstyled: props.unstyled,
@@ -747,23 +1094,23 @@ var ConversationView = (0, import_vue6.defineComponent)({
747
1094
  ...props.onBack ? { onBack: goBack } : {},
748
1095
  ...props.onRefresh ? { onRefresh: refresh } : {}
749
1096
  };
750
- return slots.header?.(slotProps) ?? (0, import_vue6.h)("header", {
1097
+ return slots.header?.(slotProps) ?? (0, import_vue7.h)("header", {
751
1098
  class: partClass("header", appearance(), "ckui-conversation-header"),
752
1099
  style: partStyle("header", appearance())
753
1100
  }, [
754
- props.onBack ? (0, import_vue6.h)("button", {
1101
+ props.onBack ? (0, import_vue7.h)("button", {
755
1102
  type: "button",
756
1103
  "aria-label": "Back",
757
1104
  onClick: goBack,
758
1105
  class: partClass("button", appearance(), "ckui-icon-button"),
759
1106
  style: partStyle("button", appearance())
760
- }, [(0, import_vue6.h)(import_vue5.ArrowLeft, { size: 20, "aria-hidden": "true" })]) : null,
761
- (0, import_vue6.h)(ConvoKitAvatar, { name: props.conversation.displayTitle, src: props.conversation.imageUrl }),
762
- (0, import_vue6.h)("div", { class: "ckui-conversation-header__body" }, [
763
- (0, import_vue6.h)("strong", props.conversation.displayTitle),
764
- (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"}`)
765
1112
  ]),
766
- props.onRefresh ? (0, import_vue6.h)("button", {
1113
+ props.onRefresh ? (0, import_vue7.h)("button", {
767
1114
  type: "button",
768
1115
  "aria-label": "Refresh conversation",
769
1116
  onClick: () => {
@@ -771,12 +1118,12 @@ var ConversationView = (0, import_vue6.defineComponent)({
771
1118
  },
772
1119
  class: partClass("button", appearance(), "ckui-icon-button"),
773
1120
  style: partStyle("button", appearance())
774
- }, [(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
775
1122
  ]);
776
1123
  };
777
1124
  const renderTyping = () => {
778
1125
  const slotProps = { userIds: props.typingUserIds, displayNameForUser: nameForUser };
779
- return slots["typing-indicator"]?.(slotProps) ?? (0, import_vue6.h)("div", {
1126
+ return slots["typing-indicator"]?.(slotProps) ?? (0, import_vue7.h)("div", {
780
1127
  class: partClass("typing", appearance(), "ckui-typing"),
781
1128
  style: partStyle("typing", appearance()),
782
1129
  "aria-live": "polite"
@@ -792,7 +1139,7 @@ var ConversationView = (0, import_vue6.defineComponent)({
792
1139
  },
793
1140
  ...props.onAddAttachment ? { addAttachment } : {}
794
1141
  };
795
- return slots.composer?.(slotProps) ?? (0, import_vue6.h)("form", {
1142
+ return slots.composer?.(slotProps) ?? (0, import_vue7.h)("form", {
796
1143
  class: partClass("composer", appearance(), "ckui-composer"),
797
1144
  style: partStyle("composer", appearance()),
798
1145
  onSubmit: (event) => {
@@ -800,14 +1147,14 @@ var ConversationView = (0, import_vue6.defineComponent)({
800
1147
  void submit();
801
1148
  }
802
1149
  }, [
803
- props.onAddAttachment ? (0, import_vue6.h)("button", {
1150
+ props.onAddAttachment ? (0, import_vue7.h)("button", {
804
1151
  type: "button",
805
1152
  "aria-label": "Add attachment",
806
1153
  onClick: addAttachment,
807
1154
  class: partClass("button", appearance(), "ckui-icon-button"),
808
1155
  style: partStyle("button", appearance())
809
- }, [(0, import_vue6.h)(import_vue5.Paperclip, { size: 20, "aria-hidden": "true" })]) : null,
810
- (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", {
811
1158
  ...props.composerProps,
812
1159
  rows: props.composerProps?.rows ?? 1,
813
1160
  placeholder: props.composerPlaceholder,
@@ -830,38 +1177,38 @@ var ConversationView = (0, import_vue6.defineComponent)({
830
1177
  }
831
1178
  }
832
1179
  }),
833
- (0, import_vue6.h)("button", {
1180
+ (0, import_vue7.h)("button", {
834
1181
  type: "submit",
835
1182
  "aria-label": "Send message",
836
1183
  disabled: !draft().trim() || props.isSending || submitting.value,
837
1184
  class: partClass("button", appearance(), "ckui-send-button"),
838
1185
  style: partStyle("button", appearance())
839
- }, [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" })])
840
1187
  ]);
841
1188
  };
842
1189
  return () => {
843
1190
  if (props.isInitialLoading && props.messages.length === 0) {
844
- return (0, import_vue6.h)("div", {
1191
+ return (0, import_vue7.h)("div", {
845
1192
  ...attrs,
846
1193
  class: cx(!props.unstyled && "ckui ckui-conversation", props.classNames?.root, attrs.class),
847
1194
  style: [props.styles?.root, attrs.style],
848
1195
  "data-density": props.density
849
- }, slots.loading?.() ?? (0, import_vue6.h)("div", {
1196
+ }, slots.loading?.() ?? (0, import_vue7.h)("div", {
850
1197
  class: partClass("loading", appearance(), "ckui-state"),
851
1198
  style: partStyle("loading", appearance()),
852
1199
  role: "status"
853
- }, [(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"]));
854
1201
  }
855
1202
  const children = [renderHeader()];
856
1203
  if (props.error) {
857
1204
  const retry = props.onRefresh ? () => {
858
1205
  void refresh();
859
1206
  } : void 0;
860
- 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", {
861
1208
  class: partClass("error", appearance(), "ckui-conversation-error"),
862
1209
  style: partStyle("error", appearance()),
863
1210
  role: "alert"
864
- }, [(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]));
865
1212
  }
866
1213
  const messageSlots = {
867
1214
  ...slots.message ? { message: slots.message } : {},
@@ -871,7 +1218,7 @@ var ConversationView = (0, import_vue6.defineComponent)({
871
1218
  ...slots["loading-older"] ? { "loading-older": slots["loading-older"] } : {},
872
1219
  ...slots["message-error"] ? { error: slots["message-error"] } : {}
873
1220
  };
874
- children.push((0, import_vue6.h)(MessageListView, {
1221
+ children.push((0, import_vue7.h)(MessageListView, {
875
1222
  conversation: props.conversation,
876
1223
  messages: props.messages,
877
1224
  currentUserId: props.currentUserId,
@@ -895,7 +1242,7 @@ var ConversationView = (0, import_vue6.defineComponent)({
895
1242
  unstyled: props.unstyled
896
1243
  }, messageSlots));
897
1244
  children.push(renderTyping(), renderComposer());
898
- return (0, import_vue6.h)("section", {
1245
+ return (0, import_vue7.h)("section", {
899
1246
  ...attrs,
900
1247
  class: cx(!props.unstyled && "ckui ckui-conversation", props.classNames?.root, attrs.class),
901
1248
  style: [props.styles?.root, attrs.style],
@@ -905,7 +1252,7 @@ var ConversationView = (0, import_vue6.defineComponent)({
905
1252
  };
906
1253
  }
907
1254
  });
908
- var Conversation = (0, import_vue6.defineComponent)({
1255
+ var Conversation = (0, import_vue7.defineComponent)({
909
1256
  name: "Conversation",
910
1257
  inheritAttrs: false,
911
1258
  props: {
@@ -935,7 +1282,7 @@ var Conversation = (0, import_vue6.defineComponent)({
935
1282
  autoLoad: props.autoLoad
936
1283
  });
937
1284
  expose({ controller });
938
- (0, import_vue6.watchEffect)(() => {
1285
+ (0, import_vue7.watchEffect)(() => {
939
1286
  emit("controller-change", controller);
940
1287
  });
941
1288
  return () => {
@@ -944,16 +1291,16 @@ var Conversation = (0, import_vue6.defineComponent)({
944
1291
  const retry = () => {
945
1292
  void controller.refresh();
946
1293
  };
947
- return (0, import_vue6.h)("div", {
1294
+ return (0, import_vue7.h)("div", {
948
1295
  ...attrs,
949
1296
  class: cx(!props.unstyled && "ckui ckui-conversation", props.classNames?.root, attrs.class),
950
1297
  style: [props.styles?.root, attrs.style],
951
1298
  "data-density": props.density
952
- }, controller.error.value ? slots.error?.({ error: controller.error.value, retry }) ?? (0, import_vue6.h)("div", { class: "ckui-state ckui-state--error", role: "alert" }, [
953
- (0, import_vue6.h)("span", errorMessage(controller.error.value)),
954
- (0, import_vue6.h)("button", { type: "button", class: "ckui-link-button", onClick: retry }, "Try again")
955
- ]) : slots.loading?.() ?? (0, import_vue6.h)("div", { class: "ckui-state", role: "status" }, [
956
- (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" }),
957
1304
  " Loading conversation\u2026"
958
1305
  ]));
959
1306
  }
@@ -982,7 +1329,7 @@ var Conversation = (0, import_vue6.defineComponent)({
982
1329
  error: _error,
983
1330
  ...forwarded
984
1331
  } = props;
985
- return (0, import_vue6.h)(ConversationView, {
1332
+ return (0, import_vue7.h)(ConversationView, {
986
1333
  ...attrs,
987
1334
  ...forwarded,
988
1335
  conversation: loadedConversation,
@@ -1021,110 +1368,192 @@ var Conversation = (0, import_vue6.defineComponent)({
1021
1368
  });
1022
1369
 
1023
1370
  // src/components/conversation-list.ts
1024
- var import_vue8 = require("@lucide/vue");
1025
- var import_vue9 = require("vue");
1371
+ var import_vue9 = require("@lucide/vue");
1372
+ var import_vue10 = require("vue");
1026
1373
 
1027
1374
  // src/composables/use-conversation-list.ts
1028
- var import_vue7 = require("vue");
1029
- function useConversationList(options) {
1030
- const pageSize = options.pageSize ?? 30;
1031
- if (!Number.isInteger(pageSize) || pageSize <= 0) throw new RangeError("pageSize must be a positive integer");
1032
- const source = (0, import_vue7.shallowRef)([]);
1033
- const filter = (0, import_vue7.shallowRef)(options.initialFilter ?? {});
1034
- const isInitialLoading = (0, import_vue7.ref)(false);
1035
- const isLoadingMore = (0, import_vue7.ref)(false);
1036
- const hasMore = (0, import_vue7.ref)(true);
1037
- const hasLoaded = (0, import_vue7.ref)(false);
1038
- const error = (0, import_vue7.shallowRef)(null);
1039
- const conversations = (0, import_vue7.computed)(() => applyConversationFilter(source.value, filter.value));
1040
- let offset = 0;
1041
- let generation = 0;
1042
- let disposed = false;
1043
- const loadUntilVisible = async (activeGeneration, activeFilter) => {
1044
- const visibleBefore = applyConversationFilter(source.value, activeFilter).length;
1045
- while (true) {
1046
- const request = { limit: pageSize, offset, filter: activeFilter };
1047
- const page = options.pageLoader ? await options.pageLoader(request) : await (0, import_vue7.toValue)(options.client).getConversations({
1048
- limit: pageSize,
1049
- offset: request.offset,
1050
- 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
+ }
1051
1427
  });
1052
- if (disposed || activeGeneration !== generation) return;
1053
- offset += page.length;
1054
- hasMore.value = page.length === pageSize;
1055
- source.value = mergeConversations(source.value, page);
1056
- 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 });
1057
1433
  }
1058
1434
  };
1059
- const loadInitialFor = async (activeFilter = filter.value) => {
1060
- const activeGeneration = ++generation;
1061
- source.value = [];
1062
- offset = 0;
1063
- hasMore.value = true;
1064
- isInitialLoading.value = true;
1065
- isLoadingMore.value = false;
1066
- 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 });
1067
1483
  try {
1068
- await loadUntilVisible(activeGeneration, activeFilter);
1484
+ await this.loadUntilVisible(generation, this.state.filter);
1069
1485
  } catch (cause) {
1070
- if (!disposed && activeGeneration === generation) error.value = cause;
1486
+ this.fail(cause, generation);
1071
1487
  } finally {
1072
- if (!disposed && activeGeneration === generation) {
1073
- isInitialLoading.value = false;
1074
- hasLoaded.value = true;
1075
- }
1488
+ if (this.alive(generation)) this.patch({ isInitialLoading: false, hasLoaded: true });
1076
1489
  }
1077
1490
  };
1078
- const loadMore = async () => {
1079
- if (isInitialLoading.value || isLoadingMore.value || !hasMore.value) return;
1080
- const activeGeneration = generation;
1081
- isLoadingMore.value = true;
1082
- 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 });
1083
1496
  try {
1084
- await loadUntilVisible(activeGeneration, filter.value);
1497
+ await this.loadUntilVisible(generation, this.state.filter);
1085
1498
  } catch (cause) {
1086
- if (!disposed && activeGeneration === generation) error.value = cause;
1499
+ this.fail(cause, generation);
1087
1500
  } finally {
1088
- if (!disposed && activeGeneration === generation) isLoadingMore.value = false;
1501
+ if (this.alive(generation)) this.patch({ isLoadingMore: false });
1089
1502
  }
1090
1503
  };
1091
- const setFilter = async (nextFilter) => {
1092
- const previousArchived = filter.value.archived ?? false;
1093
- filter.value = nextFilter;
1094
- error.value = null;
1095
- if ((nextFilter.archived ?? false) !== previousArchived || !hasLoaded.value) {
1096
- await loadInitialFor(nextFilter);
1097
- } else if (applyConversationFilter(source.value, nextFilter).length === 0 && hasMore.value) {
1098
- await loadMore();
1099
- }
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();
1100
1510
  };
1101
- 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]);
1102
1535
  const dispose = async () => {
1103
- disposed = true;
1104
- generation += 1;
1536
+ stop();
1537
+ store.dispose();
1538
+ unsubscribe?.();
1539
+ unsubscribe = void 0;
1105
1540
  };
1106
- if (options.autoLoad ?? true) {
1107
- (0, import_vue7.watch)(() => (0, import_vue7.toValue)(options.client), () => {
1108
- disposed = false;
1109
- void loadInitialFor(filter.value);
1110
- }, { immediate: true });
1111
- }
1112
- if ((0, import_vue7.getCurrentScope)()) (0, import_vue7.onScopeDispose)(() => {
1541
+ if ((0, import_vue8.getCurrentScope)()) (0, import_vue8.onScopeDispose)(() => {
1113
1542
  void dispose();
1114
1543
  });
1115
1544
  return {
1116
- conversations,
1117
- filter,
1118
- isInitialLoading,
1119
- isLoadingMore,
1120
- hasMore,
1121
- hasLoaded,
1122
- error,
1123
- loadInitial: loadInitialFor,
1124
- refresh: loadInitialFor,
1125
- loadMore,
1126
- setFilter,
1127
- 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),
1128
1557
  dispose
1129
1558
  };
1130
1559
  }
@@ -1151,7 +1580,7 @@ var listViewProps = {
1151
1580
  paginationThreshold: { type: Number, default: 240 },
1152
1581
  ariaLabel: { type: String, default: "Conversations" }
1153
1582
  };
1154
- var ConversationListView = (0, import_vue9.defineComponent)({
1583
+ var ConversationListView = (0, import_vue10.defineComponent)({
1155
1584
  name: "ConversationListView",
1156
1585
  inheritAttrs: false,
1157
1586
  props: listViewProps,
@@ -1161,7 +1590,7 @@ var ConversationListView = (0, import_vue9.defineComponent)({
1161
1590
  "load-more": () => true
1162
1591
  },
1163
1592
  setup(props, { attrs, emit, slots }) {
1164
- const internalElement = (0, import_vue9.ref)(null);
1593
+ const internalElement = (0, import_vue10.ref)(null);
1165
1594
  let requestInFlight = false;
1166
1595
  let lastRequestedLength = null;
1167
1596
  const appearance = () => ({
@@ -1191,36 +1620,36 @@ var ConversationListView = (0, import_vue9.defineComponent)({
1191
1620
  const renderContent = () => {
1192
1621
  const currentAppearance = appearance();
1193
1622
  if (props.isInitialLoading && props.conversations.length === 0) {
1194
- return slots["initial-loading"]?.() ?? (0, import_vue9.h)("div", {
1623
+ return slots["initial-loading"]?.() ?? (0, import_vue10.h)("div", {
1195
1624
  class: partClass("loading", currentAppearance, "ckui-state"),
1196
1625
  style: partStyle("loading", currentAppearance),
1197
1626
  role: "status"
1198
- }, [(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"]);
1199
1628
  }
1200
1629
  if (props.error && props.conversations.length === 0) {
1201
1630
  const retry = props.onRefresh ? () => {
1202
1631
  void refresh();
1203
1632
  } : void 0;
1204
- 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", {
1205
1634
  class: partClass("error", currentAppearance, "ckui-state ckui-state--error"),
1206
1635
  style: partStyle("error", currentAppearance),
1207
1636
  role: "alert"
1208
1637
  }, [
1209
- (0, import_vue9.h)("span", errorMessage(props.error)),
1210
- 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
1211
1640
  ]);
1212
1641
  }
1213
1642
  if (props.conversations.length === 0) {
1214
- return slots.empty?.() ?? (0, import_vue9.h)("div", {
1643
+ return slots.empty?.() ?? (0, import_vue10.h)("div", {
1215
1644
  class: partClass("empty", currentAppearance, "ckui-state"),
1216
1645
  style: partStyle("empty", currentAppearance)
1217
- }, [(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"]);
1218
1647
  }
1219
1648
  const children = props.conversations.flatMap((conversation, index) => {
1220
1649
  const selected = props.selectedConversationId === conversation.id;
1221
1650
  const select = () => selectConversation(conversation);
1222
1651
  const slotProps = { conversation, index, selected, select };
1223
- const item = slots["conversation-item"]?.(slotProps) ?? (0, import_vue9.h)("button", {
1652
+ const item = slots["conversation-item"]?.(slotProps) ?? (0, import_vue10.h)("button", {
1224
1653
  type: "button",
1225
1654
  "data-selected": selected || void 0,
1226
1655
  "aria-current": selected ? "true" : void 0,
@@ -1228,54 +1657,54 @@ var ConversationListView = (0, import_vue9.defineComponent)({
1228
1657
  class: partClass("listItem", currentAppearance, "ckui-conversation-item"),
1229
1658
  style: partStyle("listItem", currentAppearance)
1230
1659
  }, [
1231
- (0, import_vue9.h)(ConvoKitAvatar, {
1660
+ (0, import_vue10.h)(ConvoKitAvatar, {
1232
1661
  name: conversation.displayTitle,
1233
1662
  src: conversation.imageUrl,
1234
1663
  class: partClass("avatar", currentAppearance, ""),
1235
1664
  style: partStyle("avatar", currentAppearance)
1236
1665
  }),
1237
- (0, import_vue9.h)("span", { class: "ckui-conversation-item__body" }, [
1238
- (0, import_vue9.h)("strong", conversation.displayTitle),
1239
- (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")
1240
1669
  ]),
1241
- (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" })
1242
1671
  ]);
1243
- 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])];
1244
1673
  if (index < props.conversations.length - 1) {
1245
- 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" })));
1246
1675
  }
1247
1676
  return nodes;
1248
1677
  });
1249
1678
  if (props.error) {
1250
- 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", {
1251
1680
  class: partClass("error", currentAppearance, "ckui-inline-state ckui-state--error"),
1252
1681
  style: partStyle("error", currentAppearance),
1253
1682
  role: "alert"
1254
- }, [(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: () => {
1255
1684
  void requestMore();
1256
1685
  } }, "Retry")]));
1257
1686
  } else if (props.isLoadingMore) {
1258
- children.push(slots["load-more"]?.() ?? (0, import_vue9.h)("div", {
1687
+ children.push(slots["load-more"]?.() ?? (0, import_vue10.h)("div", {
1259
1688
  class: partClass("loading", currentAppearance, "ckui-inline-state"),
1260
1689
  style: partStyle("loading", currentAppearance),
1261
1690
  role: "status"
1262
- }, [(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"]));
1263
1692
  }
1264
- return (0, import_vue9.h)("div", {
1693
+ return (0, import_vue10.h)("div", {
1265
1694
  role: "list",
1266
1695
  class: partClass("list", currentAppearance, "ckui-conversation-list__items"),
1267
1696
  style: partStyle("list", currentAppearance)
1268
1697
  }, children);
1269
1698
  };
1270
- return () => (0, import_vue9.h)("div", {
1699
+ return () => (0, import_vue10.h)("div", {
1271
1700
  ...attrs,
1272
1701
  class: cx(!props.unstyled && "ckui ckui-conversation-list", props.classNames?.root, attrs.class),
1273
1702
  style: [props.styles?.root, attrs.style],
1274
1703
  "data-density": props.density
1275
1704
  }, [
1276
- props.onRefresh ? (0, import_vue9.h)("div", { class: "ckui-conversation-list__toolbar" }, [
1277
- (0, import_vue9.h)("span", props.ariaLabel),
1278
- (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", {
1279
1708
  type: "button",
1280
1709
  "aria-label": "Refresh conversations",
1281
1710
  class: partClass("button", appearance(), "ckui-icon-button"),
@@ -1283,9 +1712,9 @@ var ConversationListView = (0, import_vue9.defineComponent)({
1283
1712
  onClick: () => {
1284
1713
  void refresh();
1285
1714
  }
1286
- }, [(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" })])
1287
1716
  ]) : null,
1288
- (0, import_vue9.h)("div", {
1717
+ (0, import_vue10.h)("div", {
1289
1718
  ref: (element) => {
1290
1719
  internalElement.value = element;
1291
1720
  if (props.scrollElement) props.scrollElement.value = element;
@@ -1303,7 +1732,7 @@ var ConversationListView = (0, import_vue9.defineComponent)({
1303
1732
  ]);
1304
1733
  }
1305
1734
  });
1306
- var ConversationList = (0, import_vue9.defineComponent)({
1735
+ var ConversationList = (0, import_vue10.defineComponent)({
1307
1736
  name: "ConversationList",
1308
1737
  inheritAttrs: false,
1309
1738
  props: {
@@ -1326,7 +1755,7 @@ var ConversationList = (0, import_vue9.defineComponent)({
1326
1755
  autoLoad: props.autoLoad
1327
1756
  });
1328
1757
  expose({ controller });
1329
- (0, import_vue9.watchEffect)(() => {
1758
+ (0, import_vue10.watchEffect)(() => {
1330
1759
  emit("controller-change", controller);
1331
1760
  });
1332
1761
  return () => {
@@ -1346,7 +1775,7 @@ var ConversationList = (0, import_vue9.defineComponent)({
1346
1775
  error: _error,
1347
1776
  ...forwarded
1348
1777
  } = props;
1349
- return (0, import_vue9.h)(ConversationListView, {
1778
+ return (0, import_vue10.h)(ConversationListView, {
1350
1779
  ...attrs,
1351
1780
  ...forwarded,
1352
1781
  conversations: controller.conversations.value,
@@ -1365,7 +1794,7 @@ var ConversationList = (0, import_vue9.defineComponent)({
1365
1794
  });
1366
1795
 
1367
1796
  // src/theme.ts
1368
- var import_vue10 = require("vue");
1797
+ var import_vue11 = require("vue");
1369
1798
  var defaultConvoKitTheme = {
1370
1799
  background: "#fafafa",
1371
1800
  surface: "#ffffff",
@@ -1382,8 +1811,8 @@ var defaultConvoKitTheme = {
1382
1811
  fontFamily: 'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'
1383
1812
  };
1384
1813
  var themeKey = /* @__PURE__ */ Symbol("ConvoKitTheme");
1385
- var defaultThemeRef = (0, import_vue10.computed)(() => defaultConvoKitTheme);
1386
- var ConvoKitThemeProvider = (0, import_vue10.defineComponent)({
1814
+ var defaultThemeRef = (0, import_vue11.computed)(() => defaultConvoKitTheme);
1815
+ var ConvoKitThemeProvider = (0, import_vue11.defineComponent)({
1387
1816
  name: "ConvoKitThemeProvider",
1388
1817
  inheritAttrs: false,
1389
1818
  props: {
@@ -1392,9 +1821,9 @@ var ConvoKitThemeProvider = (0, import_vue10.defineComponent)({
1392
1821
  style: { type: [String, Array, Object], default: void 0 }
1393
1822
  },
1394
1823
  setup(props, { attrs, slots }) {
1395
- const parent = (0, import_vue10.inject)(themeKey, defaultThemeRef);
1396
- const value = (0, import_vue10.computed)(() => ({ ...parent.value, ...props.theme }));
1397
- (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);
1398
1827
  return () => {
1399
1828
  const theme = value.value;
1400
1829
  const variables = {
@@ -1412,7 +1841,7 @@ var ConvoKitThemeProvider = (0, import_vue10.defineComponent)({
1412
1841
  "--ckui-avatar-size": theme.avatarSize,
1413
1842
  "--ckui-font": theme.fontFamily
1414
1843
  };
1415
- return (0, import_vue10.h)("div", {
1844
+ return (0, import_vue11.h)("div", {
1416
1845
  ...attrs,
1417
1846
  class: cx("ckui-theme", props.class, attrs.class),
1418
1847
  style: [variables, props.style, attrs.style]
@@ -1421,7 +1850,7 @@ var ConvoKitThemeProvider = (0, import_vue10.defineComponent)({
1421
1850
  }
1422
1851
  });
1423
1852
  function useConvoKitTheme() {
1424
- return (0, import_vue10.inject)(themeKey, defaultThemeRef);
1853
+ return (0, import_vue11.inject)(themeKey, defaultThemeRef);
1425
1854
  }
1426
1855
  // Annotate the CommonJS export names for ESM import in node:
1427
1856
  0 && (module.exports = {