opendesk-react-native 0.1.3 → 0.1.6

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