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