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