@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.
- package/lib/commonjs/components/BottomSheet.js +67 -47
- package/lib/commonjs/components/BottomSheet.js.map +1 -1
- package/lib/commonjs/components/LauncherButton.js +177 -0
- package/lib/commonjs/components/LauncherButton.js.map +1 -0
- package/lib/commonjs/components/SupportButton.js +54 -176
- package/lib/commonjs/components/SupportButton.js.map +1 -1
- package/lib/commonjs/components/icons.js +74 -23
- package/lib/commonjs/components/icons.js.map +1 -1
- package/lib/commonjs/components/primitives.js +183 -38
- package/lib/commonjs/components/primitives.js.map +1 -1
- package/lib/commonjs/components/screens/ComposeScreen.js +115 -58
- package/lib/commonjs/components/screens/ComposeScreen.js.map +1 -1
- package/lib/commonjs/components/screens/HomeScreen.js +170 -115
- package/lib/commonjs/components/screens/HomeScreen.js.map +1 -1
- package/lib/commonjs/components/screens/ThreadScreen.js +222 -108
- package/lib/commonjs/components/screens/ThreadScreen.js.map +1 -1
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/theme.js +136 -17
- package/lib/commonjs/theme.js.map +1 -1
- package/lib/commonjs/types.js +7 -0
- package/lib/commonjs/types.js.map +1 -1
- package/lib/commonjs/unread.js +92 -0
- package/lib/commonjs/unread.js.map +1 -0
- package/lib/commonjs/useSupportThreads.js +286 -0
- package/lib/commonjs/useSupportThreads.js.map +1 -0
- package/lib/commonjs/utils.js +33 -0
- package/lib/commonjs/utils.js.map +1 -1
- package/lib/module/components/BottomSheet.js +68 -48
- package/lib/module/components/BottomSheet.js.map +1 -1
- package/lib/module/components/LauncherButton.js +172 -0
- package/lib/module/components/LauncherButton.js.map +1 -0
- package/lib/module/components/SupportButton.js +57 -179
- package/lib/module/components/SupportButton.js.map +1 -1
- package/lib/module/components/icons.js +72 -22
- package/lib/module/components/icons.js.map +1 -1
- package/lib/module/components/primitives.js +179 -39
- package/lib/module/components/primitives.js.map +1 -1
- package/lib/module/components/screens/ComposeScreen.js +118 -61
- package/lib/module/components/screens/ComposeScreen.js.map +1 -1
- package/lib/module/components/screens/HomeScreen.js +172 -117
- package/lib/module/components/screens/HomeScreen.js.map +1 -1
- package/lib/module/components/screens/ThreadScreen.js +228 -112
- package/lib/module/components/screens/ThreadScreen.js.map +1 -1
- package/lib/module/index.js.map +1 -1
- package/lib/module/theme.js +135 -16
- package/lib/module/theme.js.map +1 -1
- package/lib/module/types.js +7 -0
- package/lib/module/types.js.map +1 -1
- package/lib/module/unread.js +83 -0
- package/lib/module/unread.js.map +1 -0
- package/lib/module/useSupportThreads.js +281 -0
- package/lib/module/useSupportThreads.js.map +1 -0
- package/lib/module/utils.js +30 -0
- package/lib/module/utils.js.map +1 -1
- package/lib/typescript/components/BottomSheet.d.ts.map +1 -1
- package/lib/typescript/components/LauncherButton.d.ts +14 -0
- package/lib/typescript/components/LauncherButton.d.ts.map +1 -0
- package/lib/typescript/components/SupportButton.d.ts +3 -3
- package/lib/typescript/components/SupportButton.d.ts.map +1 -1
- package/lib/typescript/components/icons.d.ts +13 -2
- package/lib/typescript/components/icons.d.ts.map +1 -1
- package/lib/typescript/components/primitives.d.ts +35 -4
- package/lib/typescript/components/primitives.d.ts.map +1 -1
- package/lib/typescript/components/screens/ComposeScreen.d.ts.map +1 -1
- package/lib/typescript/components/screens/HomeScreen.d.ts +3 -2
- package/lib/typescript/components/screens/HomeScreen.d.ts.map +1 -1
- package/lib/typescript/components/screens/ThreadScreen.d.ts.map +1 -1
- package/lib/typescript/index.d.ts +1 -1
- package/lib/typescript/index.d.ts.map +1 -1
- package/lib/typescript/theme.d.ts +44 -3
- package/lib/typescript/theme.d.ts.map +1 -1
- package/lib/typescript/types.d.ts +22 -0
- package/lib/typescript/types.d.ts.map +1 -1
- package/lib/typescript/unread.d.ts +22 -0
- package/lib/typescript/unread.d.ts.map +1 -0
- package/lib/typescript/useSupportThreads.d.ts +28 -0
- package/lib/typescript/useSupportThreads.d.ts.map +1 -0
- package/lib/typescript/utils.d.ts +5 -0
- package/lib/typescript/utils.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/components/BottomSheet.tsx +61 -43
- package/src/components/LauncherButton.tsx +217 -0
- package/src/components/SupportButton.tsx +52 -196
- package/src/components/icons.tsx +77 -19
- package/src/components/primitives.tsx +217 -36
- package/src/components/screens/ComposeScreen.tsx +131 -61
- package/src/components/screens/HomeScreen.tsx +168 -117
- package/src/components/screens/ThreadScreen.tsx +289 -119
- package/src/index.tsx +1 -0
- package/src/theme.ts +165 -18
- package/src/types.ts +24 -0
- package/src/unread.ts +91 -0
- package/src/useSupportThreads.ts +372 -0
- 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
|
-
|
|
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:
|
|
237
|
+
gap: 10,
|
|
70
238
|
paddingHorizontal: 12,
|
|
71
|
-
paddingBottom:
|
|
239
|
+
paddingBottom: 12,
|
|
72
240
|
borderBottomWidth: 1,
|
|
73
241
|
borderBottomColor: theme.colors.border,
|
|
74
242
|
}}
|
|
75
243
|
>
|
|
76
|
-
<
|
|
244
|
+
<IconButton
|
|
77
245
|
onPress={onBack}
|
|
78
246
|
theme={theme}
|
|
79
|
-
accessibilityLabel="Back"
|
|
80
|
-
|
|
247
|
+
accessibilityLabel="Back to conversations"
|
|
248
|
+
size={32}
|
|
81
249
|
>
|
|
82
250
|
<Chevron color={theme.colors.textSecondary} direction="left" size={12} />
|
|
83
|
-
</
|
|
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={{
|
|
263
|
+
contentContainerStyle={{ paddingHorizontal: 16, paddingVertical: 12 }}
|
|
264
|
+
keyboardShouldPersistTaps="handled"
|
|
265
|
+
onContentSizeChange={() =>
|
|
266
|
+
scrollRef.current?.scrollToEnd({ animated: false })
|
|
267
|
+
}
|
|
96
268
|
>
|
|
97
|
-
{
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
<
|
|
158
|
-
style={
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
164
|
-
|
|
310
|
+
<Text style={textStyles.secondary(theme)}>
|
|
311
|
+
This conversation is resolved and read-only
|
|
312
|
+
</Text>
|
|
313
|
+
</View>
|
|
165
314
|
) : (
|
|
166
315
|
<>
|
|
167
|
-
<
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
-
{
|
|
207
|
-
</
|
|
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
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
|
|
57
|
+
radii: typeof supportRadii;
|
|
18
58
|
motion: typeof motion;
|
|
59
|
+
shadows: SupportShadows;
|
|
60
|
+
launcher: LauncherPalette;
|
|
19
61
|
}
|
|
20
62
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
38
|
-
|
|
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
|
}
|