@appsupportkit/support-sdk 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/lib/commonjs/components/BottomSheet.js +67 -47
  2. package/lib/commonjs/components/BottomSheet.js.map +1 -1
  3. package/lib/commonjs/components/LauncherButton.js +177 -0
  4. package/lib/commonjs/components/LauncherButton.js.map +1 -0
  5. package/lib/commonjs/components/SupportButton.js +54 -176
  6. package/lib/commonjs/components/SupportButton.js.map +1 -1
  7. package/lib/commonjs/components/icons.js +74 -23
  8. package/lib/commonjs/components/icons.js.map +1 -1
  9. package/lib/commonjs/components/primitives.js +183 -38
  10. package/lib/commonjs/components/primitives.js.map +1 -1
  11. package/lib/commonjs/components/screens/ComposeScreen.js +115 -58
  12. package/lib/commonjs/components/screens/ComposeScreen.js.map +1 -1
  13. package/lib/commonjs/components/screens/HomeScreen.js +170 -115
  14. package/lib/commonjs/components/screens/HomeScreen.js.map +1 -1
  15. package/lib/commonjs/components/screens/ThreadScreen.js +222 -108
  16. package/lib/commonjs/components/screens/ThreadScreen.js.map +1 -1
  17. package/lib/commonjs/index.js.map +1 -1
  18. package/lib/commonjs/theme.js +136 -17
  19. package/lib/commonjs/theme.js.map +1 -1
  20. package/lib/commonjs/types.js +7 -0
  21. package/lib/commonjs/types.js.map +1 -1
  22. package/lib/commonjs/unread.js +92 -0
  23. package/lib/commonjs/unread.js.map +1 -0
  24. package/lib/commonjs/useSupportThreads.js +286 -0
  25. package/lib/commonjs/useSupportThreads.js.map +1 -0
  26. package/lib/commonjs/utils.js +33 -0
  27. package/lib/commonjs/utils.js.map +1 -1
  28. package/lib/module/components/BottomSheet.js +68 -48
  29. package/lib/module/components/BottomSheet.js.map +1 -1
  30. package/lib/module/components/LauncherButton.js +172 -0
  31. package/lib/module/components/LauncherButton.js.map +1 -0
  32. package/lib/module/components/SupportButton.js +57 -179
  33. package/lib/module/components/SupportButton.js.map +1 -1
  34. package/lib/module/components/icons.js +72 -22
  35. package/lib/module/components/icons.js.map +1 -1
  36. package/lib/module/components/primitives.js +179 -39
  37. package/lib/module/components/primitives.js.map +1 -1
  38. package/lib/module/components/screens/ComposeScreen.js +118 -61
  39. package/lib/module/components/screens/ComposeScreen.js.map +1 -1
  40. package/lib/module/components/screens/HomeScreen.js +172 -117
  41. package/lib/module/components/screens/HomeScreen.js.map +1 -1
  42. package/lib/module/components/screens/ThreadScreen.js +228 -112
  43. package/lib/module/components/screens/ThreadScreen.js.map +1 -1
  44. package/lib/module/index.js.map +1 -1
  45. package/lib/module/theme.js +135 -16
  46. package/lib/module/theme.js.map +1 -1
  47. package/lib/module/types.js +7 -0
  48. package/lib/module/types.js.map +1 -1
  49. package/lib/module/unread.js +83 -0
  50. package/lib/module/unread.js.map +1 -0
  51. package/lib/module/useSupportThreads.js +281 -0
  52. package/lib/module/useSupportThreads.js.map +1 -0
  53. package/lib/module/utils.js +30 -0
  54. package/lib/module/utils.js.map +1 -1
  55. package/lib/typescript/components/BottomSheet.d.ts.map +1 -1
  56. package/lib/typescript/components/LauncherButton.d.ts +14 -0
  57. package/lib/typescript/components/LauncherButton.d.ts.map +1 -0
  58. package/lib/typescript/components/SupportButton.d.ts +3 -3
  59. package/lib/typescript/components/SupportButton.d.ts.map +1 -1
  60. package/lib/typescript/components/icons.d.ts +13 -2
  61. package/lib/typescript/components/icons.d.ts.map +1 -1
  62. package/lib/typescript/components/primitives.d.ts +35 -4
  63. package/lib/typescript/components/primitives.d.ts.map +1 -1
  64. package/lib/typescript/components/screens/ComposeScreen.d.ts.map +1 -1
  65. package/lib/typescript/components/screens/HomeScreen.d.ts +3 -2
  66. package/lib/typescript/components/screens/HomeScreen.d.ts.map +1 -1
  67. package/lib/typescript/components/screens/ThreadScreen.d.ts.map +1 -1
  68. package/lib/typescript/index.d.ts +1 -1
  69. package/lib/typescript/index.d.ts.map +1 -1
  70. package/lib/typescript/theme.d.ts +44 -3
  71. package/lib/typescript/theme.d.ts.map +1 -1
  72. package/lib/typescript/types.d.ts +22 -0
  73. package/lib/typescript/types.d.ts.map +1 -1
  74. package/lib/typescript/unread.d.ts +22 -0
  75. package/lib/typescript/unread.d.ts.map +1 -0
  76. package/lib/typescript/useSupportThreads.d.ts +28 -0
  77. package/lib/typescript/useSupportThreads.d.ts.map +1 -0
  78. package/lib/typescript/utils.d.ts +5 -0
  79. package/lib/typescript/utils.d.ts.map +1 -1
  80. package/package.json +2 -2
  81. package/src/components/BottomSheet.tsx +61 -43
  82. package/src/components/LauncherButton.tsx +217 -0
  83. package/src/components/SupportButton.tsx +52 -196
  84. package/src/components/icons.tsx +77 -19
  85. package/src/components/primitives.tsx +217 -36
  86. package/src/components/screens/ComposeScreen.tsx +131 -61
  87. package/src/components/screens/HomeScreen.tsx +168 -117
  88. package/src/components/screens/ThreadScreen.tsx +289 -119
  89. package/src/index.tsx +1 -0
  90. package/src/theme.ts +165 -18
  91. package/src/types.ts +24 -0
  92. package/src/unread.ts +91 -0
  93. package/src/useSupportThreads.ts +372 -0
  94. package/src/utils.ts +33 -0
@@ -1,4 +1,4 @@
1
- import { useEffect, useRef, useState } from 'react';
1
+ import { useEffect, useMemo, useRef, useState } from 'react';
2
2
  import {
3
3
  KeyboardAvoidingView,
4
4
  Platform,
@@ -8,18 +8,177 @@ import {
8
8
  View,
9
9
  } from 'react-native';
10
10
  import type { SupportTheme } from '../../theme';
11
- import type { Thread } from '../../types';
11
+ import type { Thread, ThreadMessage } from '../../types';
12
12
  import { USER_MESSAGE_MAX, USER_MESSAGE_MIN } from '../../types';
13
- import { formatDate, relativeTime } from '../../utils';
14
- import { Chevron } from '../icons';
13
+ import { dayLabel, formatDate, formatTime, isSameDay, relativeTime } from '../../utils';
14
+ import { ArrowUpIcon, Chevron } from '../icons';
15
15
  import {
16
- PressableRow,
17
- PrimaryButton,
16
+ IconButton,
18
17
  StatusIndicator,
19
18
  textStyles,
20
19
  TypeChipView,
21
20
  } from '../primitives';
22
21
 
22
+ /** Consecutive messages from one sender inside this window form a group. */
23
+ const GROUP_WINDOW_MS = 5 * 60 * 1000;
24
+ /** The counter only appears once the limit is actually in play. */
25
+ const COUNTER_VISIBLE_FROM = USER_MESSAGE_MAX - 60;
26
+
27
+ interface Row {
28
+ message: ThreadMessage;
29
+ firstInGroup: boolean;
30
+ lastInGroup: boolean;
31
+ /** Date separator to draw above this message, if any. */
32
+ day: string | null;
33
+ /** "New" separator above the first message the user hasn't seen. */
34
+ startsUnread: boolean;
35
+ }
36
+
37
+ function buildRows(messages: ThreadMessage[], unreadCount: number): Row[] {
38
+ // Unread messages are always the newest admin ones, so the divider goes
39
+ // above the Nth-from-last admin message.
40
+ let remaining = unreadCount;
41
+ let firstUnreadId: string | null = null;
42
+ for (let i = messages.length - 1; i >= 0 && remaining > 0; i -= 1) {
43
+ const candidate = messages[i];
44
+ if (candidate === undefined || candidate.sender !== 'admin') continue;
45
+ remaining -= 1;
46
+ firstUnreadId = candidate.id;
47
+ }
48
+
49
+ return messages.map((message, i) => {
50
+ const previous = i > 0 ? (messages[i - 1] ?? null) : null;
51
+ const next = i < messages.length - 1 ? (messages[i + 1] ?? null) : null;
52
+ const withinGroup = (a: ThreadMessage | null, b: ThreadMessage) =>
53
+ a !== null &&
54
+ a.sender === b.sender &&
55
+ a.admin_name === b.admin_name &&
56
+ Math.abs(Date.parse(b.created_at) - Date.parse(a.created_at)) <
57
+ GROUP_WINDOW_MS;
58
+
59
+ const day =
60
+ previous === null || !isSameDay(previous.created_at, message.created_at)
61
+ ? dayLabel(message.created_at)
62
+ : null;
63
+ const startsUnread = message.id === firstUnreadId;
64
+
65
+ return {
66
+ message,
67
+ // A separator always breaks a group.
68
+ firstInGroup: day !== null || startsUnread || !withinGroup(previous, message),
69
+ lastInGroup:
70
+ next === null ||
71
+ next.id === firstUnreadId ||
72
+ !isSameDay(next.created_at, message.created_at) ||
73
+ !withinGroup(next, message),
74
+ day,
75
+ startsUnread,
76
+ };
77
+ });
78
+ }
79
+
80
+ function Separator({
81
+ label,
82
+ theme,
83
+ tone = 'muted',
84
+ }: {
85
+ label: string;
86
+ theme: SupportTheme;
87
+ tone?: 'muted' | 'accent';
88
+ }) {
89
+ const color =
90
+ tone === 'accent' ? theme.colors.accent : theme.colors.textTertiary;
91
+ return (
92
+ <View
93
+ style={{
94
+ flexDirection: 'row',
95
+ alignItems: 'center',
96
+ gap: 10,
97
+ paddingVertical: 6,
98
+ }}
99
+ >
100
+ <View
101
+ style={{
102
+ flex: 1,
103
+ height: 1,
104
+ backgroundColor:
105
+ tone === 'accent' ? theme.colors.accentMuted : theme.colors.border,
106
+ }}
107
+ />
108
+ <Text
109
+ style={{
110
+ fontSize: 11,
111
+ fontWeight: '600',
112
+ letterSpacing: 0.3,
113
+ color,
114
+ }}
115
+ >
116
+ {label}
117
+ </Text>
118
+ <View
119
+ style={{
120
+ flex: 1,
121
+ height: 1,
122
+ backgroundColor:
123
+ tone === 'accent' ? theme.colors.accentMuted : theme.colors.border,
124
+ }}
125
+ />
126
+ </View>
127
+ );
128
+ }
129
+
130
+ function MessageBubble({ row, theme }: { row: Row; theme: SupportTheme }) {
131
+ const { message, firstInGroup, lastInGroup } = row;
132
+ const own = message.sender === 'user';
133
+ const round = theme.radii.bubble;
134
+ const tight = theme.radii.bubbleTail;
135
+
136
+ return (
137
+ <View
138
+ style={{
139
+ maxWidth: '86%',
140
+ alignSelf: own ? 'flex-end' : 'flex-start',
141
+ alignItems: own ? 'flex-end' : 'flex-start',
142
+ marginTop: firstInGroup ? 10 : 2,
143
+ gap: 4,
144
+ }}
145
+ >
146
+ {!own && firstInGroup && (
147
+ <Text style={[textStyles.meta(theme), { paddingHorizontal: 4 }]}>
148
+ {message.admin_name ?? 'Support'}
149
+ </Text>
150
+ )}
151
+ <View
152
+ style={{
153
+ paddingHorizontal: 14,
154
+ paddingVertical: 10,
155
+ backgroundColor: own ? theme.colors.accent : theme.colors.surface,
156
+ borderWidth: own ? 0 : 1,
157
+ borderColor: theme.colors.border,
158
+ borderTopLeftRadius: own || firstInGroup ? round : tight,
159
+ borderBottomLeftRadius: own || lastInGroup ? round : tight,
160
+ borderTopRightRadius: !own || firstInGroup ? round : tight,
161
+ borderBottomRightRadius: !own || lastInGroup ? round : tight,
162
+ }}
163
+ >
164
+ <Text
165
+ style={[
166
+ textStyles.body(theme),
167
+ own && { color: theme.colors.accentForeground },
168
+ ]}
169
+ >
170
+ {message.body}
171
+ </Text>
172
+ </View>
173
+ {lastInGroup && (
174
+ <Text style={[textStyles.meta(theme), { paddingHorizontal: 4 }]}>
175
+ {formatTime(message.created_at)}
176
+ </Text>
177
+ )}
178
+ </View>
179
+ );
180
+ }
181
+
23
182
  export function ThreadScreen({
24
183
  theme,
25
184
  thread,
@@ -36,11 +195,20 @@ export function ThreadScreen({
36
195
  const [sending, setSending] = useState(false);
37
196
  const [failedMessage, setFailedMessage] = useState<string | null>(null);
38
197
  const scrollRef = useRef<ScrollView>(null);
198
+ // Captured once: opening the thread marks it read within a tick, but the
199
+ // "New" divider should stay put while the user is reading.
200
+ const [unreadOnOpen] = useState(thread.unread_count);
39
201
 
40
202
  const resolved = thread.status === 'resolved';
41
203
  const trimmed = draft.trim();
42
204
  const valid =
43
205
  trimmed.length >= USER_MESSAGE_MIN && trimmed.length <= USER_MESSAGE_MAX;
206
+ const tooShort = trimmed.length > 0 && trimmed.length < USER_MESSAGE_MIN;
207
+
208
+ const rows = useMemo(
209
+ () => buildRows(thread.messages, unreadOnOpen),
210
+ [thread.messages, unreadOnOpen],
211
+ );
44
212
 
45
213
  useEffect(() => {
46
214
  scrollRef.current?.scrollToEnd({ animated: false });
@@ -66,21 +234,21 @@ export function ThreadScreen({
66
234
  style={{
67
235
  flexDirection: 'row',
68
236
  alignItems: 'center',
69
- gap: 8,
237
+ gap: 10,
70
238
  paddingHorizontal: 12,
71
- paddingBottom: 10,
239
+ paddingBottom: 12,
72
240
  borderBottomWidth: 1,
73
241
  borderBottomColor: theme.colors.border,
74
242
  }}
75
243
  >
76
- <PressableRow
244
+ <IconButton
77
245
  onPress={onBack}
78
246
  theme={theme}
79
- accessibilityLabel="Back"
80
- style={{ padding: 6, borderRadius: theme.radii.control }}
247
+ accessibilityLabel="Back to conversations"
248
+ size={32}
81
249
  >
82
250
  <Chevron color={theme.colors.textSecondary} direction="left" size={12} />
83
- </PressableRow>
251
+ </IconButton>
84
252
  <TypeChipView type={thread.type} theme={theme} />
85
253
  <StatusIndicator status={thread.status} theme={theme} />
86
254
  <Text style={[textStyles.meta(theme), { marginLeft: 'auto' }]}>
@@ -92,54 +260,30 @@ export function ThreadScreen({
92
260
  <ScrollView
93
261
  ref={scrollRef}
94
262
  style={{ flex: 1 }}
95
- contentContainerStyle={{ padding: 16, gap: 10 }}
263
+ contentContainerStyle={{ paddingHorizontal: 16, paddingVertical: 12 }}
264
+ keyboardShouldPersistTaps="handled"
265
+ onContentSizeChange={() =>
266
+ scrollRef.current?.scrollToEnd({ animated: false })
267
+ }
96
268
  >
97
- {thread.messages.map((m) => {
98
- const own = m.sender === 'user';
99
- return (
100
- <View
101
- key={m.id}
102
- style={{
103
- maxWidth: '85%',
104
- alignSelf: own ? 'flex-end' : 'flex-start',
105
- alignItems: own ? 'flex-end' : 'flex-start',
106
- gap: 3,
107
- }}
108
- >
109
- {!own && (
110
- <Text style={textStyles.meta(theme)}>
111
- {m.admin_name ?? 'Support'}
112
- </Text>
113
- )}
114
- <View
115
- style={{
116
- borderRadius: theme.radii.card,
117
- paddingHorizontal: 12,
118
- paddingVertical: 8,
119
- backgroundColor: own
120
- ? theme.colors.accentMuted
121
- : theme.colors.surface,
122
- borderWidth: own ? 0 : 1,
123
- borderColor: theme.colors.border,
124
- }}
125
- >
126
- <Text style={textStyles.body(theme)}>{m.body}</Text>
127
- </View>
128
- <Text style={textStyles.meta(theme)}>
129
- {relativeTime(m.created_at)}
130
- </Text>
131
- </View>
132
- );
133
- })}
134
-
135
- {resolved && thread.resolved_at && (
136
- <View style={{ flexDirection: 'row', alignItems: 'center', gap: 10, marginVertical: 6 }}>
137
- <View style={{ flex: 1, height: 1, backgroundColor: theme.colors.border }} />
138
- <Text style={textStyles.meta(theme)}>
139
- Resolved by {thread.resolved_by_name ?? 'support'} ·{' '}
140
- {formatDate(thread.resolved_at)}
141
- </Text>
142
- <View style={{ flex: 1, height: 1, backgroundColor: theme.colors.border }} />
269
+ {rows.map((row) => (
270
+ <View key={row.message.id}>
271
+ {row.day !== null && <Separator label={row.day} theme={theme} />}
272
+ {row.startsUnread && (
273
+ <Separator label="New" theme={theme} tone="accent" />
274
+ )}
275
+ <MessageBubble row={row} theme={theme} />
276
+ </View>
277
+ ))}
278
+
279
+ {resolved && thread.resolved_at !== null && (
280
+ <View style={{ marginTop: 16 }}>
281
+ <Separator
282
+ label={`Resolved by ${thread.resolved_by_name ?? 'support'} · ${formatDate(
283
+ thread.resolved_at,
284
+ )}`}
285
+ theme={theme}
286
+ />
143
287
  </View>
144
288
  )}
145
289
  </ScrollView>
@@ -151,74 +295,100 @@ export function ThreadScreen({
151
295
  borderTopColor: theme.colors.border,
152
296
  paddingHorizontal: 16,
153
297
  paddingTop: 10,
298
+ paddingBottom: 4,
154
299
  }}
155
300
  >
156
301
  {resolved ? (
157
- <Text
158
- style={[
159
- textStyles.meta(theme),
160
- { textAlign: 'center', paddingVertical: 8 },
161
- ]}
302
+ <View
303
+ style={{
304
+ alignItems: 'center',
305
+ paddingVertical: 10,
306
+ borderRadius: theme.radii.control,
307
+ backgroundColor: theme.colors.surface,
308
+ }}
162
309
  >
163
- This thread is resolved and read-only
164
- </Text>
310
+ <Text style={textStyles.secondary(theme)}>
311
+ This conversation is resolved and read-only
312
+ </Text>
313
+ </View>
165
314
  ) : (
166
315
  <>
167
- <TextInput
168
- value={draft}
169
- onChangeText={(v) => {
170
- setDraft(v);
171
- setFailedMessage(null);
172
- }}
173
- multiline
174
- placeholder="Reply"
175
- placeholderTextColor={theme.colors.textTertiary}
176
- style={{
177
- minHeight: 60,
178
- maxHeight: 120,
179
- borderWidth: 1,
180
- borderColor: theme.colors.border,
181
- borderRadius: theme.radii.control,
182
- backgroundColor: theme.colors.surface,
183
- color: theme.colors.textPrimary,
184
- fontSize: 14,
185
- lineHeight: 20,
186
- padding: 10,
187
- textAlignVertical: 'top',
188
- }}
189
- />
190
- <View
191
- style={{
192
- flexDirection: 'row',
193
- alignItems: 'center',
194
- gap: 12,
195
- paddingVertical: 8,
196
- }}
197
- >
198
- <Text
199
- style={[
200
- textStyles.meta(theme),
201
- draft.length > USER_MESSAGE_MAX && {
202
- color: theme.colors.statusDanger,
203
- },
204
- ]}
316
+ <View style={{ flexDirection: 'row', alignItems: 'flex-end', gap: 10 }}>
317
+ <TextInput
318
+ value={draft}
319
+ onChangeText={(v) => {
320
+ setDraft(v);
321
+ setFailedMessage(null);
322
+ }}
323
+ multiline
324
+ editable={!sending}
325
+ placeholder="Write a reply…"
326
+ placeholderTextColor={theme.colors.textTertiary}
327
+ style={{
328
+ flex: 1,
329
+ minHeight: 44,
330
+ maxHeight: 120,
331
+ borderWidth: 1,
332
+ borderColor: theme.colors.border,
333
+ borderRadius: theme.radii.cardLg,
334
+ backgroundColor: theme.colors.surface,
335
+ color: theme.colors.textPrimary,
336
+ fontSize: 15,
337
+ lineHeight: 20,
338
+ paddingHorizontal: 14,
339
+ paddingTop: 12,
340
+ paddingBottom: 12,
341
+ textAlignVertical: 'top',
342
+ }}
343
+ />
344
+ <IconButton
345
+ onPress={handleSend}
346
+ theme={theme}
347
+ tone="accent"
348
+ size={44}
349
+ disabled={!valid || sending}
350
+ accessibilityLabel={sending ? 'Sending' : 'Send reply'}
205
351
  >
206
- {draft.length}/{USER_MESSAGE_MAX}
207
- </Text>
208
- {failedMessage !== null && (
209
- <Text style={{ fontSize: 11, color: theme.colors.statusDanger }}>
210
- {failedMessage}
211
- </Text>
212
- )}
213
- <View style={{ marginLeft: 'auto' }}>
214
- <PrimaryButton
215
- label={sending ? 'Sending…' : 'Send'}
216
- onPress={handleSend}
217
- disabled={!valid || sending}
218
- theme={theme}
219
- />
220
- </View>
352
+ <ArrowUpIcon color={theme.colors.accentForeground} size={19} />
353
+ </IconButton>
221
354
  </View>
355
+
356
+ {(tooShort ||
357
+ draft.length >= COUNTER_VISIBLE_FROM ||
358
+ failedMessage !== null) && (
359
+ <View
360
+ style={{
361
+ flexDirection: 'row',
362
+ alignItems: 'center',
363
+ gap: 10,
364
+ paddingTop: 8,
365
+ paddingHorizontal: 4,
366
+ }}
367
+ >
368
+ {failedMessage !== null ? (
369
+ <Text style={{ fontSize: 12, color: theme.colors.statusDanger }}>
370
+ {failedMessage}
371
+ </Text>
372
+ ) : tooShort ? (
373
+ <Text style={textStyles.meta(theme)}>
374
+ At least {USER_MESSAGE_MIN} characters
375
+ </Text>
376
+ ) : null}
377
+ {draft.length >= COUNTER_VISIBLE_FROM && (
378
+ <Text
379
+ style={[
380
+ textStyles.meta(theme),
381
+ { marginLeft: 'auto' },
382
+ draft.length > USER_MESSAGE_MAX && {
383
+ color: theme.colors.statusDanger,
384
+ },
385
+ ]}
386
+ >
387
+ {draft.length}/{USER_MESSAGE_MAX}
388
+ </Text>
389
+ )}
390
+ </View>
391
+ )}
222
392
  </>
223
393
  )}
224
394
  </View>
package/src/index.tsx CHANGED
@@ -8,6 +8,7 @@ export {
8
8
  export { SupportApiError } from './api';
9
9
  export { isPushAvailable } from './push';
10
10
  export type {
11
+ LauncherVariant,
11
12
  MessageSender,
12
13
  PushPermissionStatus,
13
14
  QueryStatus,
package/src/theme.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  // The SDK consumes the same design tokens as the admin dashboard, so both
2
- // surfaces look like one product.
2
+ // surfaces look like one product. On top of those it adds the few values
3
+ // that only exist on mobile: sheet/bubble radii and the elevation used by
4
+ // the launcher and the sheet (the web side gets those from CSS).
3
5
  import {
4
6
  darkColors,
5
7
  lightColors,
@@ -8,32 +10,177 @@ import {
8
10
  spacing,
9
11
  type ThemeColors,
10
12
  } from '@appsupportkit/design-tokens';
13
+ import type { ViewStyle } from 'react-native';
11
14
  import type { ThemeName } from './types';
12
15
 
16
+ /** Rounder than the dashboard: touch targets read better with more radius. */
17
+ export const supportRadii = {
18
+ ...radii,
19
+ control: 10,
20
+ card: 14,
21
+ cardLg: 18,
22
+ /** Message bubbles. */
23
+ bubble: 18,
24
+ /** The tight corner on a bubble's sender side. */
25
+ bubbleTail: 6,
26
+ sheet: 24,
27
+ pill: 999,
28
+ } as const;
29
+
30
+ export interface SupportShadows {
31
+ /** Floating launcher button. */
32
+ launcher: ViewStyle;
33
+ /** The bottom sheet itself. */
34
+ sheet: ViewStyle;
35
+ /** Unread badge, so it lifts off the button. */
36
+ badge: ViewStyle;
37
+ }
38
+
39
+ /**
40
+ * The launcher sits on the host app's own background rather than on an SDK
41
+ * surface, so the two themes need different treatment: on near-black a
42
+ * solid fill needs a hairline to separate from the background and a black
43
+ * shadow, while on white the accent-tinted shadow does that work and a
44
+ * borderless mark has to be darkened to stay legible.
45
+ */
46
+ export interface LauncherPalette {
47
+ /** Hairline drawn over the solid fill. */
48
+ solidRing: string;
49
+ /** Mark color for the borderless (minimal) variant. */
50
+ minimalMark: string;
51
+ }
52
+
13
53
  export interface SupportTheme {
14
54
  name: ThemeName;
15
55
  colors: ThemeColors;
16
56
  spacing: typeof spacing;
17
- radii: typeof radii;
57
+ radii: typeof supportRadii;
18
58
  motion: typeof motion;
59
+ shadows: SupportShadows;
60
+ launcher: LauncherPalette;
19
61
  }
20
62
 
21
- export const supportDarkTheme: SupportTheme = {
22
- name: 'dark',
23
- colors: darkColors,
24
- spacing,
25
- radii,
26
- motion,
27
- };
63
+ // Color helpers come first: the themes below are built at module load, so
64
+ // anything they reach for has to already be initialized. (A `const` used
65
+ // before its definition survives tsc but lands as `undefined` on Hermes.)
66
+ const HEX = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i;
28
67
 
29
- export const supportLightTheme: SupportTheme = {
30
- name: 'light',
31
- colors: lightColors,
32
- spacing,
33
- radii,
34
- motion,
35
- };
68
+ function parseHex(color: string): [number, number, number] | null {
69
+ const match = HEX.exec(color.trim());
70
+ const digits = match?.[1];
71
+ if (digits === undefined) return null;
72
+ const hex =
73
+ digits.length === 3
74
+ ? digits
75
+ .split('')
76
+ .map((c) => c + c)
77
+ .join('')
78
+ : digits;
79
+ return [
80
+ parseInt(hex.slice(0, 2), 16),
81
+ parseInt(hex.slice(2, 4), 16),
82
+ parseInt(hex.slice(4, 6), 16),
83
+ ];
84
+ }
85
+
86
+ function mix([r, g, b]: [number, number, number], target: number, amount: number) {
87
+ const channel = (c: number) => Math.round(c + (target - c) * amount);
88
+ return `rgb(${channel(r)}, ${channel(g)}, ${channel(b)})`;
89
+ }
90
+
91
+ function shadowsFor(name: ThemeName, accent: string): SupportShadows {
92
+ const dark = name === 'dark';
93
+ return {
94
+ launcher: {
95
+ // Tinting the launcher's shadow with the accent is what makes it read
96
+ // as lifted rather than pasted on.
97
+ shadowColor: dark ? '#000000' : accent,
98
+ shadowOpacity: dark ? 0.45 : 0.3,
99
+ shadowRadius: 14,
100
+ shadowOffset: { width: 0, height: 6 },
101
+ elevation: 8,
102
+ },
103
+ sheet: {
104
+ shadowColor: '#000000',
105
+ shadowOpacity: dark ? 0.5 : 0.16,
106
+ shadowRadius: 24,
107
+ shadowOffset: { width: 0, height: -6 },
108
+ elevation: 24,
109
+ },
110
+ badge: {
111
+ shadowColor: '#000000',
112
+ shadowOpacity: 0.25,
113
+ shadowRadius: 3,
114
+ shadowOffset: { width: 0, height: 1 },
115
+ elevation: 3,
116
+ },
117
+ };
118
+ }
119
+
120
+ function launcherFor(name: ThemeName, accent: string): LauncherPalette {
121
+ const rgb = parseHex(accent);
122
+ return {
123
+ solidRing:
124
+ name === 'dark' ? 'rgba(255, 255, 255, 0.14)' : 'rgba(0, 0, 0, 0.06)',
125
+ // On a dark app background the raw accent is too close to the surface
126
+ // to read as a glyph; lift it toward white.
127
+ minimalMark:
128
+ name === 'dark' && rgb !== null ? mix(rgb, 255, 0.35) : accent,
129
+ };
130
+ }
131
+
132
+ function buildTheme(name: ThemeName, colors: ThemeColors): SupportTheme {
133
+ return {
134
+ name,
135
+ colors,
136
+ spacing,
137
+ radii: supportRadii,
138
+ motion,
139
+ shadows: shadowsFor(name, colors.accent),
140
+ launcher: launcherFor(name, colors.accent),
141
+ };
142
+ }
143
+
144
+ export const supportDarkTheme: SupportTheme = buildTheme('dark', darkColors);
145
+ export const supportLightTheme: SupportTheme = buildTheme('light', lightColors);
146
+
147
+ /**
148
+ * Derives the accent family from a single brand color: a pressed shade, the
149
+ * 12% wash used behind chips and own-message bubbles, and a foreground that
150
+ * stays legible on top (dark text on light brands, white on dark ones).
151
+ */
152
+ function withAccent(theme: SupportTheme, accent: string): SupportTheme {
153
+ const rgb = parseHex(accent);
154
+ if (!rgb) return theme;
155
+ const [r, g, b] = rgb;
156
+ // Relative luminance, sRGB coefficients — good enough to pick a foreground.
157
+ const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
158
+ const colors: ThemeColors = {
159
+ ...theme.colors,
160
+ accent,
161
+ accentHover: mix(rgb, theme.name === 'dark' ? 255 : 0, 0.12),
162
+ accentMuted: `rgba(${r}, ${g}, ${b}, 0.12)`,
163
+ accentForeground: luminance > 0.62 ? '#111113' : '#FFFFFF',
164
+ statusOpen: accent,
165
+ statusOpenBg: `rgba(${r}, ${g}, ${b}, 0.10)`,
166
+ focusRing: accent,
167
+ };
168
+ return {
169
+ ...theme,
170
+ colors,
171
+ shadows: shadowsFor(theme.name, accent),
172
+ launcher: launcherFor(theme.name, accent),
173
+ };
174
+ }
36
175
 
37
- export function resolveTheme(name: ThemeName | undefined): SupportTheme {
38
- return name === 'light' ? supportLightTheme : supportDarkTheme;
176
+ /**
177
+ * @param name "light" | "dark" (default dark).
178
+ * @param accentColor Optional brand color (hex) replacing the default accent.
179
+ */
180
+ export function resolveTheme(
181
+ name: ThemeName | undefined,
182
+ accentColor?: string,
183
+ ): SupportTheme {
184
+ const base = name === 'light' ? supportLightTheme : supportDarkTheme;
185
+ return accentColor ? withAccent(base, accentColor) : base;
39
186
  }