opendesk-react-native 0.1.3 → 0.1.7

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.
@@ -1,1318 +1,1476 @@
1
- import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
- import {
3
- ActivityIndicator,
4
- Animated,
5
- FlatList,
6
- Image,
7
- KeyboardAvoidingView,
8
- Linking,
9
- Platform,
10
- Pressable,
11
- ScrollView,
12
- StyleSheet,
13
- Text,
14
- TextInput,
15
- useColorScheme,
16
- View,
17
- type StyleProp,
18
- type ViewStyle,
19
- } from 'react-native';
20
- import type { AIReply } from '../client';
21
- import { useConversation, useConversations, useOpenDesk, useOpenDeskState } from '../react';
22
- import type { Attachment, Conversation, Message, UploadFile, WidgetConfig } from '../types';
23
- import { BotFace } from './BotFace';
24
- import { ArrowIcon, ChevronIcon, CloseIcon, PaperclipIcon, PersonIcon, SparkIcon } from './icons';
25
- import { defaultStrings, format, type ChatStrings } from './strings';
26
- import { makeTheme, type ChatTheme } from './theme';
27
-
28
- export interface OpenDeskChatProps {
29
- /** Opens straight into this conversation instead of the home screen. */
30
- initialConversationId?: string;
31
- /** Shown as a close button in the header when given. */
32
- onClose?: () => void;
33
- /**
34
- * Lets the visitor attach a file. Wire it to your picker (expo-image-picker,
35
- * expo-document-picker…) and resolve `{ uri, name, type }`, a web `File`, or null when cancelled.
36
- * Without it the attach button is hidden.
37
- */
38
- pickAttachment?: () => Promise<UploadFile | null>;
39
- /** Overrides the widget's brand colour. */
40
- accentColor?: string;
41
- /** Overrides the widget's theme setting. */
42
- theme?: 'light' | 'dark' | 'auto';
43
- /** Replaces any of the built-in English strings. */
44
- strings?: Partial<ChatStrings>;
45
- /** Height of any navigation header above the chat, so the keyboard never covers the composer on iOS. */
46
- keyboardVerticalOffset?: number;
47
- }
48
-
49
- const MAX_UPLOAD_BYTES = 25 * 1024 * 1024;
50
- const GROUP_GAP_MS = 5 * 60 * 1000;
51
-
52
- /**
53
- * A drop-in support screen that mirrors openDesk's web widget: the home screen, the thread
54
- * with AI streaming, actions and handoff, and the composer with attachments.
55
- */
56
- export function OpenDeskChat({
57
- initialConversationId,
58
- onClose,
59
- pickAttachment,
60
- accentColor,
61
- theme: themeSetting,
62
- strings: stringOverrides,
63
- keyboardVerticalOffset = 0,
64
- }: OpenDeskChatProps) {
65
- const [conversationId, setConversationId] = useState<string | null>(initialConversationId ?? null);
66
- const [composing, setComposing] = useState(false);
67
- const config = useOpenDeskState((state) => state.config);
68
- const systemScheme = useColorScheme();
69
- const setting = themeSetting ?? config?.theme ?? 'auto';
70
- const scheme = setting === 'auto' ? (systemScheme === 'dark' ? 'dark' : 'light') : setting;
71
- const theme = useMemo(() => makeTheme(config, scheme, accentColor), [config, scheme, accentColor]);
72
- const strings = useMemo(() => ({ ...defaultStrings, ...stringOverrides }), [stringOverrides]);
73
- const styles = useMemo(() => createStyles(theme), [theme]);
74
- const ui = useMemo(() => ({ theme, strings, styles, bot: config?.bot ?? null }), [theme, strings, styles, config?.bot]);
75
-
76
- const inThread = conversationId !== null || composing;
77
-
78
- return (
79
- <KeyboardAvoidingView
80
- style={styles.root}
81
- behavior={Platform.OS === 'ios' ? 'padding' : undefined}
82
- keyboardVerticalOffset={keyboardVerticalOffset}
83
- >
84
- {inThread ? (
85
- <Thread
86
- ui={ui}
87
- conversationId={conversationId}
88
- pickAttachment={pickAttachment}
89
- onBack={() => {
90
- setConversationId(null);
91
- setComposing(false);
92
- }}
93
- onClose={onClose}
94
- onStarted={(id) => {
95
- setComposing(false);
96
- setConversationId(id);
97
- }}
98
- />
99
- ) : (
100
- <Home ui={ui} onClose={onClose} onOpen={setConversationId} onNew={() => setComposing(true)} />
101
- )}
102
- </KeyboardAvoidingView>
103
- );
104
- }
105
-
106
- interface Ui {
107
- theme: ChatTheme;
108
- strings: ChatStrings;
109
- styles: Styles;
110
- /** The widget's bot, for messages whose author carries no look of its own. */
111
- bot: WidgetConfig['bot'];
112
- }
113
-
114
- function subtitleFor(config: WidgetConfig | null, strings: ChatStrings): string {
115
- if (config?.subtitle) {
116
- return config.subtitle;
117
- }
118
-
119
- return config?.ai_answers && config.bot ? format(strings.replyTimeAi, { name: config.bot.name }) : strings.replyTime;
120
- }
121
-
122
- function Home({
123
- ui,
124
- onClose,
125
- onOpen,
126
- onNew,
127
- }: {
128
- ui: Ui;
129
- onClose?: () => void;
130
- onOpen: (id: string) => void;
131
- onNew: () => void;
132
- }) {
133
- const { theme, strings, styles } = ui;
134
- const { conversations, ready, error } = useConversations();
135
- const unread = useOpenDeskState((state) => state.unread);
136
- const config = useOpenDeskState((state) => state.config);
137
- const name = config?.title || config?.workspace_name || strings.support;
138
-
139
- return (
140
- <View style={styles.flex}>
141
- <View style={[styles.head, styles.headHome]}>
142
- <View style={styles.headRow}>
143
- <View style={styles.ws}>
144
- <Glyph ui={ui} name={name} url={config?.avatar_url ?? null} />
145
- <Text style={styles.wsName} numberOfLines={1}>
146
- {name}
147
- </Text>
148
- </View>
149
- {onClose ? <IconButton ui={ui} label={strings.closeChat} onPress={onClose} icon={<CloseIcon color={theme.fg3} />} /> : null}
150
- </View>
151
- <Text style={styles.greet}>{config?.greeting || strings.greeting}</Text>
152
- <View style={styles.subRow}>
153
- <AvatarStack ui={ui} config={config} />
154
- <Text style={styles.sub}>{subtitleFor(config, strings)}</Text>
155
- </View>
156
- </View>
157
-
158
- <ScrollView style={styles.flex} contentContainerStyle={styles.homeBody}>
159
- {!ready ? (
160
- error ? (
161
- <View style={styles.empty}>
162
- <Text style={styles.emptyTitle}>{strings.unavailable}</Text>
163
- <Text style={styles.emptyText}>{strings.unavailableHint}</Text>
164
- </View>
165
- ) : (
166
- <View style={[styles.card, styles.skeletonCard]}>
167
- <View style={[styles.skel, { width: '60%' }]} />
168
- <View style={[styles.skel, { width: '85%' }]} />
169
- </View>
170
- )
171
- ) : null}
172
-
173
- {ready && conversations.length > 0 ? (
174
- <View style={styles.card}>
175
- <Text style={styles.cardHead}>{strings.yourConversations}</Text>
176
- {conversations.map((conversation, index) => (
177
- <ConversationRow
178
- key={conversation.id}
179
- ui={ui}
180
- conversation={conversation}
181
- unread={unread[conversation.id] ?? 0}
182
- first={index === 0}
183
- onOpen={onOpen}
184
- />
185
- ))}
186
- </View>
187
- ) : null}
188
-
189
- {config?.open === false ? (
190
- <View style={styles.away}>
191
- <Text style={styles.awayText}>{config.offline_message || strings.offlineDefault}</Text>
192
- </View>
193
- ) : null}
194
-
195
- <Pressable accessibilityRole="button" onPress={onNew} style={({ pressed }) => [styles.primary, pressed && styles.pressed]}>
196
- <Text style={styles.primaryText}>{strings.sendMessage}</Text>
197
- <ArrowIcon color={theme.accentFg} size={15} />
198
- </Pressable>
199
-
200
- {config?.help_center_url ? (
201
- <View style={styles.card}>
202
- <Pressable
203
- accessibilityRole="link"
204
- onPress={() => Linking.openURL(config.help_center_url)}
205
- style={({ pressed }) => [styles.linkRow, pressed && { backgroundColor: theme.bg3 }]}
206
- >
207
- <Text style={styles.linkText}>{strings.searchHelp}</Text>
208
- <ArrowIcon color={theme.fg3} size={14} angle={-45} />
209
- </Pressable>
210
- </View>
211
- ) : null}
212
- </ScrollView>
213
-
214
- <Pressable accessibilityRole="link" onPress={() => Linking.openURL('https://opendesk.sh')} style={styles.foot}>
215
- <Text style={styles.footText}>{strings.poweredBy}</Text>
216
- </Pressable>
217
- </View>
218
- );
219
- }
220
-
221
- const ConversationRow = memo(function ConversationRow({
222
- ui,
223
- conversation,
224
- unread,
225
- first,
226
- onOpen,
227
- }: {
228
- ui: Ui;
229
- conversation: Conversation;
230
- unread: number;
231
- first: boolean;
232
- onOpen: (id: string) => void;
233
- }) {
234
- const { theme, strings, styles } = ui;
235
- const open = conversation.status === 'open' || conversation.status === 'pending';
236
-
237
- return (
238
- <Pressable
239
- accessibilityRole="button"
240
- onPress={() => onOpen(conversation.id)}
241
- style={({ pressed }) => [styles.conv, !first && styles.convBorder, pressed && { backgroundColor: theme.bg3 }]}
242
- >
243
- <View style={[styles.dot, open && { backgroundColor: theme.accent }]} />
244
- <View style={styles.convMain}>
245
- <View style={styles.convTop}>
246
- <Text style={[styles.convSubject, unread > 0 && styles.bold]} numberOfLines={1}>
247
- {conversation.subject || strings.conversation}
248
- </Text>
249
- <Text style={styles.time}>{relativeTime(conversation.last_message_at)}</Text>
250
- </View>
251
- <View style={styles.convTop}>
252
- <Text style={styles.convPrev} numberOfLines={1}>
253
- {conversation.last_message_preview || ' '}
254
- </Text>
255
- {unread > 0 ? (
256
- <View style={styles.unread}>
257
- <Text style={styles.unreadText}>{unread}</Text>
258
- </View>
259
- ) : null}
260
- </View>
261
- </View>
262
- </Pressable>
263
- );
264
- });
265
-
266
- type Row =
267
- | { kind: 'message'; key: string; message: Message; first: boolean; last: boolean; gap: boolean }
268
- | { kind: 'reply'; key: string; reply: AIReply }
269
- | { kind: 'day'; key: string; label: string }
270
- | { kind: 'typing'; key: string; name: string }
271
- | { kind: 'handed'; key: string };
272
-
273
- function Thread({
274
- ui,
275
- conversationId,
276
- pickAttachment,
277
- onBack,
278
- onClose,
279
- onStarted,
280
- }: {
281
- ui: Ui;
282
- conversationId: string | null;
283
- pickAttachment?: () => Promise<UploadFile | null>;
284
- onBack: () => void;
285
- onClose?: () => void;
286
- onStarted: (id: string) => void;
287
- }) {
288
- const { theme, strings, styles } = ui;
289
- const client = useOpenDesk();
290
- const config = useOpenDeskState((state) => state.config);
291
- const { messages, aiReplies, typing, conversation } = useConversation(conversationId);
292
- const [draft, setDraft] = useState('');
293
- const [attachments, setAttachments] = useState<Attachment[]>([]);
294
- const [uploadingName, setUploadingName] = useState<string | null>(null);
295
- const [topicId, setTopicId] = useState<string | null>(null);
296
- const [sending, setSending] = useState(false);
297
- const [focused, setFocused] = useState(false);
298
- const [inputHeight, setInputHeight] = useState(30);
299
- const [error, setError] = useState<string | null>(null);
300
-
301
- const topics = config?.topics ?? [];
302
- const topic = topics.find((t) => t.id === topicId) ?? null;
303
- const needsTopic = conversationId === null && topics.length > 0 && topic === null;
304
- const name = config?.title || config?.workspace_name || strings.support;
305
- const lastMessage = messages[messages.length - 1];
306
- const lastAiIndex = lastIndexWhere(messages, (m) => m.ai === true);
307
- const handedOff = conversation?.ai_handoff === true && !messages.slice(lastAiIndex + 1).some((m) => m.author.type === 'user');
308
- const handoffMessageId =
309
- conversation && !conversation.ai_handoff && aiReplies.length === 0 && lastMessage?.ai && lastMessage.direction === 'outbound'
310
- ? lastMessage.id
311
- : null;
312
-
313
- const rows = useMemo(() => buildRows(messages, aiReplies, typing, handedOff, strings), [messages, aiReplies, typing, handedOff, strings]);
314
-
315
- const decide = useCallback(
316
- (message: Message, decision: 'confirm' | 'decline') => {
317
- if (message.ai_action) {
318
- client.decideAction(message.ticket_id, message.ai_action.id, decision).catch(() => undefined);
319
- }
320
- },
321
- [client],
322
- );
323
-
324
- const requestHuman = useCallback(
325
- (message: Message) => {
326
- client.requestHuman(message.ticket_id).catch(() => setError(strings.handoffFailed));
327
- },
328
- [client, strings.handoffFailed],
329
- );
330
-
331
- const ready = (draft.trim().length > 0 || attachments.length > 0) && !sending && uploadingName === null && !needsTopic;
332
-
333
- const send = useCallback(async () => {
334
- if (!ready) {
335
- return;
336
- }
337
- const body = draft.trim();
338
- const attachmentIds = attachments.map((attachment) => attachment.id);
339
- setError(null);
340
- setDraft('');
341
- setAttachments([]);
342
- try {
343
- if (conversationId) {
344
- await client.sendMessage(conversationId, { ...(body ? { body } : {}), attachment_ids: attachmentIds });
345
- } else {
346
- setSending(true);
347
- const started = await client.startConversation({
348
- ...(body ? { body } : {}),
349
- attachment_ids: attachmentIds,
350
- ...(topicId ? { topic_id: topicId } : {}),
351
- });
352
- onStarted(started.id);
353
- }
354
- } catch (caught) {
355
- setDraft(body);
356
- setAttachments(attachments);
357
- setError(caught instanceof Error ? `${strings.notSent} ${caught.message}` : strings.notSent);
358
- } finally {
359
- setSending(false);
360
- }
361
- }, [attachments, client, conversationId, draft, onStarted, ready, strings.notSent, topicId]);
362
-
363
- const attach = useCallback(async () => {
364
- if (!pickAttachment) {
365
- return;
366
- }
367
- setError(null);
368
- let file: UploadFile | null;
369
- try {
370
- file = await pickAttachment();
371
- } catch {
372
- return;
373
- }
374
- if (!file) {
375
- return;
376
- }
377
- const fileName = 'name' in file && typeof file.name === 'string' ? file.name : 'file';
378
- const size = 'size' in file && typeof file.size === 'number' ? file.size : undefined;
379
- const allowed = config?.attachment_extensions;
380
- const dot = fileName.lastIndexOf('.');
381
- if (allowed && allowed.length > 0 && (dot < 1 || !allowed.includes(fileName.slice(dot).toLowerCase()))) {
382
- setError(format(strings.fileTypeRefused, { name: fileName }));
383
-
384
- return;
385
- }
386
- if (size !== undefined && size > MAX_UPLOAD_BYTES) {
387
- setError(format(strings.fileTooLarge, { name: fileName }));
388
-
389
- return;
390
- }
391
- setUploadingName(fileName);
392
- try {
393
- const uploaded = await client.upload(file);
394
- setAttachments((current) => [...current, uploaded]);
395
- } catch (caught) {
396
- setError(caught instanceof Error && caught.message ? caught.message : format(strings.uploadFailed, { name: fileName }));
397
- } finally {
398
- setUploadingName(null);
399
- }
400
- }, [client, config?.attachment_extensions, pickAttachment, strings]);
401
-
402
- const showAttach = Boolean(pickAttachment) && config?.attachments !== false && (config?.attachment_extensions?.length ?? 1) > 0;
403
- const bot = config?.ai_answers ? config.bot : null;
404
-
405
- return (
406
- <View style={styles.flex}>
407
- <View style={styles.head}>
408
- <IconButton ui={ui} label={strings.back} onPress={onBack} icon={<ChevronIcon color={theme.fg3} direction="left" />} />
409
- {bot ? (
410
- <BotFace size={26} look={bot.look} avatarUrl={bot.avatar_url} />
411
- ) : (
412
- <Glyph ui={ui} name={name} url={config?.avatar_url ?? null} />
413
- )}
414
- <View style={styles.headMain}>
415
- <Text style={styles.title} numberOfLines={1}>
416
- {conversation?.subject || name}
417
- </Text>
418
- <Text style={styles.titleSub} numberOfLines={1}>
419
- {subtitleFor(config, strings)}
420
- </Text>
421
- </View>
422
- {onClose ? <IconButton ui={ui} label={strings.closeChat} onPress={onClose} icon={<CloseIcon color={theme.fg3} />} /> : null}
423
- </View>
424
-
425
- {rows.length === 0 ? (
426
- <ScrollView style={styles.flex} contentContainerStyle={styles.emptyScroll} keyboardShouldPersistTaps="handled">
427
- {needsTopic ? (
428
- <View style={[styles.card, styles.picker]}>
429
- <Text style={styles.pickerTitle}>{strings.topicsTitle}</Text>
430
- <Text style={styles.pickerHint}>{strings.topicsHint}</Text>
431
- {topics.map((item, index) => (
432
- <Pressable
433
- key={item.id}
434
- accessibilityRole="button"
435
- onPress={() => setTopicId(item.id)}
436
- style={({ pressed }) => [styles.topic, index > 0 && styles.convBorder, pressed && { backgroundColor: theme.bg2 }]}
437
- >
438
- <Text style={styles.topicText}>{item.label}</Text>
439
- <ChevronIcon color={theme.fg4} size={14} direction="right" />
440
- </Pressable>
441
- ))}
442
- </View>
443
- ) : (
444
- <View style={styles.empty}>
445
- <Text style={styles.emptyTitle}>{config?.greeting || strings.greeting}</Text>
446
- <Text style={styles.emptyText}>{config?.intro || strings.emptyHint}</Text>
447
- </View>
448
- )}
449
- </ScrollView>
450
- ) : (
451
- <FlatList
452
- inverted
453
- style={styles.flex}
454
- data={rows}
455
- keyExtractor={(row) => row.key}
456
- contentContainerStyle={styles.thread}
457
- keyboardShouldPersistTaps="handled"
458
- renderItem={({ item }) => (
459
- <ThreadRow
460
- ui={ui}
461
- row={item}
462
- showHandoff={item.kind === 'message' && item.message.id === handoffMessageId}
463
- onDecide={decide}
464
- onRequestHuman={requestHuman}
465
- />
466
- )}
467
- />
468
- )}
469
-
470
- <View style={styles.composer}>
471
- {topic && conversationId === null ? (
472
- <View style={styles.topicRow}>
473
- <View style={styles.topicChip}>
474
- <Text style={styles.topicChipText} numberOfLines={1}>
475
- {topic.label}
476
- </Text>
477
- <Pressable
478
- accessibilityRole="button"
479
- accessibilityLabel={strings.changeTopic}
480
- onPress={() => setTopicId(null)}
481
- style={styles.topicChipX}
482
- >
483
- <CloseIcon color={theme.fg3} size={10} />
484
- </Pressable>
485
- </View>
486
- </View>
487
- ) : null}
488
-
489
- {attachments.length > 0 || uploadingName ? (
490
- <View style={styles.pendingFiles}>
491
- {attachments.map((attachment) => (
492
- <View key={attachment.id} style={[styles.chip, styles.pendingChip]}>
493
- <Text style={styles.chipText} numberOfLines={1}>
494
- {attachment.filename}
495
- </Text>
496
- <Pressable
497
- accessibilityRole="button"
498
- accessibilityLabel={format(strings.removeFile, { name: attachment.filename })}
499
- onPress={() => setAttachments((current) => current.filter((a) => a.id !== attachment.id))}
500
- hitSlop={8}
501
- >
502
- <CloseIcon color={theme.fg3} size={10} />
503
- </Pressable>
504
- </View>
505
- ))}
506
- {uploadingName ? (
507
- <View style={[styles.chip, styles.pendingChip]}>
508
- <ActivityIndicator size="small" color={theme.fg3} style={styles.chipSpinner} />
509
- <Text style={styles.chipText} numberOfLines={1}>
510
- {uploadingName}
511
- </Text>
512
- </View>
513
- ) : null}
514
- </View>
515
- ) : null}
516
-
517
- {error ? <Text style={styles.err}>{error}</Text> : null}
518
-
519
- <View style={[styles.cbar, showAttach && styles.cbarLead, focused && styles.cbarFocus, needsTopic && styles.cbarOff]}>
520
- {showAttach ? (
521
- <IconButton
522
- ui={ui}
523
- label={strings.attach}
524
- onPress={attach}
525
- disabled={uploadingName !== null || needsTopic}
526
- icon={<PaperclipIcon color={theme.fg3} />}
527
- />
528
- ) : null}
529
- <TextInput
530
- style={[styles.input, { height: inputHeight }]}
531
- value={draft}
532
- onContentSizeChange={(event) => setInputHeight(Math.min(120, Math.max(30, Math.ceil(event.nativeEvent.contentSize.height))))}
533
- {...(Platform.OS === 'web' ? ({ rows: 1 } as object) : {})}
534
- editable={!needsTopic}
535
- onFocus={() => setFocused(true)}
536
- onBlur={() => setFocused(false)}
537
- onChangeText={(text) => {
538
- setDraft(text);
539
- if (conversationId) {
540
- client.typing(conversationId);
541
- }
542
- }}
543
- placeholder={needsTopic ? strings.placeholderTopic : strings.placeholder}
544
- placeholderTextColor={theme.fg4}
545
- accessibilityLabel={strings.placeholder}
546
- multiline
547
- />
548
- <Pressable
549
- accessibilityRole="button"
550
- accessibilityLabel={strings.send}
551
- disabled={!ready}
552
- onPress={send}
553
- style={[styles.send, ready && styles.sendReady]}
554
- >
555
- <ArrowIcon color={theme.accentFg} size={14} angle={-90} />
556
- </Pressable>
557
- </View>
558
- </View>
559
- </View>
560
- );
561
- }
562
-
563
- const ThreadRow = memo(function ThreadRow({
564
- ui,
565
- row,
566
- showHandoff,
567
- onDecide,
568
- onRequestHuman,
569
- }: {
570
- ui: Ui;
571
- row: Row;
572
- showHandoff: boolean;
573
- onDecide: (message: Message, decision: 'confirm' | 'decline') => void;
574
- onRequestHuman: (message: Message) => void;
575
- }) {
576
- const { theme, strings, styles } = ui;
577
-
578
- switch (row.kind) {
579
- case 'day':
580
- return <Text style={styles.day}>{row.label}</Text>;
581
- case 'typing':
582
- return (
583
- <View style={styles.typing}>
584
- <View style={styles.typingBubble}>
585
- <Dots color={theme.fg3} />
586
- </View>
587
- <Text style={styles.typingText}>{format(strings.typing, { name: row.name })}</Text>
588
- </View>
589
- );
590
- case 'handed':
591
- return (
592
- <View style={styles.handed}>
593
- <View style={styles.handedIc}>
594
- <PersonIcon color={theme.fg2} />
595
- </View>
596
- <View style={styles.flex}>
597
- <Text style={styles.handedTitle}>{strings.handedOff}</Text>
598
- <Text style={styles.handedText}>{strings.handedOffHint}</Text>
599
- </View>
600
- </View>
601
- );
602
- case 'reply':
603
- return <Bubble ui={ui} reply={row.reply} first last gap showHandoff={false} onDecide={onDecide} onRequestHuman={onRequestHuman} />;
604
- case 'message':
605
- return (
606
- <Bubble
607
- ui={ui}
608
- message={row.message}
609
- first={row.first}
610
- last={row.last}
611
- gap={row.gap}
612
- showHandoff={showHandoff}
613
- onDecide={onDecide}
614
- onRequestHuman={onRequestHuman}
615
- />
616
- );
617
- }
618
- });
619
-
620
- /**
621
- * One bubble for a stored message or an AI answer still streaming. Both share one element
622
- * tree, so when the stored message replaces the stream (same row key) nothing remounts.
623
- */
624
- function Bubble({
625
- ui,
626
- message,
627
- reply,
628
- first,
629
- last,
630
- gap,
631
- showHandoff,
632
- onDecide,
633
- onRequestHuman,
634
- }: {
635
- ui: Ui;
636
- message?: Message;
637
- reply?: AIReply;
638
- first: boolean;
639
- last: boolean;
640
- gap: boolean;
641
- showHandoff: boolean;
642
- onDecide: (message: Message, decision: 'confirm' | 'decline') => void;
643
- onRequestHuman: (message: Message) => void;
644
- }) {
645
- const { theme, strings, styles, bot: fallbackBot } = ui;
646
- const mine = message?.direction === 'inbound';
647
- const ai = reply !== undefined || message?.ai === true;
648
- const author = message ? message.author.user?.name || message.author.bot?.name : reply?.bot?.name;
649
- const bot = message ? message.author.bot : reply?.bot;
650
- const avatar = message ? (message.author.user?.avatar_url ?? bot?.avatar_url) : bot?.avatar_url;
651
- const botLook = bot?.look ?? fallbackBot?.look;
652
- const text = message ? (message.body_text ?? '') : (reply?.text ?? '');
653
- const local = message?.id.startsWith('local-') === true;
654
- const action = message?.ai_action;
655
- const tools = reply?.tools ?? [];
656
- const corners: ViewStyle = mine
657
- ? { borderBottomRightRadius: 4, ...(first ? {} : { borderTopRightRadius: 4 }) }
658
- : { borderBottomLeftRadius: 4, ...(first ? {} : { borderTopLeftRadius: 4 }) };
659
- const images = (message?.attachments ?? []).filter((a) => a.content_type.startsWith('image/'));
660
- const files = (message?.attachments ?? []).filter((a) => !a.content_type.startsWith('image/'));
661
-
662
- return (
663
- <View style={[styles.row, mine ? styles.rowMe : styles.rowThem, gap && styles.rowGap]}>
664
- {!mine && first && author ? (
665
- <View style={styles.who}>
666
- {ai ? (
667
- <BotFace size={18} look={botLook} avatarUrl={avatar} />
668
- ) : avatar ? (
669
- <Avatar ui={ui} size={18} name={author} url={avatar} tint={theme.bg2} />
670
- ) : null}
671
- <Text style={styles.whoName}>{author}</Text>
672
- {ai ? (
673
- <View style={styles.aiPill}>
674
- <Text style={styles.aiPillText}>{strings.ai}</Text>
675
- </View>
676
- ) : null}
677
- </View>
678
- ) : null}
679
-
680
- {tools.length > 0 ? (
681
- <View style={styles.tools}>
682
- {tools.map((tool, index) => (
683
- <View key={`${tool.name}-${index}`} style={styles.tool}>
684
- <View style={[styles.toolIc, tool.status === 'error' && { backgroundColor: theme.bg2 }]}>
685
- <SparkIcon color={tool.status === 'error' ? theme.fg3 : theme.ai} size={8} />
686
- </View>
687
- <Text style={[styles.toolText, tool.status === 'running' && { color: theme.fg2 }]}>
688
- {format(tool.status === 'running' ? strings.checking : tool.status === 'error' ? strings.toolFailed : strings.checked, {
689
- tool: tool.name.replace(/_/g, ' '),
690
- })}
691
- </Text>
692
- </View>
693
- ))}
694
- </View>
695
- ) : null}
696
-
697
- {text || reply ? (
698
- <View
699
- style={[
700
- styles.msg,
701
- mine ? styles.msgMe : ai ? styles.msgAi : styles.msgThem,
702
- corners,
703
- local && styles.sendingMsg,
704
- ]}
705
- >
706
- {text ? <Text style={[styles.msgText, mine && { color: theme.accentFg }]}>{text}</Text> : <Dots color={theme.ai} />}
707
- </View>
708
- ) : null}
709
-
710
- {images.map((attachment) => (
711
- <Pressable key={attachment.id} onPress={() => Linking.openURL(attachment.url)} style={styles.fileGap}>
712
- <Image source={{ uri: attachment.url }} style={styles.thumb} accessibilityLabel={attachment.filename} resizeMode="cover" />
713
- </Pressable>
714
- ))}
715
- {files.map((attachment) => (
716
- <Pressable key={attachment.id} onPress={() => Linking.openURL(attachment.url)} style={[styles.chip, styles.fileGap]}>
717
- <Text style={styles.chipText} numberOfLines={1}>
718
- {attachment.filename}
719
- </Text>
720
- <Text style={styles.chipSize}>{fileSize(attachment.size_bytes)}</Text>
721
- </Pressable>
722
- ))}
723
-
724
- {message && action ? <ActionCard ui={ui} message={message} onDecide={onDecide} /> : null}
725
-
726
- {message && showHandoff ? (
727
- <Pressable
728
- accessibilityRole="button"
729
- onPress={() => onRequestHuman(message)}
730
- style={({ pressed }) => [styles.handoff, pressed && { backgroundColor: theme.bg2 }]}
731
- >
732
- <PersonIcon color={theme.fg} size={14} />
733
- <Text style={styles.handoffText}>{strings.talkToPerson}</Text>
734
- </Pressable>
735
- ) : null}
736
-
737
- {last && message ? <Text style={styles.meta}>{local ? strings.sending : clockTime(message.created_at)}</Text> : null}
738
- </View>
739
- );
740
- }
741
-
742
- function ActionCard({
743
- ui,
744
- message,
745
- onDecide,
746
- }: {
747
- ui: Ui;
748
- message: Message;
749
- onDecide: (message: Message, decision: 'confirm' | 'decline') => void;
750
- }) {
751
- const { theme, strings, styles } = ui;
752
- const action = message.ai_action!;
753
- const pending = action.status === 'pending';
754
- const label = {
755
- pending: strings.actionPending,
756
- confirmed: strings.actionConfirmed,
757
- declined: strings.actionDeclined,
758
- expired: strings.actionExpired,
759
- failed: strings.actionFailed,
760
- }[action.status];
761
-
762
- return (
763
- <View style={[styles.action, pending && styles.actionPending]}>
764
- <View style={styles.actionTop}>
765
- <View style={styles.actionTool}>
766
- <SparkIcon color={theme.ai} size={8} />
767
- <Text style={styles.actionToolText} numberOfLines={1}>
768
- {action.tool.replace(/_/g, ' ')}
769
- </Text>
770
- </View>
771
- <Text style={[styles.actionNeed, action.status === 'failed' && { color: theme.danger }]}>{label}</Text>
772
- </View>
773
- <Text style={styles.actionTitle}>{action.title}</Text>
774
- {action.summary ? <Text style={styles.actionSum}>{action.summary}</Text> : null}
775
- {pending ? (
776
- <View style={styles.actionBtns}>
777
- <Pressable onPress={() => onDecide(message, 'decline')} style={[styles.btn, styles.btnGhost]}>
778
- <Text style={styles.btnGhostText}>{strings.cancel}</Text>
779
- </Pressable>
780
- <Pressable onPress={() => onDecide(message, 'confirm')} style={[styles.btn, styles.btnSolid]}>
781
- <Text style={styles.btnSolidText}>{strings.confirm}</Text>
782
- </Pressable>
783
- </View>
784
- ) : null}
785
- </View>
786
- );
787
- }
788
-
789
- function IconButton({
790
- ui,
791
- label,
792
- onPress,
793
- icon,
794
- disabled,
795
- }: {
796
- ui: Ui;
797
- label: string;
798
- onPress: () => void;
799
- icon: React.ReactNode;
800
- disabled?: boolean;
801
- }) {
802
- return (
803
- <Pressable
804
- accessibilityRole="button"
805
- accessibilityLabel={label}
806
- onPress={onPress}
807
- disabled={disabled}
808
- hitSlop={6}
809
- style={({ pressed }) => [ui.styles.iconBtn, pressed && { backgroundColor: ui.theme.bg2 }, disabled && ui.styles.disabled]}
810
- >
811
- {icon}
812
- </Pressable>
813
- );
814
- }
815
-
816
- function Glyph({ ui, name, url }: { ui: Ui; name: string; url: string | null }) {
817
- const { styles } = ui;
818
-
819
- return url ? (
820
- <Image source={{ uri: url }} style={styles.wsGlyph} />
821
- ) : (
822
- <View style={styles.wsGlyph}>
823
- <Text style={styles.wsGlyphText}>{name.trim().charAt(0).toUpperCase()}</Text>
824
- </View>
825
- );
826
- }
827
-
828
- function Avatar({
829
- ui,
830
- size,
831
- name,
832
- url,
833
- round,
834
- tint,
835
- style,
836
- }: {
837
- ui: Ui;
838
- size: number;
839
- name: string;
840
- url: string | null | undefined;
841
- round?: boolean;
842
- tint: string;
843
- style?: StyleProp<ViewStyle>;
844
- }) {
845
- const radius = round ? size / 2 : Math.min(ui.theme.radius.av, size / 2);
846
-
847
- return (
848
- <View
849
- style={[
850
- { width: size, height: size, borderRadius: radius, backgroundColor: tint, overflow: 'hidden', alignItems: 'center', justifyContent: 'center' },
851
- style,
852
- ]}
853
- >
854
- {url ? (
855
- <Image source={{ uri: url }} style={{ width: size, height: size }} />
856
- ) : (
857
- <Text style={{ fontSize: size * 0.4, fontWeight: '600', color: ui.theme.fg2 }}>{initials(name)}</Text>
858
- )}
859
- </View>
860
- );
861
- }
862
-
863
- function AvatarStack({ ui, config }: { ui: Ui; config: WidgetConfig | null }) {
864
- const { theme } = ui;
865
- const ring = { borderWidth: 2, borderColor: theme.headBg };
866
- const team = (config?.team ?? []).slice(0, 3);
867
- const bot = config?.ai_answers ? config.bot : null;
868
- if (!bot && team.length === 0) {
869
- return null;
870
- }
871
-
872
- return (
873
- <View style={{ flexDirection: 'row', alignItems: 'center' }}>
874
- {bot ? <BotFace size={22} look={bot.look} avatarUrl={bot.avatar_url} /> : null}
875
- {team.map((member, index) => (
876
- <Avatar
877
- key={`${member.name}-${index}`}
878
- ui={ui}
879
- size={22}
880
- name={member.name}
881
- url={member.avatar_url}
882
- tint={theme.bg2}
883
- style={[ring, { marginLeft: index === 0 && bot ? 1 : -7 }]}
884
- />
885
- ))}
886
- </View>
887
- );
888
- }
889
-
890
- /** Three dots that pulse in turn, like the widget's typing indicator. */
891
- function Dots({ color }: { color: string }) {
892
- const values = useRef([0, 1, 2].map(() => new Animated.Value(0.3))).current;
893
-
894
- useEffect(() => {
895
- const native = Platform.OS !== 'web';
896
- const loop = Animated.loop(
897
- Animated.stagger(
898
- 160,
899
- values.map((value) =>
900
- Animated.sequence([
901
- Animated.timing(value, { toValue: 1, duration: 280, useNativeDriver: native }),
902
- Animated.timing(value, { toValue: 0.3, duration: 280, useNativeDriver: native }),
903
- ]),
904
- ),
905
- ),
906
- );
907
- loop.start();
908
-
909
- return () => loop.stop();
910
- }, [values]);
911
-
912
- return (
913
- <View style={{ flexDirection: 'row', alignItems: 'center', height: 19.5, gap: 3 }}>
914
- {values.map((opacity, index) => (
915
- <Animated.View key={index} style={{ width: 5, height: 5, borderRadius: 3, backgroundColor: color, opacity }} />
916
- ))}
917
- </View>
918
- );
919
- }
920
-
921
- /** Newest first for the inverted list: typing, streaming answers, then messages grouped by author with day labels. */
922
- function buildRows(messages: Message[], aiReplies: AIReply[], typing: string | null, handedOff: boolean, strings: ChatStrings): Row[] {
923
- const rows: Row[] = [];
924
- const authorKey = (m: Message) =>
925
- m.direction === 'inbound' ? 'me' : `${m.author.type}:${m.author.user?.id ?? m.author.bot?.id ?? ''}:${m.ai ? 'ai' : ''}`;
926
-
927
- messages.forEach((message, index) => {
928
- const previous = messages[index - 1];
929
- const next = messages[index + 1];
930
- const day = dayLabel(message.created_at, strings);
931
- const newDay = !previous || dayLabel(previous.created_at, strings) !== day;
932
- if (newDay) {
933
- rows.push({ kind: 'day', key: `day-${day}`, label: day });
934
- }
935
- const startsGroup =
936
- newDay || authorKey(previous!) !== authorKey(message) || Date.parse(message.created_at) - Date.parse(previous!.created_at) > GROUP_GAP_MS;
937
- const endsGroup =
938
- !next ||
939
- authorKey(next) !== authorKey(message) ||
940
- Date.parse(next.created_at) - Date.parse(message.created_at) > GROUP_GAP_MS ||
941
- dayLabel(next.created_at, strings) !== day;
942
- rows.push({
943
- kind: 'message',
944
- key: message.client_id ?? message.id,
945
- message,
946
- first: startsGroup,
947
- last: endsGroup,
948
- gap: startsGroup && !newDay,
949
- });
950
- });
951
-
952
- if (handedOff) {
953
- rows.push({ kind: 'handed', key: 'handed' });
954
- }
955
- aiReplies.forEach((reply) => rows.push({ kind: 'reply', key: `ai-${reply.id}`, reply }));
956
- if (typing) {
957
- rows.push({ kind: 'typing', key: 'typing', name: typing });
958
- }
959
-
960
- return rows.reverse();
961
- }
962
-
963
- function lastIndexWhere<T>(items: T[], predicate: (item: T) => boolean): number {
964
- for (let i = items.length - 1; i >= 0; i--) {
965
- if (predicate(items[i]!)) {
966
- return i;
967
- }
968
- }
969
-
970
- return -1;
971
- }
972
-
973
- function initials(name: string): string {
974
- return name
975
- .split(/\s+/)
976
- .filter(Boolean)
977
- .slice(0, 2)
978
- .map((part) => part.charAt(0).toUpperCase())
979
- .join('');
980
- }
981
-
982
- const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
983
-
984
- function dayLabel(iso: string, strings: ChatStrings): string {
985
- const date = new Date(iso);
986
- const today = new Date();
987
- const yesterday = new Date(today.getTime() - 86400000);
988
- if (date.toDateString() === today.toDateString()) {
989
- return strings.today;
990
- }
991
- if (date.toDateString() === yesterday.toDateString()) {
992
- return strings.yesterday;
993
- }
994
-
995
- return `${months[date.getMonth()]} ${date.getDate()}`;
996
- }
997
-
998
- function clockTime(iso: string): string {
999
- const date = new Date(iso);
1000
- const hours = date.getHours();
1001
-
1002
- return `${hours % 12 || 12}:${String(date.getMinutes()).padStart(2, '0')} ${hours < 12 ? 'AM' : 'PM'}`;
1003
- }
1004
-
1005
- function relativeTime(iso: string): string {
1006
- const seconds = Math.round((Date.now() - Date.parse(iso)) / 1000);
1007
- if (!Number.isFinite(seconds)) {
1008
- return '';
1009
- }
1010
- if (seconds < 45) {
1011
- return 'now';
1012
- }
1013
- const minutes = Math.round(seconds / 60);
1014
- if (minutes < 60) {
1015
- return `${minutes}m ago`;
1016
- }
1017
- const hours = Math.round(minutes / 60);
1018
- if (hours < 24) {
1019
- return `${hours}h ago`;
1020
- }
1021
- const days = Math.round(hours / 24);
1022
- if (days < 7) {
1023
- return `${days}d ago`;
1024
- }
1025
- const date = new Date(iso);
1026
-
1027
- return `${months[date.getMonth()]} ${date.getDate()}`;
1028
- }
1029
-
1030
- function fileSize(bytes: number): string {
1031
- if (bytes < 1024) {
1032
- return `${bytes} B`;
1033
- }
1034
- if (bytes < 1024 * 1024) {
1035
- return `${Math.round(bytes / 1024)} KB`;
1036
- }
1037
-
1038
- return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
1039
- }
1040
-
1041
- type Styles = ReturnType<typeof createStyles>;
1042
-
1043
- function createStyles(t: ChatTheme) {
1044
- const r = t.radius;
1045
-
1046
- return StyleSheet.create({
1047
- root: { flex: 1, backgroundColor: t.bg },
1048
- flex: { flex: 1 },
1049
- bold: { fontWeight: '600' },
1050
- pressed: { opacity: 0.92, transform: [{ scale: 0.985 }] },
1051
- disabled: { opacity: 0.4 },
1052
-
1053
- head: {
1054
- flexDirection: 'row',
1055
- alignItems: 'center',
1056
- gap: 10,
1057
- paddingHorizontal: 12,
1058
- paddingTop: 12,
1059
- paddingBottom: 11,
1060
- borderBottomWidth: 1,
1061
- borderBottomColor: t.line,
1062
- backgroundColor: t.bg,
1063
- },
1064
- headHome: { flexDirection: 'column', alignItems: 'stretch', gap: 0, padding: 16, paddingBottom: 20, backgroundColor: t.headBg },
1065
- headRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 8 },
1066
- headMain: { flex: 1, minWidth: 0 },
1067
- ws: { flexDirection: 'row', alignItems: 'center', gap: 9, flexShrink: 1 },
1068
- wsGlyph: {
1069
- width: 26,
1070
- height: 26,
1071
- borderRadius: Math.min(r.av, 13),
1072
- backgroundColor: t.accent,
1073
- alignItems: 'center',
1074
- justifyContent: 'center',
1075
- overflow: 'hidden',
1076
- },
1077
- wsGlyphText: { color: t.accentFg, fontSize: 12, fontWeight: '700' },
1078
- wsName: { fontSize: 13.5, fontWeight: '600', letterSpacing: -0.14, color: t.fg, flexShrink: 1 },
1079
- greet: { marginTop: 18, fontSize: 22, lineHeight: 28, fontWeight: '600', letterSpacing: -0.55, color: t.fg },
1080
- subRow: { marginTop: 8, flexDirection: 'row', alignItems: 'center', gap: 8 },
1081
- sub: { flex: 1, color: t.fg3, fontSize: 13, lineHeight: 18 },
1082
- title: { fontSize: 14, fontWeight: '600', letterSpacing: -0.14, color: t.fg },
1083
- titleSub: { fontSize: 12, color: t.fg3, marginTop: 1 },
1084
- iconBtn: { width: 30, height: 30, borderRadius: Math.min(r.ctl, 15), alignItems: 'center', justifyContent: 'center' },
1085
-
1086
- homeBody: { padding: 16, gap: 12 },
1087
- card: { borderWidth: 1, borderColor: t.line, borderRadius: r.card, backgroundColor: t.bg, overflow: 'hidden' },
1088
- cardHead: {
1089
- paddingHorizontal: 14,
1090
- paddingTop: 12,
1091
- paddingBottom: 6,
1092
- fontSize: 11,
1093
- fontWeight: '600',
1094
- letterSpacing: 0.66,
1095
- textTransform: 'uppercase',
1096
- color: t.fg4,
1097
- },
1098
- conv: { flexDirection: 'row', alignItems: 'flex-start', gap: 10, paddingVertical: 10, paddingHorizontal: 14 },
1099
- convBorder: { borderTopWidth: 1, borderTopColor: t.line },
1100
- dot: { width: 7, height: 7, borderRadius: 4, marginTop: 6, backgroundColor: t.line2 },
1101
- convMain: { flex: 1, minWidth: 0 },
1102
- convTop: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', gap: 8 },
1103
- convSubject: { flex: 1, fontSize: 13, fontWeight: '500', color: t.fg },
1104
- convPrev: { flex: 1, fontSize: 12.5, color: t.fg3, marginTop: 1 },
1105
- time: { fontFamily: t.mono, fontSize: 10.5, color: t.fg4 },
1106
- unread: { minWidth: 18, height: 18, borderRadius: 9, paddingHorizontal: 5, backgroundColor: t.danger, alignItems: 'center', justifyContent: 'center' },
1107
- unreadText: { color: '#ffffff', fontSize: 10.5, fontWeight: '600' },
1108
- primary: {
1109
- height: 44,
1110
- borderRadius: r.ctl,
1111
- backgroundColor: t.accent,
1112
- flexDirection: 'row',
1113
- alignItems: 'center',
1114
- justifyContent: 'space-between',
1115
- paddingHorizontal: 16,
1116
- shadowColor: '#000000',
1117
- shadowOpacity: 0.08,
1118
- shadowRadius: 2,
1119
- shadowOffset: { width: 0, height: 1 },
1120
- elevation: 1,
1121
- },
1122
- primaryText: { color: t.accentFg, fontSize: 13.5, fontWeight: '500' },
1123
- linkRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingVertical: 12, paddingHorizontal: 14 },
1124
- linkText: { fontSize: 13, fontWeight: '500', color: t.fg },
1125
- away: { borderWidth: 1, borderColor: t.line, borderRadius: r.card, backgroundColor: t.bg3, padding: 12 },
1126
- awayText: { fontSize: 12.5, lineHeight: 18, color: t.fg2 },
1127
- foot: { height: 30, alignItems: 'center', justifyContent: 'center', borderTopWidth: 1, borderTopColor: t.line },
1128
- footText: { fontSize: 11, color: t.fg4 },
1129
- skeletonCard: { paddingVertical: 4 },
1130
- skel: { height: 12, borderRadius: 4, marginVertical: 10, marginHorizontal: 14, backgroundColor: t.bg2 },
1131
-
1132
- emptyScroll: { flexGrow: 1 },
1133
- empty: { paddingVertical: 32, paddingHorizontal: 24, alignItems: 'center' },
1134
- emptyTitle: { fontSize: 14, fontWeight: '600', color: t.fg, marginBottom: 4, textAlign: 'center' },
1135
- emptyText: { fontSize: 13, lineHeight: 19, color: t.fg3, textAlign: 'center' },
1136
- picker: { margin: 14 },
1137
- pickerTitle: { paddingHorizontal: 14, paddingTop: 14, fontSize: 14, fontWeight: '600', color: t.fg },
1138
- pickerHint: { paddingHorizontal: 14, marginTop: 2, marginBottom: 12, fontSize: 12.5, lineHeight: 18, color: t.fg3 },
1139
- topic: {
1140
- minHeight: 42,
1141
- flexDirection: 'row',
1142
- alignItems: 'center',
1143
- justifyContent: 'space-between',
1144
- gap: 10,
1145
- paddingVertical: 10,
1146
- paddingHorizontal: 14,
1147
- },
1148
- topicText: { flex: 1, fontSize: 13.5, lineHeight: 19, fontWeight: '500', color: t.fg },
1149
-
1150
- thread: { paddingTop: 16, paddingHorizontal: 14, paddingBottom: 8 },
1151
- day: { alignSelf: 'center', fontFamily: t.mono, fontSize: 10.5, color: t.fg4, marginTop: 10, marginBottom: 6 },
1152
- row: { maxWidth: '84%', marginTop: 2 },
1153
- rowMe: { alignSelf: 'flex-end', alignItems: 'flex-end' },
1154
- rowThem: { alignSelf: 'flex-start', alignItems: 'flex-start' },
1155
- rowGap: { marginTop: 12 },
1156
- who: { flexDirection: 'row', alignItems: 'center', gap: 6, marginHorizontal: 2, marginBottom: 4 },
1157
- whoName: { fontSize: 11.5, fontWeight: '500', color: t.fg2 },
1158
- aiPill: { height: 16, paddingHorizontal: 5, borderRadius: 4, backgroundColor: t.aiSoft, justifyContent: 'center' },
1159
- aiPillText: { color: t.ai, fontSize: 9.5, fontWeight: '600', letterSpacing: 0.38 },
1160
- msg: { paddingVertical: 8, paddingHorizontal: 12, maxWidth: '100%', borderRadius: r.bubble },
1161
- msgMe: { backgroundColor: t.accent },
1162
- msgThem: { backgroundColor: t.bg2 },
1163
- msgAi: { backgroundColor: t.bg3, borderWidth: 1, borderColor: t.line },
1164
- msgText: { fontSize: 13.5, lineHeight: 19.5, color: t.fg },
1165
- sendingMsg: { opacity: 0.7 },
1166
- meta: { fontFamily: t.mono, fontSize: 10, color: t.fg4, marginTop: 3, marginHorizontal: 4 },
1167
- tools: { gap: 4, marginBottom: 6 },
1168
- tool: { flexDirection: 'row', alignItems: 'center', gap: 7 },
1169
- toolIc: { width: 16, height: 16, borderRadius: 8, backgroundColor: t.aiSoft, alignItems: 'center', justifyContent: 'center' },
1170
- toolText: { fontSize: 12, lineHeight: 18, color: t.fg3 },
1171
- fileGap: { marginTop: 4 },
1172
- thumb: { width: 220, height: 160, borderRadius: r.card, borderWidth: 1, borderColor: t.line, backgroundColor: t.bg2 },
1173
- chip: {
1174
- flexDirection: 'row',
1175
- alignItems: 'center',
1176
- gap: 6,
1177
- maxWidth: 240,
1178
- height: 30,
1179
- paddingHorizontal: 10,
1180
- borderRadius: r.ctl,
1181
- borderWidth: 1,
1182
- borderColor: t.line,
1183
- backgroundColor: t.bg,
1184
- },
1185
- chipText: { flexShrink: 1, fontSize: 12, color: t.fg },
1186
- chipSize: { fontFamily: t.mono, fontSize: 10.5, color: t.fg4 },
1187
- chipSpinner: { transform: [{ scale: 0.6 }], width: 14, height: 14 },
1188
- handoff: {
1189
- marginTop: 6,
1190
- flexDirection: 'row',
1191
- alignItems: 'center',
1192
- gap: 6,
1193
- height: 30,
1194
- paddingHorizontal: 11,
1195
- borderRadius: r.ctl,
1196
- borderWidth: 1,
1197
- borderColor: t.line2,
1198
- backgroundColor: t.bg,
1199
- },
1200
- handoffText: { fontSize: 12.5, fontWeight: '500', color: t.fg },
1201
- handed: {
1202
- marginTop: 6,
1203
- marginBottom: 14,
1204
- flexDirection: 'row',
1205
- alignItems: 'flex-start',
1206
- gap: 10,
1207
- paddingVertical: 11,
1208
- paddingHorizontal: 12,
1209
- borderRadius: r.card,
1210
- backgroundColor: t.bg3,
1211
- borderWidth: 1,
1212
- borderColor: t.line,
1213
- },
1214
- handedIc: {
1215
- width: 28,
1216
- height: 28,
1217
- borderRadius: Math.min(r.av, 14),
1218
- backgroundColor: t.bg,
1219
- borderWidth: 1,
1220
- borderColor: t.line,
1221
- alignItems: 'center',
1222
- justifyContent: 'center',
1223
- },
1224
- handedTitle: { fontSize: 13, lineHeight: 18, fontWeight: '600', color: t.fg },
1225
- handedText: { fontSize: 12, lineHeight: 17, color: t.fg3 },
1226
- typing: { alignSelf: 'flex-start', flexDirection: 'row', alignItems: 'center', gap: 8, paddingTop: 10, paddingBottom: 4 },
1227
- typingBubble: {
1228
- paddingHorizontal: 12,
1229
- height: 36,
1230
- justifyContent: 'center',
1231
- borderRadius: r.bubble,
1232
- borderBottomLeftRadius: 4,
1233
- backgroundColor: t.bg2,
1234
- },
1235
- typingText: { fontSize: 12, color: t.fg3 },
1236
- action: {
1237
- marginTop: 6,
1238
- width: 272,
1239
- maxWidth: '100%',
1240
- paddingTop: 11,
1241
- paddingHorizontal: 12,
1242
- paddingBottom: 12,
1243
- borderRadius: r.card,
1244
- borderWidth: 1,
1245
- borderColor: t.line,
1246
- backgroundColor: t.bg,
1247
- },
1248
- actionPending: { borderColor: t.aiLine },
1249
- actionTop: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 8, minHeight: 20 },
1250
- actionTool: { flexDirection: 'row', alignItems: 'center', gap: 5, flexShrink: 1 },
1251
- actionToolText: { fontSize: 11.5, fontWeight: '500', color: t.ai },
1252
- actionNeed: { fontSize: 11, color: t.fg4 },
1253
- actionTitle: { marginTop: 6, fontSize: 13.5, lineHeight: 19, fontWeight: '600', letterSpacing: -0.14, color: t.fg },
1254
- actionSum: { marginTop: 2, fontSize: 12.5, lineHeight: 18, color: t.fg2 },
1255
- actionBtns: { flexDirection: 'row', justifyContent: 'flex-end', gap: 6, marginTop: 10 },
1256
- btn: { height: 30, paddingHorizontal: 12, borderRadius: r.ctl, alignItems: 'center', justifyContent: 'center' },
1257
- btnSolid: { backgroundColor: t.accent },
1258
- btnSolidText: { color: t.accentFg, fontSize: 12.5, fontWeight: '500' },
1259
- btnGhost: { borderWidth: 1, borderColor: t.line2 },
1260
- btnGhostText: { color: t.fg, fontSize: 12.5, fontWeight: '500' },
1261
-
1262
- composer: { paddingTop: 10, paddingHorizontal: 12, paddingBottom: 12, gap: 6, backgroundColor: t.bg },
1263
- topicRow: { alignItems: 'center' },
1264
- topicChip: {
1265
- flexDirection: 'row',
1266
- alignItems: 'center',
1267
- gap: 4,
1268
- height: 26,
1269
- paddingLeft: 10,
1270
- paddingRight: 4,
1271
- borderRadius: 999,
1272
- backgroundColor: t.bg2,
1273
- borderWidth: 1,
1274
- borderColor: t.line,
1275
- },
1276
- topicChipText: { fontSize: 12, fontWeight: '500', color: t.fg },
1277
- topicChipX: { width: 20, height: 20, borderRadius: 10, alignItems: 'center', justifyContent: 'center' },
1278
- pendingFiles: { flexDirection: 'row', flexWrap: 'wrap', gap: 6 },
1279
- pendingChip: { height: 26 },
1280
- err: { color: t.danger, fontSize: 12 },
1281
- cbar: {
1282
- flexDirection: 'row',
1283
- alignItems: 'flex-end',
1284
- gap: 4,
1285
- padding: 5,
1286
- paddingLeft: 12,
1287
- borderWidth: 1,
1288
- borderColor: t.line,
1289
- borderRadius: r.bar,
1290
- backgroundColor: t.bg,
1291
- },
1292
- cbarLead: { paddingLeft: 5 },
1293
- cbarFocus: { borderColor: t.fg4 },
1294
- cbarOff: { backgroundColor: t.bg3 },
1295
- input: {
1296
- flex: 1,
1297
- minHeight: 30,
1298
- maxHeight: 120,
1299
- paddingVertical: 5,
1300
- paddingHorizontal: 0,
1301
- fontSize: 14,
1302
- lineHeight: 20,
1303
- color: t.fg,
1304
- ...(Platform.OS === 'web' ? ({ outlineStyle: 'none' } as object) : {}),
1305
- },
1306
- send: {
1307
- width: 30,
1308
- height: 30,
1309
- borderRadius: Math.min(r.ctl, 15),
1310
- alignItems: 'center',
1311
- justifyContent: 'center',
1312
- backgroundColor: t.accent,
1313
- opacity: 0.28,
1314
- transform: [{ scale: 0.9 }],
1315
- },
1316
- sendReady: { opacity: 1, transform: [{ scale: 1 }] },
1317
- });
1318
- }
1
+ import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
+ import {
3
+ ActivityIndicator,
4
+ Animated,
5
+ FlatList,
6
+ BackHandler,
7
+ Image,
8
+ Keyboard,
9
+ Linking,
10
+ Platform,
11
+ Pressable,
12
+ ScrollView,
13
+ StyleSheet,
14
+ Text,
15
+ TextInput,
16
+ useColorScheme,
17
+ View,
18
+ type StyleProp,
19
+ type ViewStyle,
20
+ } from 'react-native';
21
+ import type { AIReply } from '../client';
22
+ import { useConversation, useConversations, useOpenDesk, useOpenDeskState } from '../react';
23
+ import type { Attachment, Conversation, Message, UploadFile, WidgetConfig } from '../types';
24
+ import { BotFace } from './BotFace';
25
+ import { ArrowIcon, ChevronIcon, CloseIcon, PaperclipIcon, PersonIcon, SparkIcon } from './icons';
26
+ import { useKeyboardInset } from './keyboard';
27
+ import { extractSources, hostOf, parseHtml, parseMarkdown, type Block, type Source } from './rich';
28
+ import { RichText } from './RichText';
29
+ import { defaultStrings, format, type ChatStrings } from './strings';
30
+ import { makeTheme, type ChatTheme } from './theme';
31
+
32
+ export interface OpenDeskChatProps {
33
+ /** Opens straight into this conversation instead of the home screen. */
34
+ initialConversationId?: string;
35
+ /** Shown as a close button in the header when given. */
36
+ onClose?: () => void;
37
+ /**
38
+ * Lets the visitor attach a file. Wire it to your picker (expo-image-picker,
39
+ * expo-document-picker…) and resolve `{ uri, name, type }`, a web `File`, or null when cancelled.
40
+ * Without it the attach button is hidden.
41
+ */
42
+ pickAttachment?: () => Promise<UploadFile | null>;
43
+ /** Overrides the widget's brand colour. */
44
+ accentColor?: string;
45
+ /** Overrides the widget's theme setting. */
46
+ theme?: 'light' | 'dark' | 'auto';
47
+ /** Replaces any of the built-in English strings. */
48
+ strings?: Partial<ChatStrings>;
49
+ /**
50
+ * Extra space to keep between the composer and the keyboard. The chat measures its own position,
51
+ * so navigation headers need nothing here; inside an iOS page sheet, which it can't see past,
52
+ * pass the sheet's distance from the top of the screen.
53
+ */
54
+ keyboardVerticalOffset?: number;
55
+ }
56
+
57
+ const MAX_UPLOAD_BYTES = 25 * 1024 * 1024;
58
+ const GROUP_GAP_MS = 5 * 60 * 1000;
59
+
60
+ /**
61
+ * A drop-in support screen that mirrors openDesk's web widget: the home screen, the thread
62
+ * with AI streaming, actions and handoff, and the composer with attachments.
63
+ */
64
+ export function OpenDeskChat({
65
+ initialConversationId,
66
+ onClose,
67
+ pickAttachment,
68
+ accentColor,
69
+ theme: themeSetting,
70
+ strings: stringOverrides,
71
+ keyboardVerticalOffset = 0,
72
+ }: OpenDeskChatProps) {
73
+ const [conversationId, setConversationId] = useState<string | null>(initialConversationId ?? null);
74
+ const [composing, setComposing] = useState(false);
75
+ const config = useOpenDeskState((state) => state.config);
76
+ const systemScheme = useColorScheme();
77
+ const setting = themeSetting ?? config?.theme ?? 'auto';
78
+ const scheme = setting === 'auto' ? (systemScheme === 'dark' ? 'dark' : 'light') : setting;
79
+ const theme = useMemo(() => makeTheme(config, scheme, accentColor), [config, scheme, accentColor]);
80
+ const strings = useMemo(() => ({ ...defaultStrings, ...stringOverrides }), [stringOverrides]);
81
+ const styles = useMemo(() => createStyles(theme), [theme]);
82
+ const ui = useMemo(() => ({ theme, strings, styles, bot: config?.bot ?? null }), [theme, strings, styles, config?.bot]);
83
+ const rootRef = useRef<View>(null);
84
+ const keyboard = useKeyboardInset(rootRef, keyboardVerticalOffset);
85
+
86
+ const inThread = conversationId !== null || composing;
87
+
88
+ return (
89
+ <View ref={rootRef} style={[styles.root, { paddingBottom: keyboard.inset }]} onLayout={keyboard.onLayout}>
90
+ {inThread ? (
91
+ <Thread
92
+ ui={ui}
93
+ conversationId={conversationId}
94
+ pickAttachment={pickAttachment}
95
+ onBack={() => {
96
+ setConversationId(null);
97
+ setComposing(false);
98
+ }}
99
+ onClose={onClose}
100
+ onStarted={(id) => {
101
+ setComposing(false);
102
+ setConversationId(id);
103
+ }}
104
+ />
105
+ ) : (
106
+ <Home ui={ui} onClose={onClose} onOpen={setConversationId} onNew={() => setComposing(true)} />
107
+ )}
108
+ </View>
109
+ );
110
+ }
111
+
112
+ interface Ui {
113
+ theme: ChatTheme;
114
+ strings: ChatStrings;
115
+ styles: Styles;
116
+ /** The widget's bot, for messages whose author carries no look of its own. */
117
+ bot: WidgetConfig['bot'];
118
+ }
119
+
120
+ function subtitleFor(config: WidgetConfig | null, strings: ChatStrings): string {
121
+ if (config?.subtitle) {
122
+ return config.subtitle;
123
+ }
124
+
125
+ return config?.ai_answers && config.bot ? format(strings.replyTimeAi, { name: config.bot.name }) : strings.replyTime;
126
+ }
127
+
128
+ function Home({
129
+ ui,
130
+ onClose,
131
+ onOpen,
132
+ onNew,
133
+ }: {
134
+ ui: Ui;
135
+ onClose?: () => void;
136
+ onOpen: (id: string) => void;
137
+ onNew: () => void;
138
+ }) {
139
+ const { theme, strings, styles } = ui;
140
+ const { conversations, ready, error } = useConversations();
141
+ const unread = useOpenDeskState((state) => state.unread);
142
+ const config = useOpenDeskState((state) => state.config);
143
+ const name = config?.title || config?.workspace_name || strings.support;
144
+
145
+ return (
146
+ <View style={styles.flex}>
147
+ <View style={[styles.head, styles.headHome]}>
148
+ <View style={styles.headRow}>
149
+ <View style={styles.ws}>
150
+ <Glyph ui={ui} name={name} url={config?.avatar_url ?? null} />
151
+ <Text style={styles.wsName} numberOfLines={1}>
152
+ {name}
153
+ </Text>
154
+ </View>
155
+ {onClose ? <IconButton ui={ui} label={strings.closeChat} onPress={onClose} icon={<CloseIcon color={theme.fg3} />} /> : null}
156
+ </View>
157
+ <Text style={styles.greet}>{config?.greeting || strings.greeting}</Text>
158
+ <View style={styles.subRow}>
159
+ <AvatarStack ui={ui} config={config} />
160
+ <Text style={styles.sub}>{subtitleFor(config, strings)}</Text>
161
+ </View>
162
+ </View>
163
+
164
+ <ScrollView style={styles.flex} contentContainerStyle={styles.homeBody}>
165
+ {!ready ? (
166
+ error ? (
167
+ <View style={styles.empty}>
168
+ <Text style={styles.emptyTitle}>{strings.unavailable}</Text>
169
+ <Text style={styles.emptyText}>{strings.unavailableHint}</Text>
170
+ </View>
171
+ ) : (
172
+ <View style={[styles.card, styles.skeletonCard]}>
173
+ <View style={[styles.skel, { width: '60%' }]} />
174
+ <View style={[styles.skel, { width: '85%' }]} />
175
+ </View>
176
+ )
177
+ ) : null}
178
+
179
+ {ready && conversations.length > 0 ? (
180
+ <View style={styles.card}>
181
+ <Text style={styles.cardHead}>{strings.yourConversations}</Text>
182
+ {conversations.map((conversation, index) => (
183
+ <ConversationRow
184
+ key={conversation.id}
185
+ ui={ui}
186
+ conversation={conversation}
187
+ unread={unread[conversation.id] ?? 0}
188
+ first={index === 0}
189
+ onOpen={onOpen}
190
+ />
191
+ ))}
192
+ </View>
193
+ ) : null}
194
+
195
+ {config?.open === false ? (
196
+ <View style={styles.away}>
197
+ <Text style={styles.awayText}>{config.offline_message || strings.offlineDefault}</Text>
198
+ </View>
199
+ ) : null}
200
+
201
+ <Pressable accessibilityRole="button" onPress={onNew} style={({ pressed }) => [styles.primary, pressed && styles.pressed]}>
202
+ <Text style={styles.primaryText}>{strings.sendMessage}</Text>
203
+ <ArrowIcon color={theme.accentFg} size={15} />
204
+ </Pressable>
205
+
206
+ {config?.help_center_url ? (
207
+ <View style={styles.card}>
208
+ <Pressable
209
+ accessibilityRole="link"
210
+ onPress={() => Linking.openURL(config.help_center_url)}
211
+ style={({ pressed }) => [styles.linkRow, pressed && { backgroundColor: theme.bg3 }]}
212
+ >
213
+ <Text style={styles.linkText}>{strings.searchHelp}</Text>
214
+ <ArrowIcon color={theme.fg3} size={14} angle={-45} />
215
+ </Pressable>
216
+ </View>
217
+ ) : null}
218
+ </ScrollView>
219
+
220
+ <Pressable accessibilityRole="link" onPress={() => Linking.openURL('https://opendesk.sh')} style={styles.foot}>
221
+ <Text style={styles.footText}>{strings.poweredBy}</Text>
222
+ </Pressable>
223
+ </View>
224
+ );
225
+ }
226
+
227
+ const ConversationRow = memo(function ConversationRow({
228
+ ui,
229
+ conversation,
230
+ unread,
231
+ first,
232
+ onOpen,
233
+ }: {
234
+ ui: Ui;
235
+ conversation: Conversation;
236
+ unread: number;
237
+ first: boolean;
238
+ onOpen: (id: string) => void;
239
+ }) {
240
+ const { theme, strings, styles } = ui;
241
+ const open = conversation.status === 'open' || conversation.status === 'pending';
242
+
243
+ return (
244
+ <Pressable
245
+ accessibilityRole="button"
246
+ onPress={() => onOpen(conversation.id)}
247
+ style={({ pressed }) => [styles.conv, !first && styles.convBorder, pressed && { backgroundColor: theme.bg3 }]}
248
+ >
249
+ <View style={[styles.dot, open && { backgroundColor: theme.accent }]} />
250
+ <View style={styles.convMain}>
251
+ <View style={styles.convTop}>
252
+ <Text style={[styles.convSubject, unread > 0 && styles.bold]} numberOfLines={1}>
253
+ {conversation.subject || strings.conversation}
254
+ </Text>
255
+ <Text style={styles.time}>{relativeTime(conversation.last_message_at)}</Text>
256
+ </View>
257
+ <View style={styles.convTop}>
258
+ <Text style={styles.convPrev} numberOfLines={1}>
259
+ {conversation.last_message_preview || ' '}
260
+ </Text>
261
+ {unread > 0 ? (
262
+ <View style={styles.unread}>
263
+ <Text style={styles.unreadText}>{unread}</Text>
264
+ </View>
265
+ ) : null}
266
+ </View>
267
+ </View>
268
+ </Pressable>
269
+ );
270
+ });
271
+
272
+ type Row =
273
+ | { kind: 'message'; key: string; message: Message; first: boolean; last: boolean; gap: boolean }
274
+ | { kind: 'reply'; key: string; reply: AIReply }
275
+ | { kind: 'day'; key: string; label: string }
276
+ | { kind: 'typing'; key: string; name: string }
277
+ | { kind: 'handed'; key: string };
278
+
279
+ function Thread({
280
+ ui,
281
+ conversationId,
282
+ pickAttachment,
283
+ onBack,
284
+ onClose,
285
+ onStarted,
286
+ }: {
287
+ ui: Ui;
288
+ conversationId: string | null;
289
+ pickAttachment?: () => Promise<UploadFile | null>;
290
+ onBack: () => void;
291
+ onClose?: () => void;
292
+ onStarted: (id: string) => void;
293
+ }) {
294
+ const { theme, strings, styles } = ui;
295
+ const client = useOpenDesk();
296
+ const config = useOpenDeskState((state) => state.config);
297
+ const { messages, aiReplies, typing, conversation } = useConversation(conversationId);
298
+ const [draft, setDraft] = useState('');
299
+ const [attachments, setAttachments] = useState<Attachment[]>([]);
300
+ const [uploadingName, setUploadingName] = useState<string | null>(null);
301
+ const [topicId, setTopicId] = useState<string | null>(null);
302
+ const [sending, setSending] = useState(false);
303
+ const [focused, setFocused] = useState(false);
304
+ const [inputHeight, setInputHeight] = useState(30);
305
+ const [error, setError] = useState<string | null>(null);
306
+ const [viewing, setViewing] = useState<Attachment | null>(null);
307
+ const openImage = useCallback((attachment: Attachment) => {
308
+ Keyboard.dismiss();
309
+ setViewing(attachment);
310
+ }, []);
311
+ const closeImage = useCallback(() => setViewing(null), []);
312
+
313
+ const topics = config?.topics ?? [];
314
+ const topic = topics.find((t) => t.id === topicId) ?? null;
315
+ const needsTopic = conversationId === null && topics.length > 0 && topic === null;
316
+ const name = config?.title || config?.workspace_name || strings.support;
317
+ const lastMessage = messages[messages.length - 1];
318
+ const lastAiIndex = lastIndexWhere(messages, (m) => m.ai === true);
319
+ const handedOff = conversation?.ai_handoff === true && !messages.slice(lastAiIndex + 1).some((m) => m.author.type === 'user');
320
+ const handoffMessageId =
321
+ conversation && !conversation.ai_handoff && aiReplies.length === 0 && lastMessage?.ai && lastMessage.direction === 'outbound'
322
+ ? lastMessage.id
323
+ : null;
324
+
325
+ const rows = useMemo(() => buildRows(messages, aiReplies, typing, handedOff, strings), [messages, aiReplies, typing, handedOff, strings]);
326
+
327
+ const decide = useCallback(
328
+ (message: Message, decision: 'confirm' | 'decline') => {
329
+ if (message.ai_action) {
330
+ client.decideAction(message.ticket_id, message.ai_action.id, decision).catch(() => undefined);
331
+ }
332
+ },
333
+ [client],
334
+ );
335
+
336
+ const requestHuman = useCallback(
337
+ (message: Message) => {
338
+ client.requestHuman(message.ticket_id).catch(() => setError(strings.handoffFailed));
339
+ },
340
+ [client, strings.handoffFailed],
341
+ );
342
+
343
+ const ready = (draft.trim().length > 0 || attachments.length > 0) && !sending && uploadingName === null && !needsTopic;
344
+
345
+ const send = useCallback(async () => {
346
+ if (!ready) {
347
+ return;
348
+ }
349
+ const body = draft.trim();
350
+ const attachmentIds = attachments.map((attachment) => attachment.id);
351
+ setError(null);
352
+ setDraft('');
353
+ setAttachments([]);
354
+ try {
355
+ if (conversationId) {
356
+ await client.sendMessage(conversationId, { ...(body ? { body } : {}), attachment_ids: attachmentIds });
357
+ } else {
358
+ setSending(true);
359
+ const started = await client.startConversation({
360
+ ...(body ? { body } : {}),
361
+ attachment_ids: attachmentIds,
362
+ ...(topicId ? { topic_id: topicId } : {}),
363
+ });
364
+ onStarted(started.id);
365
+ }
366
+ } catch (caught) {
367
+ setDraft(body);
368
+ setAttachments(attachments);
369
+ setError(caught instanceof Error ? `${strings.notSent} ${caught.message}` : strings.notSent);
370
+ } finally {
371
+ setSending(false);
372
+ }
373
+ }, [attachments, client, conversationId, draft, onStarted, ready, strings.notSent, topicId]);
374
+
375
+ const attach = useCallback(async () => {
376
+ if (!pickAttachment) {
377
+ return;
378
+ }
379
+ setError(null);
380
+ let file: UploadFile | null;
381
+ try {
382
+ file = await pickAttachment();
383
+ } catch {
384
+ return;
385
+ }
386
+ if (!file) {
387
+ return;
388
+ }
389
+ const fileName = 'name' in file && typeof file.name === 'string' ? file.name : 'file';
390
+ const size = 'size' in file && typeof file.size === 'number' ? file.size : undefined;
391
+ const allowed = config?.attachment_extensions;
392
+ const dot = fileName.lastIndexOf('.');
393
+ if (allowed && allowed.length > 0 && (dot < 1 || !allowed.includes(fileName.slice(dot).toLowerCase()))) {
394
+ setError(format(strings.fileTypeRefused, { name: fileName }));
395
+
396
+ return;
397
+ }
398
+ if (size !== undefined && size > MAX_UPLOAD_BYTES) {
399
+ setError(format(strings.fileTooLarge, { name: fileName }));
400
+
401
+ return;
402
+ }
403
+ setUploadingName(fileName);
404
+ try {
405
+ const uploaded = await client.upload(file);
406
+ setAttachments((current) => [...current, uploaded]);
407
+ } catch (caught) {
408
+ setError(caught instanceof Error && caught.message ? caught.message : format(strings.uploadFailed, { name: fileName }));
409
+ } finally {
410
+ setUploadingName(null);
411
+ }
412
+ }, [client, config?.attachment_extensions, pickAttachment, strings]);
413
+
414
+ const showAttach = Boolean(pickAttachment) && config?.attachments !== false && (config?.attachment_extensions?.length ?? 1) > 0;
415
+ const bot = config?.ai_answers ? config.bot : null;
416
+
417
+ return (
418
+ <View style={styles.flex}>
419
+ <View style={styles.head}>
420
+ <IconButton ui={ui} label={strings.back} onPress={onBack} icon={<ChevronIcon color={theme.fg3} direction="left" />} />
421
+ {bot ? (
422
+ <BotFace size={26} look={bot.look} avatarUrl={bot.avatar_url} />
423
+ ) : (
424
+ <Glyph ui={ui} name={name} url={config?.avatar_url ?? null} />
425
+ )}
426
+ <View style={styles.headMain}>
427
+ <Text style={styles.title} numberOfLines={1}>
428
+ {conversation?.subject || name}
429
+ </Text>
430
+ <Text style={styles.titleSub} numberOfLines={1}>
431
+ {subtitleFor(config, strings)}
432
+ </Text>
433
+ </View>
434
+ {onClose ? <IconButton ui={ui} label={strings.closeChat} onPress={onClose} icon={<CloseIcon color={theme.fg3} />} /> : null}
435
+ </View>
436
+
437
+ {rows.length === 0 ? (
438
+ <ScrollView style={styles.flex} contentContainerStyle={styles.emptyScroll} keyboardShouldPersistTaps="handled">
439
+ {needsTopic ? (
440
+ <View style={[styles.card, styles.picker]}>
441
+ <Text style={styles.pickerTitle}>{strings.topicsTitle}</Text>
442
+ <Text style={styles.pickerHint}>{strings.topicsHint}</Text>
443
+ {topics.map((item, index) => (
444
+ <Pressable
445
+ key={item.id}
446
+ accessibilityRole="button"
447
+ onPress={() => setTopicId(item.id)}
448
+ style={({ pressed }) => [styles.topic, index > 0 && styles.convBorder, pressed && { backgroundColor: theme.bg2 }]}
449
+ >
450
+ <Text style={styles.topicText}>{item.label}</Text>
451
+ <ChevronIcon color={theme.fg4} size={14} direction="right" />
452
+ </Pressable>
453
+ ))}
454
+ </View>
455
+ ) : (
456
+ <View style={styles.empty}>
457
+ <Text style={styles.emptyTitle}>{config?.greeting || strings.greeting}</Text>
458
+ <Text style={styles.emptyText}>{config?.intro || strings.emptyHint}</Text>
459
+ </View>
460
+ )}
461
+ </ScrollView>
462
+ ) : (
463
+ <FlatList
464
+ inverted
465
+ style={styles.flex}
466
+ data={rows}
467
+ keyExtractor={(row) => row.key}
468
+ contentContainerStyle={styles.thread}
469
+ keyboardShouldPersistTaps="handled"
470
+ renderItem={({ item }) => (
471
+ <ThreadRow
472
+ ui={ui}
473
+ row={item}
474
+ showHandoff={item.kind === 'message' && item.message.id === handoffMessageId}
475
+ onDecide={decide}
476
+ onRequestHuman={requestHuman}
477
+ onOpenImage={openImage}
478
+ />
479
+ )}
480
+ />
481
+ )}
482
+
483
+ <View style={styles.composer}>
484
+ {topic && conversationId === null ? (
485
+ <View style={styles.topicRow}>
486
+ <View style={styles.topicChip}>
487
+ <Text style={styles.topicChipText} numberOfLines={1}>
488
+ {topic.label}
489
+ </Text>
490
+ <Pressable
491
+ accessibilityRole="button"
492
+ accessibilityLabel={strings.changeTopic}
493
+ onPress={() => setTopicId(null)}
494
+ style={styles.topicChipX}
495
+ >
496
+ <CloseIcon color={theme.fg3} size={10} />
497
+ </Pressable>
498
+ </View>
499
+ </View>
500
+ ) : null}
501
+
502
+ {attachments.length > 0 || uploadingName ? (
503
+ <View style={styles.pendingFiles}>
504
+ {attachments.map((attachment) => (
505
+ <View key={attachment.id} style={[styles.chip, styles.pendingChip]}>
506
+ <Text style={styles.chipText} numberOfLines={1}>
507
+ {attachment.filename}
508
+ </Text>
509
+ <Pressable
510
+ accessibilityRole="button"
511
+ accessibilityLabel={format(strings.removeFile, { name: attachment.filename })}
512
+ onPress={() => setAttachments((current) => current.filter((a) => a.id !== attachment.id))}
513
+ hitSlop={8}
514
+ >
515
+ <CloseIcon color={theme.fg3} size={10} />
516
+ </Pressable>
517
+ </View>
518
+ ))}
519
+ {uploadingName ? (
520
+ <View style={[styles.chip, styles.pendingChip]}>
521
+ <ActivityIndicator size="small" color={theme.fg3} style={styles.chipSpinner} />
522
+ <Text style={styles.chipText} numberOfLines={1}>
523
+ {uploadingName}
524
+ </Text>
525
+ </View>
526
+ ) : null}
527
+ </View>
528
+ ) : null}
529
+
530
+ {error ? <Text style={styles.err}>{error}</Text> : null}
531
+
532
+ <View style={[styles.cbar, showAttach && styles.cbarLead, focused && styles.cbarFocus, needsTopic && styles.cbarOff]}>
533
+ {showAttach ? (
534
+ <IconButton
535
+ ui={ui}
536
+ label={strings.attach}
537
+ onPress={attach}
538
+ disabled={uploadingName !== null || needsTopic}
539
+ icon={<PaperclipIcon color={theme.fg3} />}
540
+ />
541
+ ) : null}
542
+ <TextInput
543
+ style={[styles.input, { height: inputHeight }]}
544
+ value={draft}
545
+ onContentSizeChange={(event) => setInputHeight(Math.min(120, Math.max(30, Math.ceil(event.nativeEvent.contentSize.height))))}
546
+ {...(Platform.OS === 'web' ? ({ rows: 1 } as object) : {})}
547
+ editable={!needsTopic}
548
+ onFocus={() => setFocused(true)}
549
+ onBlur={() => setFocused(false)}
550
+ onChangeText={(text) => {
551
+ setDraft(text);
552
+ if (conversationId) {
553
+ client.typing(conversationId);
554
+ }
555
+ }}
556
+ placeholder={needsTopic ? strings.placeholderTopic : strings.placeholder}
557
+ placeholderTextColor={theme.fg4}
558
+ accessibilityLabel={strings.placeholder}
559
+ multiline
560
+ />
561
+ <Pressable
562
+ accessibilityRole="button"
563
+ accessibilityLabel={strings.send}
564
+ disabled={!ready}
565
+ onPress={send}
566
+ style={[styles.send, ready && styles.sendReady]}
567
+ >
568
+ <ArrowIcon color={theme.accentFg} size={14} angle={-90} />
569
+ </Pressable>
570
+ </View>
571
+ </View>
572
+
573
+ {viewing ? <ImageViewer ui={ui} attachment={viewing} onClose={closeImage} /> : null}
574
+ </View>
575
+ );
576
+ }
577
+
578
+ const ThreadRow = memo(function ThreadRow({
579
+ ui,
580
+ row,
581
+ showHandoff,
582
+ onDecide,
583
+ onRequestHuman,
584
+ onOpenImage,
585
+ }: {
586
+ ui: Ui;
587
+ row: Row;
588
+ showHandoff: boolean;
589
+ onDecide: (message: Message, decision: 'confirm' | 'decline') => void;
590
+ onRequestHuman: (message: Message) => void;
591
+ onOpenImage: (attachment: Attachment) => void;
592
+ }) {
593
+ const { theme, strings, styles } = ui;
594
+
595
+ switch (row.kind) {
596
+ case 'day':
597
+ return <Text style={styles.day}>{row.label}</Text>;
598
+ case 'typing':
599
+ return (
600
+ <View style={styles.typing}>
601
+ <View style={styles.typingBubble}>
602
+ <Dots color={theme.fg3} />
603
+ </View>
604
+ <Text style={styles.typingText}>{format(strings.typing, { name: row.name })}</Text>
605
+ </View>
606
+ );
607
+ case 'handed':
608
+ return (
609
+ <View style={styles.handed}>
610
+ <View style={styles.handedIc}>
611
+ <PersonIcon color={theme.fg2} />
612
+ </View>
613
+ <View style={styles.flex}>
614
+ <Text style={styles.handedTitle}>{strings.handedOff}</Text>
615
+ <Text style={styles.handedText}>{strings.handedOffHint}</Text>
616
+ </View>
617
+ </View>
618
+ );
619
+ case 'reply':
620
+ return (
621
+ <Bubble
622
+ ui={ui}
623
+ reply={row.reply}
624
+ first
625
+ last
626
+ gap
627
+ showHandoff={false}
628
+ onDecide={onDecide}
629
+ onRequestHuman={onRequestHuman}
630
+ onOpenImage={onOpenImage}
631
+ />
632
+ );
633
+ case 'message':
634
+ return (
635
+ <Bubble
636
+ ui={ui}
637
+ message={row.message}
638
+ first={row.first}
639
+ last={row.last}
640
+ gap={row.gap}
641
+ showHandoff={showHandoff}
642
+ onDecide={onDecide}
643
+ onRequestHuman={onRequestHuman}
644
+ onOpenImage={onOpenImage}
645
+ />
646
+ );
647
+ }
648
+ });
649
+
650
+ /**
651
+ * One bubble for a stored message or an AI answer still streaming. Both share one element
652
+ * tree, so when the stored message replaces the stream (same row key) nothing remounts.
653
+ */
654
+ function Bubble({
655
+ ui,
656
+ message,
657
+ reply,
658
+ first,
659
+ last,
660
+ gap,
661
+ showHandoff,
662
+ onDecide,
663
+ onRequestHuman,
664
+ onOpenImage,
665
+ }: {
666
+ ui: Ui;
667
+ message?: Message;
668
+ reply?: AIReply;
669
+ first: boolean;
670
+ last: boolean;
671
+ gap: boolean;
672
+ showHandoff: boolean;
673
+ onDecide: (message: Message, decision: 'confirm' | 'decline') => void;
674
+ onRequestHuman: (message: Message) => void;
675
+ onOpenImage: (attachment: Attachment) => void;
676
+ }) {
677
+ const { theme, strings, styles, bot: fallbackBot } = ui;
678
+ const mine = message?.direction === 'inbound';
679
+ const ai = reply !== undefined || message?.ai === true;
680
+ const author = message ? message.author.user?.name || message.author.bot?.name : reply?.bot?.name;
681
+ const bot = message ? message.author.bot : reply?.bot;
682
+ const avatar = message ? (message.author.user?.avatar_url ?? bot?.avatar_url) : bot?.avatar_url;
683
+ const botLook = bot?.look ?? fallbackBot?.look;
684
+ const text = message ? (message.body_text ?? '') : (reply?.text ?? '');
685
+ const body = useMemo(() => bodyOf(message, reply, mine, ai), [message, reply, mine, ai]);
686
+ const local = message?.id.startsWith('local-') === true;
687
+ const action = message?.ai_action;
688
+ const tools = reply?.tools ?? [];
689
+ const corners: ViewStyle = mine
690
+ ? { borderBottomRightRadius: 4, ...(first ? {} : { borderTopRightRadius: 4 }) }
691
+ : { borderBottomLeftRadius: 4, ...(first ? {} : { borderTopLeftRadius: 4 }) };
692
+ const images = (message?.attachments ?? []).filter((a) => a.content_type.startsWith('image/'));
693
+ const files = (message?.attachments ?? []).filter((a) => !a.content_type.startsWith('image/'));
694
+
695
+ return (
696
+ <View style={[styles.row, mine ? styles.rowMe : styles.rowThem, gap && styles.rowGap]}>
697
+ {!mine && first && author ? (
698
+ <View style={styles.who}>
699
+ {ai ? (
700
+ <BotFace size={18} look={botLook} avatarUrl={avatar} />
701
+ ) : avatar ? (
702
+ <Avatar ui={ui} size={18} name={author} url={avatar} tint={theme.bg2} />
703
+ ) : null}
704
+ <Text style={styles.whoName}>{author}</Text>
705
+ {ai ? (
706
+ <View style={styles.aiPill}>
707
+ <Text style={styles.aiPillText}>{strings.ai}</Text>
708
+ </View>
709
+ ) : null}
710
+ </View>
711
+ ) : null}
712
+
713
+ {tools.length > 0 ? (
714
+ <View style={styles.tools}>
715
+ {tools.map((tool, index) => (
716
+ <View key={`${tool.name}-${index}`} style={styles.tool}>
717
+ <View style={[styles.toolIc, tool.status === 'error' && { backgroundColor: theme.bg2 }]}>
718
+ <SparkIcon color={tool.status === 'error' ? theme.fg3 : theme.ai} size={8} />
719
+ </View>
720
+ <Text style={[styles.toolText, tool.status === 'running' && { color: theme.fg2 }]}>
721
+ {format(tool.status === 'running' ? strings.checking : tool.status === 'error' ? strings.toolFailed : strings.checked, {
722
+ tool: tool.name.replace(/_/g, ' '),
723
+ })}
724
+ </Text>
725
+ </View>
726
+ ))}
727
+ </View>
728
+ ) : null}
729
+
730
+ {text || body.blocks.length > 0 || reply ? (
731
+ <View
732
+ style={[
733
+ styles.msg,
734
+ mine ? styles.msgMe : ai ? styles.msgAi : styles.msgThem,
735
+ corners,
736
+ local && styles.sendingMsg,
737
+ ]}
738
+ >
739
+ {mine ? (
740
+ <Text style={[styles.msgText, { color: theme.accentFg }]}>{text}</Text>
741
+ ) : body.blocks.length > 0 ? (
742
+ <RichText
743
+ blocks={body.blocks}
744
+ textStyle={styles.msgText}
745
+ codeBackground={ai ? theme.bg2 : theme.line}
746
+ quoteColor={theme.line2}
747
+ mono={theme.mono}
748
+ />
749
+ ) : (
750
+ <Dots color={theme.ai} />
751
+ )}
752
+ </View>
753
+ ) : null}
754
+
755
+ {body.sources.length > 0 ? (
756
+ <View style={styles.sources}>
757
+ {body.sources.map((source) => (
758
+ <Pressable
759
+ key={source.href}
760
+ accessibilityRole="link"
761
+ accessibilityHint={hostOf(source.href)}
762
+ onPress={() => {
763
+ Linking.openURL(source.href).catch(() => undefined);
764
+ }}
765
+ style={({ pressed }) => [styles.src, pressed && { borderColor: theme.aiLine, backgroundColor: theme.aiSoft }]}
766
+ >
767
+ <ArrowIcon color={theme.ai} size={11} angle={-45} />
768
+ <Text style={styles.srcText} numberOfLines={1}>
769
+ {source.title}
770
+ </Text>
771
+ </Pressable>
772
+ ))}
773
+ </View>
774
+ ) : null}
775
+
776
+ {images.map((attachment) => (
777
+ <Pressable
778
+ key={attachment.id}
779
+ accessibilityRole="imagebutton"
780
+ accessibilityLabel={format(strings.viewImage, { name: attachment.filename })}
781
+ onPress={() => onOpenImage(attachment)}
782
+ style={({ pressed }) => [styles.fileGap, pressed && styles.pressed]}
783
+ >
784
+ <Image source={{ uri: attachment.url }} style={styles.thumb} resizeMode="cover" />
785
+ </Pressable>
786
+ ))}
787
+ {files.map((attachment) => (
788
+ <Pressable key={attachment.id} onPress={() => Linking.openURL(attachment.url)} style={[styles.chip, styles.fileGap]}>
789
+ <Text style={styles.chipText} numberOfLines={1}>
790
+ {attachment.filename}
791
+ </Text>
792
+ <Text style={styles.chipSize}>{fileSize(attachment.size_bytes)}</Text>
793
+ </Pressable>
794
+ ))}
795
+
796
+ {message && action ? <ActionCard ui={ui} message={message} onDecide={onDecide} /> : null}
797
+
798
+ {message && showHandoff ? (
799
+ <Pressable
800
+ accessibilityRole="button"
801
+ onPress={() => onRequestHuman(message)}
802
+ style={({ pressed }) => [styles.handoff, pressed && { backgroundColor: theme.bg2 }]}
803
+ >
804
+ <PersonIcon color={theme.fg} size={14} />
805
+ <Text style={styles.handoffText}>{strings.talkToPerson}</Text>
806
+ </Pressable>
807
+ ) : null}
808
+
809
+ {last && message ? <Text style={styles.meta}>{local ? strings.sending : clockTime(message.created_at)}</Text> : null}
810
+ </View>
811
+ );
812
+ }
813
+
814
+ /**
815
+ * What a bubble shows: the visitor's own text as typed; stored replies from their HTML (links
816
+ * included), falling back to markdown in the text; AI answers still streaming as markdown. An AI
817
+ * answer's trailing list of links becomes its sources.
818
+ */
819
+ function bodyOf(message: Message | undefined, reply: AIReply | undefined, mine: boolean, ai: boolean): { blocks: Block[]; sources: Source[] } {
820
+ if (mine) {
821
+ return { blocks: [], sources: [] };
822
+ }
823
+ if (reply && !message) {
824
+ return { blocks: parseMarkdown(reply.text, true), sources: [] };
825
+ }
826
+ const blocks = message?.body_html?.trim() ? parseHtml(message.body_html, ai) : parseMarkdown(message?.body_text ?? '');
827
+
828
+ return ai ? extractSources(blocks) : { blocks, sources: [] };
829
+ }
830
+
831
+ function ActionCard({
832
+ ui,
833
+ message,
834
+ onDecide,
835
+ }: {
836
+ ui: Ui;
837
+ message: Message;
838
+ onDecide: (message: Message, decision: 'confirm' | 'decline') => void;
839
+ }) {
840
+ const { theme, strings, styles } = ui;
841
+ const action = message.ai_action!;
842
+ const pending = action.status === 'pending';
843
+ const label = {
844
+ pending: strings.actionPending,
845
+ confirmed: strings.actionConfirmed,
846
+ declined: strings.actionDeclined,
847
+ expired: strings.actionExpired,
848
+ failed: strings.actionFailed,
849
+ }[action.status];
850
+
851
+ return (
852
+ <View style={[styles.action, pending && styles.actionPending]}>
853
+ <View style={styles.actionTop}>
854
+ <View style={styles.actionTool}>
855
+ <SparkIcon color={theme.ai} size={8} />
856
+ <Text style={styles.actionToolText} numberOfLines={1}>
857
+ {action.tool.replace(/_/g, ' ')}
858
+ </Text>
859
+ </View>
860
+ <Text style={[styles.actionNeed, action.status === 'failed' && { color: theme.danger }]}>{label}</Text>
861
+ </View>
862
+ <Text style={styles.actionTitle}>{action.title}</Text>
863
+ {action.summary ? <Text style={styles.actionSum}>{action.summary}</Text> : null}
864
+ {pending ? (
865
+ <View style={styles.actionBtns}>
866
+ <Pressable onPress={() => onDecide(message, 'decline')} style={[styles.btn, styles.btnGhost]}>
867
+ <Text style={styles.btnGhostText}>{strings.cancel}</Text>
868
+ </Pressable>
869
+ <Pressable onPress={() => onDecide(message, 'confirm')} style={[styles.btn, styles.btnSolid]}>
870
+ <Text style={styles.btnSolidText}>{strings.confirm}</Text>
871
+ </Pressable>
872
+ </View>
873
+ ) : null}
874
+ </View>
875
+ );
876
+ }
877
+
878
+ /** An attached image shown full size over the thread, so it opens in the chat instead of a browser. */
879
+ function ImageViewer({ ui, attachment, onClose }: { ui: Ui; attachment: Attachment; onClose: () => void }) {
880
+ const { strings, styles } = ui;
881
+
882
+ useEffect(() => {
883
+ if (Platform.OS !== 'android') {
884
+ return;
885
+ }
886
+ const subscription = BackHandler.addEventListener('hardwareBackPress', () => {
887
+ onClose();
888
+
889
+ return true;
890
+ });
891
+
892
+ return () => subscription.remove();
893
+ }, [onClose]);
894
+
895
+ return (
896
+ <View style={styles.viewer} accessibilityViewIsModal>
897
+ <View style={styles.viewerHead}>
898
+ <Text style={styles.viewerName} numberOfLines={1}>
899
+ {attachment.filename}
900
+ </Text>
901
+ <Pressable
902
+ accessibilityRole="button"
903
+ accessibilityLabel={strings.closeImage}
904
+ onPress={onClose}
905
+ hitSlop={6}
906
+ style={({ pressed }) => [styles.iconBtn, pressed && styles.viewerPressed]}
907
+ >
908
+ <CloseIcon color="#ffffff" />
909
+ </Pressable>
910
+ </View>
911
+ <ScrollView
912
+ style={styles.flex}
913
+ contentContainerStyle={styles.viewerBody}
914
+ maximumZoomScale={4}
915
+ minimumZoomScale={1}
916
+ centerContent
917
+ showsHorizontalScrollIndicator={false}
918
+ showsVerticalScrollIndicator={false}
919
+ >
920
+ <Image source={{ uri: attachment.url }} style={styles.viewerImage} resizeMode="contain" accessibilityLabel={attachment.filename} />
921
+ </ScrollView>
922
+ </View>
923
+ );
924
+ }
925
+
926
+ function IconButton({
927
+ ui,
928
+ label,
929
+ onPress,
930
+ icon,
931
+ disabled,
932
+ }: {
933
+ ui: Ui;
934
+ label: string;
935
+ onPress: () => void;
936
+ icon: React.ReactNode;
937
+ disabled?: boolean;
938
+ }) {
939
+ return (
940
+ <Pressable
941
+ accessibilityRole="button"
942
+ accessibilityLabel={label}
943
+ onPress={onPress}
944
+ disabled={disabled}
945
+ hitSlop={6}
946
+ style={({ pressed }) => [ui.styles.iconBtn, pressed && { backgroundColor: ui.theme.bg2 }, disabled && ui.styles.disabled]}
947
+ >
948
+ {icon}
949
+ </Pressable>
950
+ );
951
+ }
952
+
953
+ function Glyph({ ui, name, url }: { ui: Ui; name: string; url: string | null }) {
954
+ const { styles } = ui;
955
+
956
+ return url ? (
957
+ <Image source={{ uri: url }} style={styles.wsGlyph} />
958
+ ) : (
959
+ <View style={styles.wsGlyph}>
960
+ <Text style={styles.wsGlyphText}>{name.trim().charAt(0).toUpperCase()}</Text>
961
+ </View>
962
+ );
963
+ }
964
+
965
+ function Avatar({
966
+ ui,
967
+ size,
968
+ name,
969
+ url,
970
+ round,
971
+ tint,
972
+ style,
973
+ }: {
974
+ ui: Ui;
975
+ size: number;
976
+ name: string;
977
+ url: string | null | undefined;
978
+ round?: boolean;
979
+ tint: string;
980
+ style?: StyleProp<ViewStyle>;
981
+ }) {
982
+ const radius = round ? size / 2 : Math.min(ui.theme.radius.av, size / 2);
983
+
984
+ return (
985
+ <View
986
+ style={[
987
+ { width: size, height: size, borderRadius: radius, backgroundColor: tint, overflow: 'hidden', alignItems: 'center', justifyContent: 'center' },
988
+ style,
989
+ ]}
990
+ >
991
+ {url ? (
992
+ <Image source={{ uri: url }} style={{ width: size, height: size }} />
993
+ ) : (
994
+ <Text style={{ fontSize: size * 0.4, fontWeight: '600', color: ui.theme.fg2 }}>{initials(name)}</Text>
995
+ )}
996
+ </View>
997
+ );
998
+ }
999
+
1000
+ function AvatarStack({ ui, config }: { ui: Ui; config: WidgetConfig | null }) {
1001
+ const { theme } = ui;
1002
+ const ring = { borderWidth: 2, borderColor: theme.headBg };
1003
+ const team = (config?.team ?? []).slice(0, 3);
1004
+ const bot = config?.ai_answers ? config.bot : null;
1005
+ if (!bot && team.length === 0) {
1006
+ return null;
1007
+ }
1008
+
1009
+ return (
1010
+ <View style={{ flexDirection: 'row', alignItems: 'center' }}>
1011
+ {bot ? <BotFace size={22} look={bot.look} avatarUrl={bot.avatar_url} /> : null}
1012
+ {team.map((member, index) => (
1013
+ <Avatar
1014
+ key={`${member.name}-${index}`}
1015
+ ui={ui}
1016
+ size={22}
1017
+ name={member.name}
1018
+ url={member.avatar_url}
1019
+ tint={theme.bg2}
1020
+ style={[ring, { marginLeft: index === 0 && bot ? 1 : -7 }]}
1021
+ />
1022
+ ))}
1023
+ </View>
1024
+ );
1025
+ }
1026
+
1027
+ /** Three dots that pulse in turn, like the widget's typing indicator. */
1028
+ function Dots({ color }: { color: string }) {
1029
+ const values = useRef([0, 1, 2].map(() => new Animated.Value(0.3))).current;
1030
+
1031
+ useEffect(() => {
1032
+ const native = Platform.OS !== 'web';
1033
+ const loop = Animated.loop(
1034
+ Animated.stagger(
1035
+ 160,
1036
+ values.map((value) =>
1037
+ Animated.sequence([
1038
+ Animated.timing(value, { toValue: 1, duration: 280, useNativeDriver: native }),
1039
+ Animated.timing(value, { toValue: 0.3, duration: 280, useNativeDriver: native }),
1040
+ ]),
1041
+ ),
1042
+ ),
1043
+ );
1044
+ loop.start();
1045
+
1046
+ return () => loop.stop();
1047
+ }, [values]);
1048
+
1049
+ return (
1050
+ <View style={{ flexDirection: 'row', alignItems: 'center', height: 19.5, gap: 3 }}>
1051
+ {values.map((opacity, index) => (
1052
+ <Animated.View key={index} style={{ width: 5, height: 5, borderRadius: 3, backgroundColor: color, opacity }} />
1053
+ ))}
1054
+ </View>
1055
+ );
1056
+ }
1057
+
1058
+ /** Newest first for the inverted list: typing, streaming answers, then messages grouped by author with day labels. */
1059
+ function buildRows(messages: Message[], aiReplies: AIReply[], typing: string | null, handedOff: boolean, strings: ChatStrings): Row[] {
1060
+ const rows: Row[] = [];
1061
+ const authorKey = (m: Message) =>
1062
+ m.direction === 'inbound' ? 'me' : `${m.author.type}:${m.author.user?.id ?? m.author.bot?.id ?? ''}:${m.ai ? 'ai' : ''}`;
1063
+
1064
+ messages.forEach((message, index) => {
1065
+ const previous = messages[index - 1];
1066
+ const next = messages[index + 1];
1067
+ const day = dayLabel(message.created_at, strings);
1068
+ const newDay = !previous || dayLabel(previous.created_at, strings) !== day;
1069
+ if (newDay) {
1070
+ rows.push({ kind: 'day', key: `day-${day}`, label: day });
1071
+ }
1072
+ const startsGroup =
1073
+ newDay || authorKey(previous!) !== authorKey(message) || Date.parse(message.created_at) - Date.parse(previous!.created_at) > GROUP_GAP_MS;
1074
+ const endsGroup =
1075
+ !next ||
1076
+ authorKey(next) !== authorKey(message) ||
1077
+ Date.parse(next.created_at) - Date.parse(message.created_at) > GROUP_GAP_MS ||
1078
+ dayLabel(next.created_at, strings) !== day;
1079
+ rows.push({
1080
+ kind: 'message',
1081
+ key: message.client_id ?? message.id,
1082
+ message,
1083
+ first: startsGroup,
1084
+ last: endsGroup,
1085
+ gap: startsGroup && !newDay,
1086
+ });
1087
+ });
1088
+
1089
+ if (handedOff) {
1090
+ rows.push({ kind: 'handed', key: 'handed' });
1091
+ }
1092
+ aiReplies.forEach((reply) => rows.push({ kind: 'reply', key: `ai-${reply.id}`, reply }));
1093
+ if (typing) {
1094
+ rows.push({ kind: 'typing', key: 'typing', name: typing });
1095
+ }
1096
+
1097
+ return rows.reverse();
1098
+ }
1099
+
1100
+ function lastIndexWhere<T>(items: T[], predicate: (item: T) => boolean): number {
1101
+ for (let i = items.length - 1; i >= 0; i--) {
1102
+ if (predicate(items[i]!)) {
1103
+ return i;
1104
+ }
1105
+ }
1106
+
1107
+ return -1;
1108
+ }
1109
+
1110
+ function initials(name: string): string {
1111
+ return name
1112
+ .split(/\s+/)
1113
+ .filter(Boolean)
1114
+ .slice(0, 2)
1115
+ .map((part) => part.charAt(0).toUpperCase())
1116
+ .join('');
1117
+ }
1118
+
1119
+ const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
1120
+
1121
+ function dayLabel(iso: string, strings: ChatStrings): string {
1122
+ const date = new Date(iso);
1123
+ const today = new Date();
1124
+ const yesterday = new Date(today.getTime() - 86400000);
1125
+ if (date.toDateString() === today.toDateString()) {
1126
+ return strings.today;
1127
+ }
1128
+ if (date.toDateString() === yesterday.toDateString()) {
1129
+ return strings.yesterday;
1130
+ }
1131
+
1132
+ return `${months[date.getMonth()]} ${date.getDate()}`;
1133
+ }
1134
+
1135
+ function clockTime(iso: string): string {
1136
+ const date = new Date(iso);
1137
+ const hours = date.getHours();
1138
+
1139
+ return `${hours % 12 || 12}:${String(date.getMinutes()).padStart(2, '0')} ${hours < 12 ? 'AM' : 'PM'}`;
1140
+ }
1141
+
1142
+ function relativeTime(iso: string): string {
1143
+ const seconds = Math.round((Date.now() - Date.parse(iso)) / 1000);
1144
+ if (!Number.isFinite(seconds)) {
1145
+ return '';
1146
+ }
1147
+ if (seconds < 45) {
1148
+ return 'now';
1149
+ }
1150
+ const minutes = Math.round(seconds / 60);
1151
+ if (minutes < 60) {
1152
+ return `${minutes}m ago`;
1153
+ }
1154
+ const hours = Math.round(minutes / 60);
1155
+ if (hours < 24) {
1156
+ return `${hours}h ago`;
1157
+ }
1158
+ const days = Math.round(hours / 24);
1159
+ if (days < 7) {
1160
+ return `${days}d ago`;
1161
+ }
1162
+ const date = new Date(iso);
1163
+
1164
+ return `${months[date.getMonth()]} ${date.getDate()}`;
1165
+ }
1166
+
1167
+ function fileSize(bytes: number): string {
1168
+ if (bytes < 1024) {
1169
+ return `${bytes} B`;
1170
+ }
1171
+ if (bytes < 1024 * 1024) {
1172
+ return `${Math.round(bytes / 1024)} KB`;
1173
+ }
1174
+
1175
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
1176
+ }
1177
+
1178
+ type Styles = ReturnType<typeof createStyles>;
1179
+
1180
+ function createStyles(t: ChatTheme) {
1181
+ const r = t.radius;
1182
+
1183
+ return StyleSheet.create({
1184
+ root: { flex: 1, backgroundColor: t.bg },
1185
+ flex: { flex: 1 },
1186
+ bold: { fontWeight: '600' },
1187
+ pressed: { opacity: 0.92, transform: [{ scale: 0.985 }] },
1188
+ disabled: { opacity: 0.4 },
1189
+
1190
+ head: {
1191
+ flexDirection: 'row',
1192
+ alignItems: 'center',
1193
+ gap: 10,
1194
+ paddingHorizontal: 12,
1195
+ paddingTop: 18,
1196
+ paddingBottom: 14,
1197
+ borderBottomWidth: 1,
1198
+ borderBottomColor: t.line,
1199
+ backgroundColor: t.bg,
1200
+ },
1201
+ headHome: { flexDirection: 'column', alignItems: 'stretch', gap: 0, padding: 16, paddingTop: 22, paddingBottom: 20, backgroundColor: t.headBg },
1202
+ headRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 8 },
1203
+ headMain: { flex: 1, minWidth: 0 },
1204
+ ws: { flexDirection: 'row', alignItems: 'center', gap: 9, flexShrink: 1 },
1205
+ wsGlyph: {
1206
+ width: 26,
1207
+ height: 26,
1208
+ borderRadius: Math.min(r.av, 13),
1209
+ backgroundColor: t.accent,
1210
+ alignItems: 'center',
1211
+ justifyContent: 'center',
1212
+ overflow: 'hidden',
1213
+ },
1214
+ wsGlyphText: { color: t.accentFg, fontSize: 12, fontWeight: '700' },
1215
+ wsName: { fontSize: 13.5, fontWeight: '600', letterSpacing: -0.14, color: t.fg, flexShrink: 1 },
1216
+ greet: { marginTop: 18, fontSize: 22, lineHeight: 28, fontWeight: '600', letterSpacing: -0.55, color: t.fg },
1217
+ subRow: { marginTop: 8, flexDirection: 'row', alignItems: 'center', gap: 8 },
1218
+ sub: { flex: 1, color: t.fg3, fontSize: 13, lineHeight: 18 },
1219
+ title: { fontSize: 14, fontWeight: '600', letterSpacing: -0.14, color: t.fg },
1220
+ titleSub: { fontSize: 12, color: t.fg3, marginTop: 1 },
1221
+ iconBtn: { width: 30, height: 30, borderRadius: Math.min(r.ctl, 15), alignItems: 'center', justifyContent: 'center' },
1222
+
1223
+ homeBody: { padding: 16, gap: 12 },
1224
+ card: { borderWidth: 1, borderColor: t.line, borderRadius: r.card, backgroundColor: t.bg, overflow: 'hidden' },
1225
+ cardHead: {
1226
+ paddingHorizontal: 14,
1227
+ paddingTop: 12,
1228
+ paddingBottom: 6,
1229
+ fontSize: 11,
1230
+ fontWeight: '600',
1231
+ letterSpacing: 0.66,
1232
+ textTransform: 'uppercase',
1233
+ color: t.fg4,
1234
+ },
1235
+ conv: { flexDirection: 'row', alignItems: 'flex-start', gap: 10, paddingVertical: 10, paddingHorizontal: 14 },
1236
+ convBorder: { borderTopWidth: 1, borderTopColor: t.line },
1237
+ dot: { width: 7, height: 7, borderRadius: 4, marginTop: 6, backgroundColor: t.line2 },
1238
+ convMain: { flex: 1, minWidth: 0 },
1239
+ convTop: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', gap: 8 },
1240
+ convSubject: { flex: 1, fontSize: 13, fontWeight: '500', color: t.fg },
1241
+ convPrev: { flex: 1, fontSize: 12.5, color: t.fg3, marginTop: 1 },
1242
+ time: { fontFamily: t.mono, fontSize: 10.5, color: t.fg4 },
1243
+ unread: { minWidth: 18, height: 18, borderRadius: 9, paddingHorizontal: 5, backgroundColor: t.danger, alignItems: 'center', justifyContent: 'center' },
1244
+ unreadText: { color: '#ffffff', fontSize: 10.5, fontWeight: '600' },
1245
+ primary: {
1246
+ height: 44,
1247
+ borderRadius: r.ctl,
1248
+ backgroundColor: t.accent,
1249
+ flexDirection: 'row',
1250
+ alignItems: 'center',
1251
+ justifyContent: 'space-between',
1252
+ paddingHorizontal: 16,
1253
+ shadowColor: '#000000',
1254
+ shadowOpacity: 0.08,
1255
+ shadowRadius: 2,
1256
+ shadowOffset: { width: 0, height: 1 },
1257
+ elevation: 1,
1258
+ },
1259
+ primaryText: { color: t.accentFg, fontSize: 13.5, fontWeight: '500' },
1260
+ linkRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingVertical: 12, paddingHorizontal: 14 },
1261
+ linkText: { fontSize: 13, fontWeight: '500', color: t.fg },
1262
+ away: { borderWidth: 1, borderColor: t.line, borderRadius: r.card, backgroundColor: t.bg3, padding: 12 },
1263
+ awayText: { fontSize: 12.5, lineHeight: 18, color: t.fg2 },
1264
+ foot: { height: 30, alignItems: 'center', justifyContent: 'center', borderTopWidth: 1, borderTopColor: t.line },
1265
+ footText: { fontSize: 11, color: t.fg4 },
1266
+ skeletonCard: { paddingVertical: 4 },
1267
+ skel: { height: 12, borderRadius: 4, marginVertical: 10, marginHorizontal: 14, backgroundColor: t.bg2 },
1268
+
1269
+ emptyScroll: { flexGrow: 1 },
1270
+ empty: { paddingVertical: 32, paddingHorizontal: 24, alignItems: 'center' },
1271
+ emptyTitle: { fontSize: 14, fontWeight: '600', color: t.fg, marginBottom: 4, textAlign: 'center' },
1272
+ emptyText: { fontSize: 13, lineHeight: 19, color: t.fg3, textAlign: 'center' },
1273
+ picker: { margin: 14 },
1274
+ pickerTitle: { paddingHorizontal: 14, paddingTop: 14, fontSize: 14, fontWeight: '600', color: t.fg },
1275
+ pickerHint: { paddingHorizontal: 14, marginTop: 2, marginBottom: 12, fontSize: 12.5, lineHeight: 18, color: t.fg3 },
1276
+ topic: {
1277
+ minHeight: 42,
1278
+ flexDirection: 'row',
1279
+ alignItems: 'center',
1280
+ justifyContent: 'space-between',
1281
+ gap: 10,
1282
+ paddingVertical: 10,
1283
+ paddingHorizontal: 14,
1284
+ },
1285
+ topicText: { flex: 1, fontSize: 13.5, lineHeight: 19, fontWeight: '500', color: t.fg },
1286
+
1287
+ thread: { paddingTop: 16, paddingHorizontal: 14, paddingBottom: 8 },
1288
+ day: { alignSelf: 'center', fontFamily: t.mono, fontSize: 10.5, color: t.fg4, marginTop: 10, marginBottom: 6 },
1289
+ row: { maxWidth: '84%', marginTop: 2 },
1290
+ rowMe: { alignSelf: 'flex-end', alignItems: 'flex-end' },
1291
+ rowThem: { alignSelf: 'flex-start', alignItems: 'flex-start' },
1292
+ rowGap: { marginTop: 12 },
1293
+ who: { flexDirection: 'row', alignItems: 'center', gap: 6, marginHorizontal: 2, marginBottom: 4 },
1294
+ whoName: { fontSize: 11.5, fontWeight: '500', color: t.fg2 },
1295
+ aiPill: { height: 16, paddingHorizontal: 5, borderRadius: 4, backgroundColor: t.aiSoft, justifyContent: 'center' },
1296
+ aiPillText: { color: t.ai, fontSize: 9.5, fontWeight: '600', letterSpacing: 0.38 },
1297
+ msg: { paddingVertical: 8, paddingHorizontal: 12, maxWidth: '100%', borderRadius: r.bubble },
1298
+ msgMe: { backgroundColor: t.accent },
1299
+ msgThem: { backgroundColor: t.bg2 },
1300
+ msgAi: { backgroundColor: t.bg3, borderWidth: 1, borderColor: t.line },
1301
+ sources: { flexDirection: 'row', flexWrap: 'wrap', gap: 6, marginTop: 6 },
1302
+ src: {
1303
+ flexDirection: 'row',
1304
+ alignItems: 'center',
1305
+ gap: 6,
1306
+ maxWidth: 240,
1307
+ height: 26,
1308
+ paddingLeft: 8,
1309
+ paddingRight: 10,
1310
+ borderRadius: r.ctl,
1311
+ borderWidth: 1,
1312
+ borderColor: t.line,
1313
+ backgroundColor: t.bg,
1314
+ },
1315
+ srcText: { flexShrink: 1, fontSize: 12, color: t.fg2 },
1316
+ msgText: { fontSize: 13.5, lineHeight: 19.5, color: t.fg },
1317
+ sendingMsg: { opacity: 0.7 },
1318
+ meta: { fontFamily: t.mono, fontSize: 10, color: t.fg4, marginTop: 3, marginHorizontal: 4 },
1319
+ tools: { gap: 4, marginBottom: 6 },
1320
+ tool: { flexDirection: 'row', alignItems: 'center', gap: 7 },
1321
+ toolIc: { width: 16, height: 16, borderRadius: 8, backgroundColor: t.aiSoft, alignItems: 'center', justifyContent: 'center' },
1322
+ toolText: { fontSize: 12, lineHeight: 18, color: t.fg3 },
1323
+ fileGap: { marginTop: 4 },
1324
+ thumb: { width: 220, height: 160, borderRadius: r.card, borderWidth: 1, borderColor: t.line, backgroundColor: t.bg2 },
1325
+ viewer: { position: 'absolute', top: 0, right: 0, bottom: 0, left: 0, zIndex: 10, elevation: 10, backgroundColor: '#000000' },
1326
+ viewerHead: { flexDirection: 'row', alignItems: 'center', gap: 10, paddingHorizontal: 12, paddingTop: 18, paddingBottom: 14 },
1327
+ viewerName: { flex: 1, fontSize: 14, fontWeight: '600', color: '#ffffff' },
1328
+ viewerPressed: { backgroundColor: 'rgba(255,255,255,0.16)' },
1329
+ viewerBody: { flexGrow: 1 },
1330
+ viewerImage: { flex: 1, width: '100%' },
1331
+ chip: {
1332
+ flexDirection: 'row',
1333
+ alignItems: 'center',
1334
+ gap: 6,
1335
+ maxWidth: 240,
1336
+ height: 30,
1337
+ paddingHorizontal: 10,
1338
+ borderRadius: r.ctl,
1339
+ borderWidth: 1,
1340
+ borderColor: t.line,
1341
+ backgroundColor: t.bg,
1342
+ },
1343
+ chipText: { flexShrink: 1, fontSize: 12, color: t.fg },
1344
+ chipSize: { fontFamily: t.mono, fontSize: 10.5, color: t.fg4 },
1345
+ chipSpinner: { transform: [{ scale: 0.6 }], width: 14, height: 14 },
1346
+ handoff: {
1347
+ marginTop: 6,
1348
+ flexDirection: 'row',
1349
+ alignItems: 'center',
1350
+ gap: 6,
1351
+ height: 30,
1352
+ paddingHorizontal: 11,
1353
+ borderRadius: r.ctl,
1354
+ borderWidth: 1,
1355
+ borderColor: t.line2,
1356
+ backgroundColor: t.bg,
1357
+ },
1358
+ handoffText: { fontSize: 12.5, fontWeight: '500', color: t.fg },
1359
+ handed: {
1360
+ marginTop: 6,
1361
+ marginBottom: 14,
1362
+ flexDirection: 'row',
1363
+ alignItems: 'flex-start',
1364
+ gap: 10,
1365
+ paddingVertical: 11,
1366
+ paddingHorizontal: 12,
1367
+ borderRadius: r.card,
1368
+ backgroundColor: t.bg3,
1369
+ borderWidth: 1,
1370
+ borderColor: t.line,
1371
+ },
1372
+ handedIc: {
1373
+ width: 28,
1374
+ height: 28,
1375
+ borderRadius: Math.min(r.av, 14),
1376
+ backgroundColor: t.bg,
1377
+ borderWidth: 1,
1378
+ borderColor: t.line,
1379
+ alignItems: 'center',
1380
+ justifyContent: 'center',
1381
+ },
1382
+ handedTitle: { fontSize: 13, lineHeight: 18, fontWeight: '600', color: t.fg },
1383
+ handedText: { fontSize: 12, lineHeight: 17, color: t.fg3 },
1384
+ typing: { alignSelf: 'flex-start', flexDirection: 'row', alignItems: 'center', gap: 8, paddingTop: 10, paddingBottom: 4 },
1385
+ typingBubble: {
1386
+ paddingHorizontal: 12,
1387
+ height: 36,
1388
+ justifyContent: 'center',
1389
+ borderRadius: r.bubble,
1390
+ borderBottomLeftRadius: 4,
1391
+ backgroundColor: t.bg2,
1392
+ },
1393
+ typingText: { fontSize: 12, color: t.fg3 },
1394
+ action: {
1395
+ marginTop: 6,
1396
+ width: 272,
1397
+ maxWidth: '100%',
1398
+ paddingTop: 11,
1399
+ paddingHorizontal: 12,
1400
+ paddingBottom: 12,
1401
+ borderRadius: r.card,
1402
+ borderWidth: 1,
1403
+ borderColor: t.line,
1404
+ backgroundColor: t.bg,
1405
+ },
1406
+ actionPending: { borderColor: t.aiLine },
1407
+ actionTop: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 8, minHeight: 20 },
1408
+ actionTool: { flexDirection: 'row', alignItems: 'center', gap: 5, flexShrink: 1 },
1409
+ actionToolText: { fontSize: 11.5, fontWeight: '500', color: t.ai },
1410
+ actionNeed: { fontSize: 11, color: t.fg4 },
1411
+ actionTitle: { marginTop: 6, fontSize: 13.5, lineHeight: 19, fontWeight: '600', letterSpacing: -0.14, color: t.fg },
1412
+ actionSum: { marginTop: 2, fontSize: 12.5, lineHeight: 18, color: t.fg2 },
1413
+ actionBtns: { flexDirection: 'row', justifyContent: 'flex-end', gap: 6, marginTop: 10 },
1414
+ btn: { height: 30, paddingHorizontal: 12, borderRadius: r.ctl, alignItems: 'center', justifyContent: 'center' },
1415
+ btnSolid: { backgroundColor: t.accent },
1416
+ btnSolidText: { color: t.accentFg, fontSize: 12.5, fontWeight: '500' },
1417
+ btnGhost: { borderWidth: 1, borderColor: t.line2 },
1418
+ btnGhostText: { color: t.fg, fontSize: 12.5, fontWeight: '500' },
1419
+
1420
+ composer: { paddingTop: 10, paddingHorizontal: 12, paddingBottom: 12, gap: 6, backgroundColor: t.bg },
1421
+ topicRow: { alignItems: 'center' },
1422
+ topicChip: {
1423
+ flexDirection: 'row',
1424
+ alignItems: 'center',
1425
+ gap: 4,
1426
+ height: 26,
1427
+ paddingLeft: 10,
1428
+ paddingRight: 4,
1429
+ borderRadius: 999,
1430
+ backgroundColor: t.bg2,
1431
+ borderWidth: 1,
1432
+ borderColor: t.line,
1433
+ },
1434
+ topicChipText: { fontSize: 12, fontWeight: '500', color: t.fg },
1435
+ topicChipX: { width: 20, height: 20, borderRadius: 10, alignItems: 'center', justifyContent: 'center' },
1436
+ pendingFiles: { flexDirection: 'row', flexWrap: 'wrap', gap: 6 },
1437
+ pendingChip: { height: 26 },
1438
+ err: { color: t.danger, fontSize: 12 },
1439
+ cbar: {
1440
+ flexDirection: 'row',
1441
+ alignItems: 'flex-end',
1442
+ gap: 4,
1443
+ padding: 5,
1444
+ paddingLeft: 12,
1445
+ borderWidth: 1,
1446
+ borderColor: t.line,
1447
+ borderRadius: r.bar,
1448
+ backgroundColor: t.bg,
1449
+ },
1450
+ cbarLead: { paddingLeft: 5 },
1451
+ cbarFocus: { borderColor: t.fg4 },
1452
+ cbarOff: { backgroundColor: t.bg3 },
1453
+ input: {
1454
+ flex: 1,
1455
+ minHeight: 30,
1456
+ maxHeight: 120,
1457
+ paddingVertical: 5,
1458
+ paddingHorizontal: 0,
1459
+ fontSize: 14,
1460
+ lineHeight: 20,
1461
+ color: t.fg,
1462
+ ...(Platform.OS === 'web' ? ({ outlineStyle: 'none' } as object) : {}),
1463
+ },
1464
+ send: {
1465
+ width: 30,
1466
+ height: 30,
1467
+ borderRadius: Math.min(r.ctl, 15),
1468
+ alignItems: 'center',
1469
+ justifyContent: 'center',
1470
+ backgroundColor: t.accent,
1471
+ opacity: 0.28,
1472
+ transform: [{ scale: 0.9 }],
1473
+ },
1474
+ sendReady: { opacity: 1, transform: [{ scale: 1 }] },
1475
+ });
1476
+ }