@convokitapp/vue-ui 0.1.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.js ADDED
@@ -0,0 +1,1409 @@
1
+ // src/client.ts
2
+ function createConvoKitUiClient(client) {
3
+ return {
4
+ get currentUserId() {
5
+ return client.currentUserId;
6
+ },
7
+ getConversations: (options) => client.getConversations(options),
8
+ getConversation: (conversationId) => client.getConversation(conversationId),
9
+ getMessages: (options) => client.getMessages(options),
10
+ sendMessage: (input) => client.sendMessage(input),
11
+ markConversationRead: (conversationId) => client.markConversationRead(conversationId),
12
+ sendTyping: (input) => client.sendTyping(input),
13
+ onMessage: (conversationId, handler, onError) => client.realtime.onMessage(conversationId, {
14
+ onEvent: handler,
15
+ ...onError ? { onError } : {}
16
+ }),
17
+ onReadReceipt: (conversationId, handler, onError) => client.realtime.onReadReceipt(conversationId, {
18
+ onEvent: handler,
19
+ ...onError ? { onError } : {}
20
+ }),
21
+ onTyping: (conversationId, handler, onError) => client.realtime.onTyping(conversationId, {
22
+ onEvent: handler,
23
+ ...onError ? { onError } : {}
24
+ })
25
+ };
26
+ }
27
+
28
+ // src/components/avatar.ts
29
+ import { AvatarFallback, AvatarImage, AvatarRoot } from "reka-ui";
30
+ import { defineComponent, h } from "vue";
31
+
32
+ // src/utils.ts
33
+ import { clsx } from "clsx";
34
+ function cx(...values) {
35
+ return clsx(values);
36
+ }
37
+ function requestedParticipantIds(filter) {
38
+ const values = filter.participantIds ?? [];
39
+ return values instanceof Set ? values : new Set(values);
40
+ }
41
+ function matchesConversation(conversation, filter) {
42
+ const query = filter.query?.trim().toLocaleLowerCase() ?? "";
43
+ if (query) {
44
+ const haystack = [
45
+ conversation.id,
46
+ conversation.displayTitle,
47
+ conversation.title ?? "",
48
+ conversation.description ?? "",
49
+ ...conversation.participants.flatMap((participant) => [participant.id, participant.appUserId, participant.name])
50
+ ].join(" ").toLocaleLowerCase();
51
+ if (!haystack.includes(query)) return false;
52
+ }
53
+ const requested = requestedParticipantIds(filter);
54
+ if (requested.size > 0) {
55
+ const available = new Set(conversation.participants.flatMap((participant) => [participant.id, participant.appUserId]));
56
+ const values = [...requested];
57
+ const matches = filter.requireAllParticipants ? values.every((id) => available.has(id)) : values.some((id) => available.has(id));
58
+ if (!matches) return false;
59
+ }
60
+ return filter.predicate?.(conversation) ?? true;
61
+ }
62
+ function applyConversationFilter(conversations, filter) {
63
+ const result = conversations.filter((conversation) => matchesConversation(conversation, filter));
64
+ if (filter.comparator) result.sort(filter.comparator);
65
+ return result;
66
+ }
67
+ function mergeConversations(current, incoming) {
68
+ const byId = new Map(current.map((conversation) => [conversation.id, conversation]));
69
+ for (const conversation of incoming) byId.set(conversation.id, conversation);
70
+ return [...byId.values()];
71
+ }
72
+ function mergeMessages(current, incoming) {
73
+ const byId = new Map(current.map((message) => [message.id, message]));
74
+ for (const message of incoming) byId.set(message.id, message);
75
+ return [...byId.values()].sort((left, right) => {
76
+ const byTime = left.createdAt.getTime() - right.createdAt.getTime();
77
+ return byTime === 0 ? left.id.localeCompare(right.id) : byTime;
78
+ });
79
+ }
80
+ function readerIdsFor(message, readAtByUserId) {
81
+ return new Set([...readAtByUserId.entries()].filter(([userId, readAt]) => userId !== message.senderId && readAt.getTime() >= message.createdAt.getTime()).map(([userId]) => userId));
82
+ }
83
+ function partClass(part, appearance, defaultClass) {
84
+ return cx(!appearance.unstyled && defaultClass, appearance.classNames?.[part]);
85
+ }
86
+ function partStyle(part, appearance) {
87
+ return appearance.styles?.[part];
88
+ }
89
+ function errorMessage(error) {
90
+ return error instanceof Error ? error.message : String(error);
91
+ }
92
+ function formatFileSize(size) {
93
+ if (size === void 0 || !Number.isFinite(size) || size < 0) return null;
94
+ if (size < 1024) return `${Math.round(size)} B`;
95
+ if (size < 1024 * 1024) return `${(size / 1024).toFixed(size < 10 * 1024 ? 1 : 0)} KB`;
96
+ return `${(size / (1024 * 1024)).toFixed(size < 10 * 1024 * 1024 ? 1 : 0)} MB`;
97
+ }
98
+ function initials(value) {
99
+ const words = value.trim().split(/\s+/).filter(Boolean);
100
+ return (words.length > 1 ? `${words[0]?.[0] ?? ""}${words.at(-1)?.[0] ?? ""}` : value[0] ?? "?").toLocaleUpperCase();
101
+ }
102
+
103
+ // src/components/avatar.ts
104
+ var ConvoKitAvatar = defineComponent({
105
+ name: "ConvoKitAvatar",
106
+ inheritAttrs: false,
107
+ props: {
108
+ name: { type: String, required: true },
109
+ src: { type: String, default: null }
110
+ },
111
+ setup(props, { attrs }) {
112
+ return () => h(AvatarRoot, {
113
+ ...attrs,
114
+ class: cx("ckui-avatar", attrs.class)
115
+ }, {
116
+ default: () => [
117
+ props.src ? h(AvatarImage, { class: "ckui-avatar__image", src: props.src, alt: "" }) : null,
118
+ h(AvatarFallback, {
119
+ class: "ckui-avatar__fallback",
120
+ ...props.src ? { delayMs: 300 } : {}
121
+ }, () => initials(props.name))
122
+ ]
123
+ });
124
+ }
125
+ });
126
+
127
+ // src/components/conversation.ts
128
+ import { ArrowLeft, LoaderCircle as LoaderCircle2, Paperclip, RefreshCw, Send } from "@lucide/vue";
129
+ import {
130
+ defineComponent as defineComponent3,
131
+ h as h3,
132
+ ref as ref3,
133
+ watchEffect
134
+ } from "vue";
135
+
136
+ // src/composables/use-conversation.ts
137
+ import {
138
+ computed,
139
+ getCurrentScope,
140
+ onScopeDispose,
141
+ ref,
142
+ shallowRef,
143
+ toValue,
144
+ watch
145
+ } from "vue";
146
+ function useConversation(options) {
147
+ const messagePageSize = options.messagePageSize ?? 30;
148
+ const typingTimeoutMs = options.typingTimeoutMs ?? 3e3;
149
+ if (!toValue(options.conversationId).trim()) throw new TypeError("conversationId is required");
150
+ if (!Number.isInteger(messagePageSize) || messagePageSize <= 0) throw new RangeError("messagePageSize must be a positive integer");
151
+ if (!Number.isFinite(typingTimeoutMs) || typingTimeoutMs < 0) throw new RangeError("typingTimeoutMs must be non-negative");
152
+ const conversation = shallowRef(null);
153
+ const messages = shallowRef([]);
154
+ const typingUserIds = shallowRef(/* @__PURE__ */ new Set());
155
+ const readAtByUserId = shallowRef(/* @__PURE__ */ new Map());
156
+ const isInitialLoading = ref(false);
157
+ const isLoadingOlder = ref(false);
158
+ const isSending = ref(false);
159
+ const hasOlderMessages = ref(true);
160
+ const hasLoaded = ref(false);
161
+ const error = shallowRef(null);
162
+ const currentUserId = computed(() => toValue(options.client).currentUserId);
163
+ let generation = 0;
164
+ let disposed = false;
165
+ let sentTyping = false;
166
+ let typingTimer = null;
167
+ let subscriptions = [];
168
+ const unsubscribe = async () => {
169
+ const active = subscriptions;
170
+ subscriptions = [];
171
+ await Promise.all(active.map((subscription) => subscription.unsubscribe()));
172
+ };
173
+ const markRead = async () => {
174
+ const client = toValue(options.client);
175
+ const conversationId = toValue(options.conversationId);
176
+ try {
177
+ await client.markConversationRead(conversationId);
178
+ if (disposed) return;
179
+ readAtByUserId.value = new Map(readAtByUserId.value).set(client.currentUserId, /* @__PURE__ */ new Date());
180
+ } catch (cause) {
181
+ if (!disposed) error.value = cause;
182
+ }
183
+ };
184
+ const subscribe = (activeGeneration, client, conversationId) => {
185
+ const report = (cause) => {
186
+ if (!disposed && activeGeneration === generation) error.value = cause;
187
+ };
188
+ subscriptions = [
189
+ client.onMessage(conversationId, (message) => {
190
+ if (disposed || activeGeneration !== generation) return;
191
+ messages.value = mergeMessages(messages.value, [message]);
192
+ if ((options.markReadOnReceive ?? true) && message.senderId !== client.currentUserId) void markRead();
193
+ }, report),
194
+ client.onReadReceipt(conversationId, ({ userId, readAt }) => {
195
+ if (disposed || activeGeneration !== generation) return;
196
+ readAtByUserId.value = new Map(readAtByUserId.value).set(userId, readAt);
197
+ }, report),
198
+ client.onTyping(conversationId, ({ userId, isTyping }) => {
199
+ if (disposed || activeGeneration !== generation || userId === client.currentUserId) return;
200
+ const next = new Set(typingUserIds.value);
201
+ if (isTyping) next.add(userId);
202
+ else next.delete(userId);
203
+ typingUserIds.value = next;
204
+ }, report)
205
+ ];
206
+ };
207
+ const loadInitial = async () => {
208
+ const client = toValue(options.client);
209
+ const conversationId = toValue(options.conversationId).trim();
210
+ if (!conversationId) throw new TypeError("conversationId is required");
211
+ const activeGeneration = ++generation;
212
+ await unsubscribe();
213
+ if (disposed || activeGeneration !== generation) return;
214
+ messages.value = [];
215
+ conversation.value = null;
216
+ typingUserIds.value = /* @__PURE__ */ new Set();
217
+ readAtByUserId.value = /* @__PURE__ */ new Map();
218
+ hasOlderMessages.value = true;
219
+ error.value = null;
220
+ isInitialLoading.value = true;
221
+ isLoadingOlder.value = false;
222
+ subscribe(activeGeneration, client, conversationId);
223
+ try {
224
+ const [nextConversation, page] = await Promise.all([
225
+ client.getConversation(conversationId),
226
+ client.getMessages({ conversationId, limit: messagePageSize, offset: 0 })
227
+ ]);
228
+ if (disposed || activeGeneration !== generation) return;
229
+ conversation.value = nextConversation;
230
+ readAtByUserId.value = new Map(nextConversation.participants.flatMap((participant) => participant.lastReadAt ? [[participant.appUserId, participant.lastReadAt]] : []));
231
+ messages.value = mergeMessages([], page);
232
+ hasOlderMessages.value = page.length === messagePageSize;
233
+ if (options.markReadOnLoad ?? true) await markRead();
234
+ } catch (cause) {
235
+ if (!disposed && activeGeneration === generation) error.value = cause;
236
+ } finally {
237
+ if (!disposed && activeGeneration === generation) {
238
+ isInitialLoading.value = false;
239
+ hasLoaded.value = true;
240
+ }
241
+ }
242
+ };
243
+ const loadOlderMessages = async () => {
244
+ if (isInitialLoading.value || isLoadingOlder.value || !hasOlderMessages.value) return;
245
+ const activeGeneration = generation;
246
+ const client = toValue(options.client);
247
+ const conversationId = toValue(options.conversationId);
248
+ isLoadingOlder.value = true;
249
+ error.value = null;
250
+ try {
251
+ const page = await client.getMessages({
252
+ conversationId,
253
+ limit: messagePageSize,
254
+ offset: messages.value.length
255
+ });
256
+ if (disposed || activeGeneration !== generation) return;
257
+ messages.value = mergeMessages(messages.value, page);
258
+ hasOlderMessages.value = page.length === messagePageSize;
259
+ } catch (cause) {
260
+ if (!disposed && activeGeneration === generation) error.value = cause;
261
+ } finally {
262
+ if (!disposed && activeGeneration === generation) isLoadingOlder.value = false;
263
+ }
264
+ };
265
+ const updateTyping = async (nextTyping) => {
266
+ if (typingTimer) clearTimeout(typingTimer);
267
+ if (nextTyping) typingTimer = setTimeout(() => {
268
+ void updateTyping(false);
269
+ }, typingTimeoutMs);
270
+ if (sentTyping === nextTyping) return;
271
+ sentTyping = nextTyping;
272
+ try {
273
+ await toValue(options.client).sendTyping({
274
+ conversationId: toValue(options.conversationId),
275
+ isTyping: nextTyping
276
+ });
277
+ } catch (cause) {
278
+ if (!disposed) error.value = cause;
279
+ }
280
+ };
281
+ const sendMessage = async ({ text, media }) => {
282
+ const normalizedText = text?.trim();
283
+ if (!normalizedText && (!media || media.length === 0)) return null;
284
+ if (isSending.value) return null;
285
+ isSending.value = true;
286
+ error.value = null;
287
+ const activeGeneration = generation;
288
+ try {
289
+ const message = await toValue(options.client).sendMessage({
290
+ conversationId: toValue(options.conversationId),
291
+ ...normalizedText ? { text: normalizedText } : {},
292
+ ...media?.length ? { media } : {}
293
+ });
294
+ if (!disposed && activeGeneration === generation) messages.value = mergeMessages(messages.value, [message]);
295
+ await updateTyping(false);
296
+ return message;
297
+ } catch (cause) {
298
+ if (!disposed) error.value = cause;
299
+ return null;
300
+ } finally {
301
+ if (!disposed && activeGeneration === generation) isSending.value = false;
302
+ }
303
+ };
304
+ const dispose = async () => {
305
+ disposed = true;
306
+ generation += 1;
307
+ if (typingTimer) clearTimeout(typingTimer);
308
+ if (sentTyping) {
309
+ await toValue(options.client).sendTyping({
310
+ conversationId: toValue(options.conversationId),
311
+ isTyping: false
312
+ }).catch(() => void 0);
313
+ }
314
+ await unsubscribe();
315
+ };
316
+ if (options.autoLoad ?? true) {
317
+ watch(
318
+ () => [toValue(options.client), toValue(options.conversationId)],
319
+ () => {
320
+ disposed = false;
321
+ sentTyping = false;
322
+ void loadInitial();
323
+ },
324
+ { immediate: true }
325
+ );
326
+ }
327
+ if (getCurrentScope()) onScopeDispose(() => {
328
+ void dispose();
329
+ });
330
+ return {
331
+ conversation,
332
+ messages,
333
+ typingUserIds,
334
+ readAtByUserId,
335
+ isInitialLoading,
336
+ isLoadingOlder,
337
+ isSending,
338
+ hasOlderMessages,
339
+ hasLoaded,
340
+ error,
341
+ currentUserId,
342
+ readerIdsFor: (message) => readerIdsFor(message, readAtByUserId.value),
343
+ loadInitial,
344
+ refresh: loadInitial,
345
+ loadOlderMessages,
346
+ sendMessage,
347
+ markRead,
348
+ updateTyping,
349
+ dispose
350
+ };
351
+ }
352
+
353
+ // src/components/message-list.ts
354
+ import {
355
+ Check,
356
+ CheckCheck,
357
+ ContactRound,
358
+ Download,
359
+ FileText,
360
+ ImageOff,
361
+ LoaderCircle,
362
+ MapPin,
363
+ MessageCircle
364
+ } from "@lucide/vue";
365
+ import {
366
+ computed as computed2,
367
+ defineComponent as defineComponent2,
368
+ h as h2,
369
+ nextTick,
370
+ ref as ref2,
371
+ watch as watch2
372
+ } from "vue";
373
+ var appearanceProps = {
374
+ classNames: { type: Object, default: void 0 },
375
+ styles: { type: Object, default: void 0 },
376
+ density: { type: String, default: "comfortable" },
377
+ unstyled: { type: Boolean, default: false }
378
+ };
379
+ function defaultFormatTime(date) {
380
+ return new Intl.DateTimeFormat(void 0, { hour: "numeric", minute: "2-digit" }).format(date);
381
+ }
382
+ function defaultMedia(media, open, imageLoading) {
383
+ const tag = open ? "button" : "div";
384
+ const interactive = open ? { type: "button", onClick: open } : {};
385
+ if (media.type === "image") {
386
+ return h2(tag, { ...interactive, class: "ckui-media-card ckui-media-card--image" }, [
387
+ media.url ? h2("img", { src: media.url, alt: media.name ?? "Shared image", loading: imageLoading }) : h2("span", { class: "ckui-media-placeholder" }, [h2(ImageOff, { "aria-hidden": "true" }), " Image unavailable"]),
388
+ media.name ? h2("span", { class: "ckui-media-name" }, media.name) : null
389
+ ]);
390
+ }
391
+ if (media.type === "file") {
392
+ const size = formatFileSize(media.size);
393
+ return h2(tag, { ...interactive, class: "ckui-media-card ckui-media-card--file" }, [
394
+ h2(FileText, { "aria-hidden": "true" }),
395
+ h2("span", [h2("strong", media.name || "Attachment"), size ? h2("small", size) : null]),
396
+ open ? h2(Download, { size: 18, "aria-hidden": "true" }) : null
397
+ ]);
398
+ }
399
+ if (media.type === "location") {
400
+ const label = media.name || `${media.metadata.lat}, ${media.metadata.lng}`;
401
+ return h2(tag, { ...interactive, class: "ckui-media-card ckui-media-card--location" }, [
402
+ h2(MapPin, { "aria-hidden": "true" }),
403
+ h2("span", [h2("strong", label), h2("small", `${media.metadata.lat}, ${media.metadata.lng}`)])
404
+ ]);
405
+ }
406
+ const contact = media.metadata.email || media.metadata.phone || "Contact details";
407
+ return h2(tag, { ...interactive, class: "ckui-media-card ckui-media-card--contact" }, [
408
+ h2(ContactRound, { "aria-hidden": "true" }),
409
+ h2("span", [h2("strong", media.name || "Shared contact"), h2("small", String(contact))])
410
+ ]);
411
+ }
412
+ var MessageListView = defineComponent2({
413
+ name: "MessageListView",
414
+ inheritAttrs: false,
415
+ props: {
416
+ ...appearanceProps,
417
+ conversation: { type: Object, required: true },
418
+ messages: { type: Array, required: true },
419
+ currentUserId: { type: String, required: true },
420
+ readAtByUserId: { type: Object, default: () => /* @__PURE__ */ new Map() },
421
+ readersResolver: { type: Function, default: void 0 },
422
+ onLoadOlder: { type: Function, default: void 0 },
423
+ hasOlderMessages: { type: Boolean, default: false },
424
+ isLoadingOlder: { type: Boolean, default: false },
425
+ error: { type: null, required: false },
426
+ onAttachmentClick: { type: Function, default: void 0 },
427
+ scrollElement: { type: Object, default: void 0 },
428
+ paginationThreshold: { type: Number, default: 240 },
429
+ reverse: { type: Boolean, default: true },
430
+ stickToBottom: { type: Boolean, default: true },
431
+ formatTime: { type: Function, default: defaultFormatTime },
432
+ imageLoading: { type: String, default: "lazy" }
433
+ },
434
+ emits: ["load-older", "attachment-click"],
435
+ setup(props, { attrs, emit, slots }) {
436
+ const internalElement = ref2(null);
437
+ let requestInFlight = false;
438
+ let lastRequestedLength = null;
439
+ let previousMessageCount = 0;
440
+ const participants = computed2(() => new Map(props.conversation.participants.flatMap((participant) => [
441
+ [participant.id, participant],
442
+ [participant.appUserId, participant]
443
+ ])));
444
+ const appearance = () => ({
445
+ density: props.density,
446
+ unstyled: props.unstyled,
447
+ ...props.classNames ? { classNames: props.classNames } : {},
448
+ ...props.styles ? { styles: props.styles } : {}
449
+ });
450
+ const requestOlder = async () => {
451
+ if (requestInFlight || lastRequestedLength === props.messages.length || props.isLoadingOlder || !props.hasOlderMessages || !props.onLoadOlder) return;
452
+ requestInFlight = true;
453
+ lastRequestedLength = props.messages.length;
454
+ try {
455
+ await props.onLoadOlder();
456
+ } catch {
457
+ lastRequestedLength = null;
458
+ } finally {
459
+ requestInFlight = false;
460
+ }
461
+ };
462
+ watch2(() => [props.messages.length, props.hasOlderMessages], async ([count, hasOlder]) => {
463
+ const previous = previousMessageCount;
464
+ if (count !== previousMessageCount || !hasOlder) lastRequestedLength = null;
465
+ const appended = count > previous;
466
+ const element = internalElement.value;
467
+ previousMessageCount = count;
468
+ if (element && props.reverse && props.stickToBottom && appended) {
469
+ const distanceFromBottom = element.scrollHeight - element.scrollTop - element.clientHeight;
470
+ if (previous === 0 || distanceFromBottom < 320) {
471
+ await nextTick();
472
+ element.scrollTop = element.scrollHeight;
473
+ }
474
+ }
475
+ }, { flush: "post", immediate: true });
476
+ const renderMessage = (message, index) => {
477
+ const isCurrentUser = message.senderId === props.currentUserId;
478
+ const sender = participants.value.get(message.senderId);
479
+ const readerIds = props.readersResolver ? props.readersResolver(message) : readerIdsFor(message, props.readAtByUserId);
480
+ const slotProps = { message, chronologicalIndex: index, isCurrentUser, sender, readerIds };
481
+ const custom = slots.message?.(slotProps);
482
+ if (custom) return h2("div", { key: message.id, role: "listitem" }, custom);
483
+ const currentAppearance = appearance();
484
+ const messagePart = isCurrentUser ? "outgoingMessage" : "incomingMessage";
485
+ const mediaNodes = message.media.map((media, mediaIndex) => {
486
+ const open = props.onAttachmentClick ? () => {
487
+ props.onAttachmentClick?.(media, message);
488
+ } : void 0;
489
+ const mediaSlotProps = { media, message, isCurrentUser, ...open ? { open } : {} };
490
+ return h2("div", {
491
+ key: media.id ?? `${media.type}-${mediaIndex}`,
492
+ class: partClass("media", currentAppearance, "ckui-media"),
493
+ style: partStyle("media", currentAppearance)
494
+ }, slots.media?.(mediaSlotProps) ?? [defaultMedia(media, open, props.imageLoading)]);
495
+ });
496
+ const receiptSlotProps = { message, readerIds };
497
+ return h2("div", { key: message.id, role: "listitem" }, [
498
+ h2("article", {
499
+ class: cx(
500
+ !props.unstyled && "ckui-message-row",
501
+ isCurrentUser && !props.unstyled && "ckui-message-row--outgoing",
502
+ props.classNames?.message,
503
+ props.classNames?.[messagePart]
504
+ ),
505
+ style: [props.styles?.message, props.styles?.[messagePart]],
506
+ "data-message-id": message.id
507
+ }, [
508
+ h2("div", { class: "ckui-message-bubble" }, [
509
+ !isCurrentUser ? h2("strong", { class: "ckui-message-sender" }, sender?.name || message.senderId) : null,
510
+ message.text ? h2("div", { class: "ckui-message-text" }, message.text) : null,
511
+ ...mediaNodes,
512
+ h2("time", { class: "ckui-message-time", datetime: message.createdAt.toISOString() }, [
513
+ props.formatTime(message.createdAt),
514
+ isCurrentUser ? readerIds.size > 0 ? h2(CheckCheck, { size: 14, "aria-label": "Read" }) : h2(Check, { size: 14, "aria-label": "Delivered" }) : null
515
+ ])
516
+ ]),
517
+ isCurrentUser ? slots["read-receipt"]?.(receiptSlotProps) ?? h2("div", {
518
+ class: partClass("receipt", currentAppearance, "ckui-read-receipt"),
519
+ style: partStyle("receipt", currentAppearance)
520
+ }, readerIds.size > 0 ? `Read by ${readerIds.size}` : "Delivered") : null
521
+ ])
522
+ ]);
523
+ };
524
+ return () => {
525
+ const currentAppearance = appearance();
526
+ const children = [];
527
+ if (props.isLoadingOlder) {
528
+ children.push(slots["loading-older"]?.() ?? h2("div", {
529
+ class: partClass("loading", currentAppearance, "ckui-inline-state"),
530
+ style: partStyle("loading", currentAppearance),
531
+ role: "status"
532
+ }, [h2(LoaderCircle, { class: "ckui-spin", "aria-hidden": "true" }), " Loading older messages\u2026"]));
533
+ }
534
+ if (props.error) {
535
+ const retry = props.onLoadOlder ? () => {
536
+ void requestOlder();
537
+ } : void 0;
538
+ children.push(slots.error?.({ error: props.error, ...retry ? { retry } : {} }) ?? h2("div", {
539
+ class: partClass("error", currentAppearance, "ckui-inline-state ckui-state--error"),
540
+ style: partStyle("error", currentAppearance),
541
+ role: "alert"
542
+ }, [
543
+ h2("span", errorMessage(props.error)),
544
+ retry ? h2("button", { type: "button", class: "ckui-link-button", onClick: retry }, "Retry") : null
545
+ ]));
546
+ }
547
+ if (props.messages.length === 0 && !props.isLoadingOlder) {
548
+ children.push(slots.empty?.() ?? h2("div", {
549
+ class: partClass("empty", currentAppearance, "ckui-state"),
550
+ style: partStyle("empty", currentAppearance)
551
+ }, [h2(MessageCircle, { "aria-hidden": "true" }), " No messages yet"]));
552
+ } else {
553
+ children.push(...props.messages.map(renderMessage));
554
+ }
555
+ return h2("div", {
556
+ ...attrs,
557
+ ref: (element) => {
558
+ internalElement.value = element;
559
+ if (props.scrollElement) props.scrollElement.value = element;
560
+ },
561
+ class: cx(!props.unstyled && "ckui ckui-message-list", props.classNames?.messages, attrs.class),
562
+ style: [props.styles?.messages, attrs.style],
563
+ "data-density": props.density,
564
+ role: "log",
565
+ "aria-live": "polite",
566
+ "aria-label": `Messages in ${props.conversation.displayTitle}`,
567
+ onScroll: (event) => {
568
+ const nativeHandler = attrs.onScroll;
569
+ if (typeof nativeHandler === "function") nativeHandler(event);
570
+ const element = event.currentTarget;
571
+ const distanceFromOldest = props.reverse ? element.scrollTop : element.scrollHeight - element.scrollTop - element.clientHeight;
572
+ if (distanceFromOldest <= props.paginationThreshold) void requestOlder();
573
+ }
574
+ }, children);
575
+ };
576
+ }
577
+ });
578
+ function defaultReadersResolver(readAtByUserId) {
579
+ return (message) => readerIdsFor(message, readAtByUserId);
580
+ }
581
+
582
+ // src/components/conversation.ts
583
+ var appearanceProps2 = {
584
+ classNames: { type: Object, default: void 0 },
585
+ styles: { type: Object, default: void 0 },
586
+ density: { type: String, default: "comfortable" },
587
+ unstyled: { type: Boolean, default: false }
588
+ };
589
+ var viewProps = {
590
+ ...appearanceProps2,
591
+ conversation: { type: Object, required: true },
592
+ messages: { type: Array, required: true },
593
+ currentUserId: { type: String, required: true },
594
+ onSendMessage: { type: Function, required: true },
595
+ typingUserIds: { type: Object, default: () => /* @__PURE__ */ new Set() },
596
+ readAtByUserId: { type: Object, default: () => /* @__PURE__ */ new Map() },
597
+ readersResolver: { type: Function, default: void 0 },
598
+ onBack: { type: Function, default: void 0 },
599
+ onRefresh: { type: Function, default: void 0 },
600
+ onLoadOlder: { type: Function, default: void 0 },
601
+ onTypingChange: { type: Function, default: void 0 },
602
+ onAddAttachment: { type: Function, default: void 0 },
603
+ onAttachmentClick: { type: Function, default: void 0 },
604
+ isInitialLoading: { type: Boolean, default: false },
605
+ isLoadingOlder: { type: Boolean, default: false },
606
+ isSending: { type: Boolean, default: false },
607
+ hasOlderMessages: { type: Boolean, default: false },
608
+ error: { type: null, required: false },
609
+ messageError: { type: null, required: false },
610
+ displayNameForUser: { type: Function, default: void 0 },
611
+ reverseMessages: { type: Boolean, default: true },
612
+ stickToBottom: { type: Boolean, default: true },
613
+ paginationThreshold: { type: Number, default: 240 },
614
+ formatTime: { type: Function, default: void 0 },
615
+ imageLoading: { type: String, default: "lazy" },
616
+ composerPlaceholder: { type: String, default: "Write a message" },
617
+ composerAriaLabel: { type: String, default: "Message" },
618
+ composerProps: { type: Object, default: void 0 },
619
+ modelValue: { type: String, default: void 0 },
620
+ defaultDraft: { type: String, default: "" },
621
+ onDraftChange: { type: Function, default: void 0 }
622
+ };
623
+ function typingLabel(userIds, displayNameForUser) {
624
+ const names = [...userIds].map(displayNameForUser);
625
+ if (names.length === 0) return "";
626
+ if (names.length === 1) return `${names[0]} is typing\u2026`;
627
+ if (names.length === 2) return `${names[0]} and ${names[1]} are typing\u2026`;
628
+ return `${names[0]} and ${names.length - 1} others are typing\u2026`;
629
+ }
630
+ var ConversationView = defineComponent3({
631
+ name: "ConversationView",
632
+ inheritAttrs: false,
633
+ props: viewProps,
634
+ emits: [
635
+ "send-message",
636
+ "typing-change",
637
+ "back",
638
+ "refresh",
639
+ "load-older",
640
+ "add-attachment",
641
+ "attachment-click",
642
+ "update:modelValue"
643
+ ],
644
+ setup(props, { attrs, emit, slots }) {
645
+ const internalDraft = ref3(props.defaultDraft);
646
+ const submitting = ref3(false);
647
+ const appearance = () => ({
648
+ density: props.density,
649
+ unstyled: props.unstyled,
650
+ ...props.classNames ? { classNames: props.classNames } : {},
651
+ ...props.styles ? { styles: props.styles } : {}
652
+ });
653
+ const draft = () => props.modelValue ?? internalDraft.value;
654
+ const setDraft = (value) => {
655
+ if (props.modelValue === void 0) internalDraft.value = value;
656
+ props.onDraftChange?.(value);
657
+ emit("update:modelValue", value);
658
+ const isTyping = value.trim().length > 0;
659
+ void props.onTypingChange?.(isTyping);
660
+ };
661
+ const submit = async () => {
662
+ const text = draft().trim();
663
+ if (!text || props.isSending || submitting.value) return;
664
+ submitting.value = true;
665
+ try {
666
+ const shouldClear = await props.onSendMessage(text);
667
+ if (shouldClear !== false) setDraft("");
668
+ } finally {
669
+ submitting.value = false;
670
+ }
671
+ };
672
+ const nameForUser = (userId) => {
673
+ const custom = props.displayNameForUser?.(userId)?.trim();
674
+ if (custom) return custom;
675
+ const participant = props.conversation.participants.find((value) => value.id === userId || value.appUserId === userId);
676
+ return participant?.name.trim() || userId;
677
+ };
678
+ const goBack = () => {
679
+ props.onBack?.();
680
+ };
681
+ const refresh = () => props.onRefresh?.();
682
+ const loadOlder = () => props.onLoadOlder?.();
683
+ const addAttachment = () => {
684
+ props.onAddAttachment?.();
685
+ };
686
+ const renderHeader = () => {
687
+ const slotProps = {
688
+ conversation: props.conversation,
689
+ ...props.onBack ? { onBack: goBack } : {},
690
+ ...props.onRefresh ? { onRefresh: refresh } : {}
691
+ };
692
+ return slots.header?.(slotProps) ?? h3("header", {
693
+ class: partClass("header", appearance(), "ckui-conversation-header"),
694
+ style: partStyle("header", appearance())
695
+ }, [
696
+ props.onBack ? h3("button", {
697
+ type: "button",
698
+ "aria-label": "Back",
699
+ onClick: goBack,
700
+ class: partClass("button", appearance(), "ckui-icon-button"),
701
+ style: partStyle("button", appearance())
702
+ }, [h3(ArrowLeft, { size: 20, "aria-hidden": "true" })]) : null,
703
+ h3(ConvoKitAvatar, { name: props.conversation.displayTitle, src: props.conversation.imageUrl }),
704
+ h3("div", { class: "ckui-conversation-header__body" }, [
705
+ h3("strong", props.conversation.displayTitle),
706
+ h3("span", `${props.conversation.participants.length} participant${props.conversation.participants.length === 1 ? "" : "s"}`)
707
+ ]),
708
+ props.onRefresh ? h3("button", {
709
+ type: "button",
710
+ "aria-label": "Refresh conversation",
711
+ onClick: () => {
712
+ void refresh();
713
+ },
714
+ class: partClass("button", appearance(), "ckui-icon-button"),
715
+ style: partStyle("button", appearance())
716
+ }, [h3(RefreshCw, { size: 18, "aria-hidden": "true" })]) : null
717
+ ]);
718
+ };
719
+ const renderTyping = () => {
720
+ const slotProps = { userIds: props.typingUserIds, displayNameForUser: nameForUser };
721
+ return slots["typing-indicator"]?.(slotProps) ?? h3("div", {
722
+ class: partClass("typing", appearance(), "ckui-typing"),
723
+ style: partStyle("typing", appearance()),
724
+ "aria-live": "polite"
725
+ }, typingLabel(props.typingUserIds, nameForUser));
726
+ };
727
+ const renderComposer = () => {
728
+ const slotProps = {
729
+ value: draft(),
730
+ setValue: setDraft,
731
+ isSending: props.isSending || submitting.value,
732
+ send: () => {
733
+ void submit();
734
+ },
735
+ ...props.onAddAttachment ? { addAttachment } : {}
736
+ };
737
+ return slots.composer?.(slotProps) ?? h3("form", {
738
+ class: partClass("composer", appearance(), "ckui-composer"),
739
+ style: partStyle("composer", appearance()),
740
+ onSubmit: (event) => {
741
+ event.preventDefault();
742
+ void submit();
743
+ }
744
+ }, [
745
+ props.onAddAttachment ? h3("button", {
746
+ type: "button",
747
+ "aria-label": "Add attachment",
748
+ onClick: addAttachment,
749
+ class: partClass("button", appearance(), "ckui-icon-button"),
750
+ style: partStyle("button", appearance())
751
+ }, [h3(Paperclip, { size: 20, "aria-hidden": "true" })]) : null,
752
+ h3("textarea", {
753
+ ...props.composerProps,
754
+ rows: props.composerProps?.rows ?? 1,
755
+ placeholder: props.composerPlaceholder,
756
+ "aria-label": props.composerAriaLabel,
757
+ class: cx(!props.unstyled && "ckui-composer__input", props.classNames?.input, props.composerProps?.class),
758
+ style: [props.styles?.input, props.composerProps?.style],
759
+ value: draft(),
760
+ disabled: props.isSending || submitting.value,
761
+ onInput: (event) => {
762
+ const handler = props.composerProps?.onInput;
763
+ if (typeof handler === "function") handler(event);
764
+ if (!event.defaultPrevented) setDraft(event.currentTarget.value);
765
+ },
766
+ onKeydown: (event) => {
767
+ const handler = props.composerProps?.onKeydown;
768
+ if (typeof handler === "function") handler(event);
769
+ if (event.defaultPrevented) return;
770
+ if (event.key === "Enter" && !event.shiftKey) {
771
+ event.preventDefault();
772
+ void submit();
773
+ }
774
+ }
775
+ }),
776
+ h3("button", {
777
+ type: "submit",
778
+ "aria-label": "Send message",
779
+ disabled: !draft().trim() || props.isSending || submitting.value,
780
+ class: partClass("button", appearance(), "ckui-send-button"),
781
+ style: partStyle("button", appearance())
782
+ }, [props.isSending || submitting.value ? h3(LoaderCircle2, { class: "ckui-spin", size: 18, "aria-hidden": "true" }) : h3(Send, { size: 18, "aria-hidden": "true" })])
783
+ ]);
784
+ };
785
+ return () => {
786
+ if (props.isInitialLoading && props.messages.length === 0) {
787
+ return h3("div", {
788
+ ...attrs,
789
+ class: cx(!props.unstyled && "ckui ckui-conversation", props.classNames?.root, attrs.class),
790
+ style: [props.styles?.root, attrs.style],
791
+ "data-density": props.density
792
+ }, slots.loading?.() ?? h3("div", {
793
+ class: partClass("loading", appearance(), "ckui-state"),
794
+ style: partStyle("loading", appearance()),
795
+ role: "status"
796
+ }, [h3(LoaderCircle2, { class: "ckui-spin", "aria-hidden": "true" }), " Loading conversation\u2026"]));
797
+ }
798
+ const children = [renderHeader()];
799
+ if (props.error) {
800
+ const retry = props.onRefresh ? () => {
801
+ void refresh();
802
+ } : void 0;
803
+ children.push(slots.error?.({ error: props.error, ...retry ? { retry } : {} }) ?? h3("div", {
804
+ class: partClass("error", appearance(), "ckui-conversation-error"),
805
+ style: partStyle("error", appearance()),
806
+ role: "alert"
807
+ }, [h3("span", errorMessage(props.error)), retry ? h3("button", { type: "button", class: "ckui-link-button", onClick: retry }, "Retry") : null]));
808
+ }
809
+ const messageSlots = {
810
+ ...slots.message ? { message: slots.message } : {},
811
+ ...slots.media ? { media: slots.media } : {},
812
+ ...slots["read-receipt"] ? { "read-receipt": slots["read-receipt"] } : {},
813
+ ...slots.empty ? { empty: slots.empty } : {},
814
+ ...slots["loading-older"] ? { "loading-older": slots["loading-older"] } : {},
815
+ ...slots["message-error"] ? { error: slots["message-error"] } : {}
816
+ };
817
+ children.push(h3(MessageListView, {
818
+ conversation: props.conversation,
819
+ messages: props.messages,
820
+ currentUserId: props.currentUserId,
821
+ readAtByUserId: props.readAtByUserId,
822
+ ...props.readersResolver ? { readersResolver: props.readersResolver } : {},
823
+ ...props.onLoadOlder ? { onLoadOlder: loadOlder } : {},
824
+ hasOlderMessages: props.hasOlderMessages,
825
+ isLoadingOlder: props.isLoadingOlder,
826
+ ...props.messageError == null ? {} : { error: props.messageError },
827
+ ...props.onAttachmentClick ? { onAttachmentClick: (media, message) => {
828
+ props.onAttachmentClick?.(media, message);
829
+ } } : {},
830
+ reverse: props.reverseMessages,
831
+ stickToBottom: props.stickToBottom,
832
+ paginationThreshold: props.paginationThreshold,
833
+ ...props.formatTime ? { formatTime: props.formatTime } : {},
834
+ imageLoading: props.imageLoading,
835
+ ...props.classNames ? { classNames: props.classNames } : {},
836
+ ...props.styles ? { styles: props.styles } : {},
837
+ density: props.density,
838
+ unstyled: props.unstyled
839
+ }, messageSlots));
840
+ children.push(renderTyping(), renderComposer());
841
+ return h3("section", {
842
+ ...attrs,
843
+ class: cx(!props.unstyled && "ckui ckui-conversation", props.classNames?.root, attrs.class),
844
+ style: [props.styles?.root, attrs.style],
845
+ "data-density": props.density,
846
+ "aria-label": props.conversation.displayTitle
847
+ }, children);
848
+ };
849
+ }
850
+ });
851
+ var Conversation = defineComponent3({
852
+ name: "Conversation",
853
+ inheritAttrs: false,
854
+ props: {
855
+ ...viewProps,
856
+ conversation: { type: Object, default: void 0 },
857
+ messages: { type: Array, default: () => [] },
858
+ currentUserId: { type: String, default: "" },
859
+ onSendMessage: { type: Function, default: void 0 },
860
+ client: { type: Object, required: true },
861
+ conversationId: { type: String, required: true },
862
+ messagePageSize: { type: Number, default: 30 },
863
+ markReadOnLoad: { type: Boolean, default: true },
864
+ markReadOnReceive: { type: Boolean, default: true },
865
+ typingTimeoutMs: { type: Number, default: 3e3 },
866
+ autoLoad: { type: Boolean, default: true },
867
+ onControllerChange: { type: Function, default: void 0 }
868
+ },
869
+ emits: ["controller-change", "send-message", "typing-change", "back", "refresh", "load-older", "add-attachment", "attachment-click", "update:modelValue"],
870
+ setup(props, { attrs, emit, expose, slots }) {
871
+ const controller = useConversation({
872
+ client: () => props.client,
873
+ conversationId: () => props.conversationId,
874
+ messagePageSize: props.messagePageSize,
875
+ markReadOnLoad: props.markReadOnLoad,
876
+ markReadOnReceive: props.markReadOnReceive,
877
+ typingTimeoutMs: props.typingTimeoutMs,
878
+ autoLoad: props.autoLoad
879
+ });
880
+ expose({ controller });
881
+ watchEffect(() => {
882
+ emit("controller-change", controller);
883
+ });
884
+ return () => {
885
+ const loadedConversation = controller.conversation.value;
886
+ if (!loadedConversation) {
887
+ const retry = () => {
888
+ void controller.refresh();
889
+ };
890
+ return h3("div", {
891
+ ...attrs,
892
+ class: cx(!props.unstyled && "ckui ckui-conversation", props.classNames?.root, attrs.class),
893
+ style: [props.styles?.root, attrs.style],
894
+ "data-density": props.density
895
+ }, controller.error.value ? slots.error?.({ error: controller.error.value, retry }) ?? h3("div", { class: "ckui-state ckui-state--error", role: "alert" }, [
896
+ h3("span", errorMessage(controller.error.value)),
897
+ h3("button", { type: "button", class: "ckui-link-button", onClick: retry }, "Try again")
898
+ ]) : slots.loading?.() ?? h3("div", { class: "ckui-state", role: "status" }, [
899
+ h3(LoaderCircle2, { class: "ckui-spin", "aria-hidden": "true" }),
900
+ " Loading conversation\u2026"
901
+ ]));
902
+ }
903
+ const {
904
+ client: _client,
905
+ conversationId: _conversationId,
906
+ messagePageSize: _messagePageSize,
907
+ markReadOnLoad: _markReadOnLoad,
908
+ markReadOnReceive: _markReadOnReceive,
909
+ typingTimeoutMs: _typingTimeoutMs,
910
+ autoLoad: _autoLoad,
911
+ onControllerChange: _onControllerChange,
912
+ conversation: _conversation,
913
+ messages: _messages,
914
+ currentUserId: _currentUserId,
915
+ onSendMessage: _onSendMessage,
916
+ typingUserIds: _typingUserIds,
917
+ readAtByUserId: _readAtByUserId,
918
+ onRefresh: _onRefresh,
919
+ onLoadOlder: _onLoadOlder,
920
+ onTypingChange: _onTypingChange,
921
+ isInitialLoading: _isInitialLoading,
922
+ isLoadingOlder: _isLoadingOlder,
923
+ isSending: _isSending,
924
+ hasOlderMessages: _hasOlderMessages,
925
+ error: _error,
926
+ ...forwarded
927
+ } = props;
928
+ return h3(ConversationView, {
929
+ ...attrs,
930
+ ...forwarded,
931
+ conversation: loadedConversation,
932
+ messages: controller.messages.value,
933
+ currentUserId: controller.currentUserId.value,
934
+ onSendMessage: async (text) => {
935
+ emit("send-message", text);
936
+ return await controller.sendMessage({ text }) !== null;
937
+ },
938
+ typingUserIds: controller.typingUserIds.value,
939
+ readAtByUserId: controller.readAtByUserId.value,
940
+ onRefresh: controller.refresh,
941
+ onLoadOlder: controller.loadOlderMessages,
942
+ onTypingChange: controller.updateTyping,
943
+ isInitialLoading: controller.isInitialLoading.value,
944
+ isLoadingOlder: controller.isLoadingOlder.value,
945
+ isSending: controller.isSending.value,
946
+ hasOlderMessages: controller.hasOlderMessages.value,
947
+ ...controller.error.value == null ? {} : { error: controller.error.value },
948
+ "onUpdate:modelValue": (value) => emit("update:modelValue", value),
949
+ ...props.onBack ? { onBack: () => {
950
+ props.onBack?.();
951
+ emit("back");
952
+ } } : {},
953
+ ...props.onAddAttachment ? { onAddAttachment: () => {
954
+ props.onAddAttachment?.();
955
+ emit("add-attachment");
956
+ } } : {},
957
+ ...props.onAttachmentClick ? { onAttachmentClick: (media, message) => {
958
+ props.onAttachmentClick?.(media, message);
959
+ emit("attachment-click", media, message);
960
+ } } : {}
961
+ }, slots);
962
+ };
963
+ }
964
+ });
965
+
966
+ // src/components/conversation-list.ts
967
+ import { ChevronRight, Inbox, LoaderCircle as LoaderCircle3, RefreshCw as RefreshCw2 } from "@lucide/vue";
968
+ import {
969
+ defineComponent as defineComponent4,
970
+ h as h4,
971
+ ref as ref5,
972
+ watchEffect as watchEffect2
973
+ } from "vue";
974
+
975
+ // src/composables/use-conversation-list.ts
976
+ import {
977
+ computed as computed3,
978
+ getCurrentScope as getCurrentScope2,
979
+ onScopeDispose as onScopeDispose2,
980
+ ref as ref4,
981
+ shallowRef as shallowRef2,
982
+ toValue as toValue2,
983
+ watch as watch3
984
+ } from "vue";
985
+ function useConversationList(options) {
986
+ const pageSize = options.pageSize ?? 30;
987
+ if (!Number.isInteger(pageSize) || pageSize <= 0) throw new RangeError("pageSize must be a positive integer");
988
+ const source = shallowRef2([]);
989
+ const filter = shallowRef2(options.initialFilter ?? {});
990
+ const isInitialLoading = ref4(false);
991
+ const isLoadingMore = ref4(false);
992
+ const hasMore = ref4(true);
993
+ const hasLoaded = ref4(false);
994
+ const error = shallowRef2(null);
995
+ const conversations = computed3(() => applyConversationFilter(source.value, filter.value));
996
+ let offset = 0;
997
+ let generation = 0;
998
+ let disposed = false;
999
+ const loadUntilVisible = async (activeGeneration, activeFilter) => {
1000
+ const visibleBefore = applyConversationFilter(source.value, activeFilter).length;
1001
+ while (true) {
1002
+ const request = { limit: pageSize, offset, filter: activeFilter };
1003
+ const page = options.pageLoader ? await options.pageLoader(request) : await toValue2(options.client).getConversations({
1004
+ limit: pageSize,
1005
+ offset: request.offset,
1006
+ archived: activeFilter.archived ?? false
1007
+ });
1008
+ if (disposed || activeGeneration !== generation) return;
1009
+ offset += page.length;
1010
+ hasMore.value = page.length === pageSize;
1011
+ source.value = mergeConversations(source.value, page);
1012
+ if (!hasMore.value || applyConversationFilter(source.value, activeFilter).length > visibleBefore) return;
1013
+ }
1014
+ };
1015
+ const loadInitialFor = async (activeFilter = filter.value) => {
1016
+ const activeGeneration = ++generation;
1017
+ source.value = [];
1018
+ offset = 0;
1019
+ hasMore.value = true;
1020
+ isInitialLoading.value = true;
1021
+ isLoadingMore.value = false;
1022
+ error.value = null;
1023
+ try {
1024
+ await loadUntilVisible(activeGeneration, activeFilter);
1025
+ } catch (cause) {
1026
+ if (!disposed && activeGeneration === generation) error.value = cause;
1027
+ } finally {
1028
+ if (!disposed && activeGeneration === generation) {
1029
+ isInitialLoading.value = false;
1030
+ hasLoaded.value = true;
1031
+ }
1032
+ }
1033
+ };
1034
+ const loadMore = async () => {
1035
+ if (isInitialLoading.value || isLoadingMore.value || !hasMore.value) return;
1036
+ const activeGeneration = generation;
1037
+ isLoadingMore.value = true;
1038
+ error.value = null;
1039
+ try {
1040
+ await loadUntilVisible(activeGeneration, filter.value);
1041
+ } catch (cause) {
1042
+ if (!disposed && activeGeneration === generation) error.value = cause;
1043
+ } finally {
1044
+ if (!disposed && activeGeneration === generation) isLoadingMore.value = false;
1045
+ }
1046
+ };
1047
+ const setFilter = async (nextFilter) => {
1048
+ const previousArchived = filter.value.archived ?? false;
1049
+ filter.value = nextFilter;
1050
+ error.value = null;
1051
+ if ((nextFilter.archived ?? false) !== previousArchived || !hasLoaded.value) {
1052
+ await loadInitialFor(nextFilter);
1053
+ } else if (applyConversationFilter(source.value, nextFilter).length === 0 && hasMore.value) {
1054
+ await loadMore();
1055
+ }
1056
+ };
1057
+ const setQuery = (query) => setFilter({ ...filter.value, query });
1058
+ const dispose = async () => {
1059
+ disposed = true;
1060
+ generation += 1;
1061
+ };
1062
+ if (options.autoLoad ?? true) {
1063
+ watch3(() => toValue2(options.client), () => {
1064
+ disposed = false;
1065
+ void loadInitialFor(filter.value);
1066
+ }, { immediate: true });
1067
+ }
1068
+ if (getCurrentScope2()) onScopeDispose2(() => {
1069
+ void dispose();
1070
+ });
1071
+ return {
1072
+ conversations,
1073
+ filter,
1074
+ isInitialLoading,
1075
+ isLoadingMore,
1076
+ hasMore,
1077
+ hasLoaded,
1078
+ error,
1079
+ loadInitial: loadInitialFor,
1080
+ refresh: loadInitialFor,
1081
+ loadMore,
1082
+ setFilter,
1083
+ setQuery,
1084
+ dispose
1085
+ };
1086
+ }
1087
+
1088
+ // src/components/conversation-list.ts
1089
+ var appearanceProps3 = {
1090
+ classNames: { type: Object, default: void 0 },
1091
+ styles: { type: Object, default: void 0 },
1092
+ density: { type: String, default: "comfortable" },
1093
+ unstyled: { type: Boolean, default: false }
1094
+ };
1095
+ var listViewProps = {
1096
+ ...appearanceProps3,
1097
+ conversations: { type: Array, required: true },
1098
+ selectedConversationId: { type: String, default: void 0 },
1099
+ onConversationSelect: { type: Function, default: void 0 },
1100
+ onRefresh: { type: Function, default: void 0 },
1101
+ onLoadMore: { type: Function, default: void 0 },
1102
+ isInitialLoading: { type: Boolean, default: false },
1103
+ isLoadingMore: { type: Boolean, default: false },
1104
+ hasMore: { type: Boolean, default: false },
1105
+ error: { type: null, required: false },
1106
+ scrollElement: { type: Object, default: void 0 },
1107
+ paginationThreshold: { type: Number, default: 240 },
1108
+ ariaLabel: { type: String, default: "Conversations" }
1109
+ };
1110
+ var ConversationListView = defineComponent4({
1111
+ name: "ConversationListView",
1112
+ inheritAttrs: false,
1113
+ props: listViewProps,
1114
+ emits: {
1115
+ "conversation-select": (_conversation) => true,
1116
+ refresh: () => true,
1117
+ "load-more": () => true
1118
+ },
1119
+ setup(props, { attrs, emit, slots }) {
1120
+ const internalElement = ref5(null);
1121
+ let requestInFlight = false;
1122
+ let lastRequestedLength = null;
1123
+ const appearance = () => ({
1124
+ density: props.density,
1125
+ unstyled: props.unstyled,
1126
+ ...props.classNames ? { classNames: props.classNames } : {},
1127
+ ...props.styles ? { styles: props.styles } : {}
1128
+ });
1129
+ const requestMore = async () => {
1130
+ if (requestInFlight || lastRequestedLength === props.conversations.length || props.isInitialLoading || props.isLoadingMore || !props.hasMore || !props.onLoadMore) return;
1131
+ requestInFlight = true;
1132
+ lastRequestedLength = props.conversations.length;
1133
+ try {
1134
+ await props.onLoadMore();
1135
+ } catch {
1136
+ lastRequestedLength = null;
1137
+ } finally {
1138
+ requestInFlight = false;
1139
+ }
1140
+ };
1141
+ const selectConversation = (conversation) => {
1142
+ props.onConversationSelect?.(conversation);
1143
+ };
1144
+ const refresh = () => {
1145
+ return props.onRefresh?.();
1146
+ };
1147
+ const renderContent = () => {
1148
+ const currentAppearance = appearance();
1149
+ if (props.isInitialLoading && props.conversations.length === 0) {
1150
+ return slots["initial-loading"]?.() ?? h4("div", {
1151
+ class: partClass("loading", currentAppearance, "ckui-state"),
1152
+ style: partStyle("loading", currentAppearance),
1153
+ role: "status"
1154
+ }, [h4(LoaderCircle3, { class: "ckui-spin", "aria-hidden": "true" }), " Loading conversations\u2026"]);
1155
+ }
1156
+ if (props.error && props.conversations.length === 0) {
1157
+ const retry = props.onRefresh ? () => {
1158
+ void refresh();
1159
+ } : void 0;
1160
+ return slots.error?.({ error: props.error, ...retry ? { retry } : {} }) ?? h4("div", {
1161
+ class: partClass("error", currentAppearance, "ckui-state ckui-state--error"),
1162
+ style: partStyle("error", currentAppearance),
1163
+ role: "alert"
1164
+ }, [
1165
+ h4("span", errorMessage(props.error)),
1166
+ retry ? h4("button", { type: "button", class: "ckui-link-button", onClick: retry }, "Try again") : null
1167
+ ]);
1168
+ }
1169
+ if (props.conversations.length === 0) {
1170
+ return slots.empty?.() ?? h4("div", {
1171
+ class: partClass("empty", currentAppearance, "ckui-state"),
1172
+ style: partStyle("empty", currentAppearance)
1173
+ }, [h4(Inbox, { "aria-hidden": "true" }), " No conversations yet"]);
1174
+ }
1175
+ const children = props.conversations.flatMap((conversation, index) => {
1176
+ const selected = props.selectedConversationId === conversation.id;
1177
+ const select = () => selectConversation(conversation);
1178
+ const slotProps = { conversation, index, selected, select };
1179
+ const item = slots["conversation-item"]?.(slotProps) ?? h4("button", {
1180
+ type: "button",
1181
+ "data-selected": selected || void 0,
1182
+ "aria-current": selected ? "true" : void 0,
1183
+ onClick: select,
1184
+ class: partClass("listItem", currentAppearance, "ckui-conversation-item"),
1185
+ style: partStyle("listItem", currentAppearance)
1186
+ }, [
1187
+ h4(ConvoKitAvatar, {
1188
+ name: conversation.displayTitle,
1189
+ src: conversation.imageUrl,
1190
+ class: partClass("avatar", currentAppearance, ""),
1191
+ style: partStyle("avatar", currentAppearance)
1192
+ }),
1193
+ h4("span", { class: "ckui-conversation-item__body" }, [
1194
+ h4("strong", conversation.displayTitle),
1195
+ h4("span", conversation.participants.map((participant) => participant.name).join(", ") || conversation.description || "No participants")
1196
+ ]),
1197
+ h4(ChevronRight, { size: 18, "aria-hidden": "true" })
1198
+ ]);
1199
+ const nodes = [h4("div", { key: conversation.id, role: "listitem" }, [item])];
1200
+ if (index < props.conversations.length - 1) {
1201
+ nodes.push(h4("div", { key: `${conversation.id}-separator` }, slots.separator?.({ index }) ?? h4("div", { class: "ckui-separator" })));
1202
+ }
1203
+ return nodes;
1204
+ });
1205
+ if (props.error) {
1206
+ children.push(slots.error?.({ error: props.error, retry: requestMore }) ?? h4("div", {
1207
+ class: partClass("error", currentAppearance, "ckui-inline-state ckui-state--error"),
1208
+ style: partStyle("error", currentAppearance),
1209
+ role: "alert"
1210
+ }, [h4("span", errorMessage(props.error)), h4("button", { type: "button", class: "ckui-link-button", onClick: () => {
1211
+ void requestMore();
1212
+ } }, "Retry")]));
1213
+ } else if (props.isLoadingMore) {
1214
+ children.push(slots["load-more"]?.() ?? h4("div", {
1215
+ class: partClass("loading", currentAppearance, "ckui-inline-state"),
1216
+ style: partStyle("loading", currentAppearance),
1217
+ role: "status"
1218
+ }, [h4(LoaderCircle3, { class: "ckui-spin", "aria-hidden": "true" }), " Loading more\u2026"]));
1219
+ }
1220
+ return h4("div", {
1221
+ role: "list",
1222
+ class: partClass("list", currentAppearance, "ckui-conversation-list__items"),
1223
+ style: partStyle("list", currentAppearance)
1224
+ }, children);
1225
+ };
1226
+ return () => h4("div", {
1227
+ ...attrs,
1228
+ class: cx(!props.unstyled && "ckui ckui-conversation-list", props.classNames?.root, attrs.class),
1229
+ style: [props.styles?.root, attrs.style],
1230
+ "data-density": props.density
1231
+ }, [
1232
+ props.onRefresh ? h4("div", { class: "ckui-conversation-list__toolbar" }, [
1233
+ h4("span", props.ariaLabel),
1234
+ h4("button", {
1235
+ type: "button",
1236
+ "aria-label": "Refresh conversations",
1237
+ class: partClass("button", appearance(), "ckui-icon-button"),
1238
+ style: partStyle("button", appearance()),
1239
+ onClick: () => {
1240
+ void refresh();
1241
+ }
1242
+ }, [h4(RefreshCw2, { size: 17, "aria-hidden": "true" })])
1243
+ ]) : null,
1244
+ h4("div", {
1245
+ ref: (element) => {
1246
+ internalElement.value = element;
1247
+ if (props.scrollElement) props.scrollElement.value = element;
1248
+ },
1249
+ class: cx(!props.unstyled && "ckui-scroll-area", props.classNames?.list),
1250
+ style: props.styles?.list,
1251
+ "aria-label": props.ariaLabel,
1252
+ onScroll: (event) => {
1253
+ const nativeHandler = attrs.onScroll;
1254
+ if (typeof nativeHandler === "function") nativeHandler(event);
1255
+ const element = event.currentTarget;
1256
+ if (element.scrollHeight - element.scrollTop - element.clientHeight <= props.paginationThreshold) void requestMore();
1257
+ }
1258
+ }, [renderContent()])
1259
+ ]);
1260
+ }
1261
+ });
1262
+ var ConversationList = defineComponent4({
1263
+ name: "ConversationList",
1264
+ inheritAttrs: false,
1265
+ props: {
1266
+ ...listViewProps,
1267
+ conversations: { type: Array, default: () => [] },
1268
+ client: { type: Object, required: true },
1269
+ pageLoader: { type: Function, default: void 0 },
1270
+ initialFilter: { type: Object, default: void 0 },
1271
+ pageSize: { type: Number, default: 30 },
1272
+ autoLoad: { type: Boolean, default: true },
1273
+ onControllerChange: { type: Function, default: void 0 }
1274
+ },
1275
+ emits: ["conversation-select", "controller-change"],
1276
+ setup(props, { attrs, emit, expose, slots }) {
1277
+ const controller = useConversationList({
1278
+ client: () => props.client,
1279
+ ...props.pageLoader ? { pageLoader: props.pageLoader } : {},
1280
+ ...props.initialFilter ? { initialFilter: props.initialFilter } : {},
1281
+ pageSize: props.pageSize,
1282
+ autoLoad: props.autoLoad
1283
+ });
1284
+ expose({ controller });
1285
+ watchEffect2(() => {
1286
+ emit("controller-change", controller);
1287
+ });
1288
+ return () => {
1289
+ const {
1290
+ client: _client,
1291
+ pageLoader: _pageLoader,
1292
+ initialFilter: _initialFilter,
1293
+ pageSize: _pageSize,
1294
+ autoLoad: _autoLoad,
1295
+ onControllerChange: _onControllerChange,
1296
+ conversations: _conversations,
1297
+ onRefresh: _onRefresh,
1298
+ onLoadMore: _onLoadMore,
1299
+ isInitialLoading: _isInitialLoading,
1300
+ isLoadingMore: _isLoadingMore,
1301
+ hasMore: _hasMore,
1302
+ error: _error,
1303
+ ...forwarded
1304
+ } = props;
1305
+ return h4(ConversationListView, {
1306
+ ...attrs,
1307
+ ...forwarded,
1308
+ conversations: controller.conversations.value,
1309
+ onRefresh: controller.refresh,
1310
+ onLoadMore: controller.loadMore,
1311
+ isInitialLoading: controller.isInitialLoading.value,
1312
+ isLoadingMore: controller.isLoadingMore.value,
1313
+ hasMore: controller.hasMore.value,
1314
+ ...controller.error.value == null ? {} : { error: controller.error.value },
1315
+ onConversationSelect: (conversation) => {
1316
+ emit("conversation-select", conversation);
1317
+ }
1318
+ }, slots);
1319
+ };
1320
+ }
1321
+ });
1322
+
1323
+ // src/theme.ts
1324
+ import {
1325
+ computed as computed4,
1326
+ defineComponent as defineComponent5,
1327
+ h as h5,
1328
+ inject,
1329
+ provide
1330
+ } from "vue";
1331
+ var defaultConvoKitTheme = {
1332
+ background: "#f7f9f8",
1333
+ surface: "#ffffff",
1334
+ primary: "#148f78",
1335
+ text: "#17211f",
1336
+ mutedText: "#66736f",
1337
+ border: "#dde5e2",
1338
+ error: "#ba1a1a",
1339
+ incomingBubble: "#ffffff",
1340
+ outgoingBubble: "#148f78",
1341
+ outgoingText: "#ffffff",
1342
+ radius: "16px",
1343
+ avatarSize: "44px",
1344
+ fontFamily: 'Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'
1345
+ };
1346
+ var themeKey = /* @__PURE__ */ Symbol("ConvoKitTheme");
1347
+ var defaultThemeRef = computed4(() => defaultConvoKitTheme);
1348
+ var ConvoKitThemeProvider = defineComponent5({
1349
+ name: "ConvoKitThemeProvider",
1350
+ inheritAttrs: false,
1351
+ props: {
1352
+ theme: { type: Object, default: () => ({}) },
1353
+ class: { type: [String, Array, Object], default: void 0 },
1354
+ style: { type: [String, Array, Object], default: void 0 }
1355
+ },
1356
+ setup(props, { attrs, slots }) {
1357
+ const parent = inject(themeKey, defaultThemeRef);
1358
+ const value = computed4(() => ({ ...parent.value, ...props.theme }));
1359
+ provide(themeKey, value);
1360
+ return () => {
1361
+ const theme = value.value;
1362
+ const variables = {
1363
+ "--ckui-background": theme.background,
1364
+ "--ckui-surface": theme.surface,
1365
+ "--ckui-primary": theme.primary,
1366
+ "--ckui-text": theme.text,
1367
+ "--ckui-muted": theme.mutedText,
1368
+ "--ckui-border": theme.border,
1369
+ "--ckui-error": theme.error,
1370
+ "--ckui-incoming": theme.incomingBubble,
1371
+ "--ckui-outgoing": theme.outgoingBubble,
1372
+ "--ckui-outgoing-text": theme.outgoingText,
1373
+ "--ckui-radius": theme.radius,
1374
+ "--ckui-avatar-size": theme.avatarSize,
1375
+ "--ckui-font": theme.fontFamily
1376
+ };
1377
+ return h5("div", {
1378
+ ...attrs,
1379
+ class: cx("ckui-theme", props.class, attrs.class),
1380
+ style: [variables, props.style, attrs.style]
1381
+ }, slots.default?.());
1382
+ };
1383
+ }
1384
+ });
1385
+ function useConvoKitTheme() {
1386
+ return inject(themeKey, defaultThemeRef);
1387
+ }
1388
+ export {
1389
+ Conversation,
1390
+ ConversationList,
1391
+ ConversationListView,
1392
+ ConversationView,
1393
+ ConvoKitAvatar,
1394
+ ConvoKitThemeProvider,
1395
+ MessageListView,
1396
+ applyConversationFilter,
1397
+ createConvoKitUiClient,
1398
+ defaultConvoKitTheme,
1399
+ defaultReadersResolver,
1400
+ formatFileSize,
1401
+ matchesConversation,
1402
+ mergeConversations,
1403
+ mergeMessages,
1404
+ readerIdsFor,
1405
+ useConversation,
1406
+ useConversationList,
1407
+ useConvoKitTheme
1408
+ };
1409
+ //# sourceMappingURL=index.js.map