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