@appsupportkit/support-sdk 0.1.1 → 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/api.js +29 -0
- package/lib/commonjs/api.js.map +1 -1
- 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 +76 -146
- 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 +7 -0
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/push.js +103 -0
- package/lib/commonjs/push.js.map +1 -0
- 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/api.js +27 -0
- package/lib/module/api.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 +79 -149
- 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 +1 -0
- package/lib/module/index.js.map +1 -1
- package/lib/module/push.js +98 -0
- package/lib/module/push.js.map +1 -0
- 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/api.d.ts +17 -1
- package/lib/typescript/api.d.ts.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 +2 -1
- package/lib/typescript/index.d.ts.map +1 -1
- package/lib/typescript/push.d.ts +18 -0
- package/lib/typescript/push.d.ts.map +1 -0
- package/lib/typescript/theme.d.ts +44 -3
- package/lib/typescript/theme.d.ts.map +1 -1
- package/lib/typescript/types.d.ts +37 -4
- 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 +8 -2
- package/src/api.ts +41 -1
- package/src/components/BottomSheet.tsx +61 -43
- package/src/components/LauncherButton.tsx +217 -0
- package/src/components/SupportButton.tsx +81 -162
- 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 +3 -0
- package/src/push.ts +97 -0
- package/src/theme.ts +165 -18
- package/src/types.ts +39 -4
- 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
|
@@ -6,8 +6,11 @@ export {
|
|
|
6
6
|
type SupportTheme,
|
|
7
7
|
} from './theme';
|
|
8
8
|
export { SupportApiError } from './api';
|
|
9
|
+
export { isPushAvailable } from './push';
|
|
9
10
|
export type {
|
|
11
|
+
LauncherVariant,
|
|
10
12
|
MessageSender,
|
|
13
|
+
PushPermissionStatus,
|
|
11
14
|
QueryStatus,
|
|
12
15
|
QueryType,
|
|
13
16
|
SupportButtonProps,
|
package/src/push.ts
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// Optional push-notification support.
|
|
2
|
+
//
|
|
3
|
+
// expo-notifications is an OPTIONAL peer dependency: if the host app has it
|
|
4
|
+
// installed we ask for permission and mint a token, otherwise every function
|
|
5
|
+
// here degrades to "undetermined" and the support flow is unaffected. The
|
|
6
|
+
// require is wrapped in try/catch so bundling succeeds either way.
|
|
7
|
+
//
|
|
8
|
+
// Nothing in the support experience is gated on the result — a user who
|
|
9
|
+
// denies notifications keeps full use of threads, replies, and unread
|
|
10
|
+
// badges. The status is reported purely so the dashboard can show whether
|
|
11
|
+
// the user is reachable.
|
|
12
|
+
|
|
13
|
+
import type { PushPermissionStatus } from './types';
|
|
14
|
+
|
|
15
|
+
/** The slice of expo-notifications this SDK uses. */
|
|
16
|
+
interface ExpoNotificationsModule {
|
|
17
|
+
getPermissionsAsync(): Promise<{ status: string; canAskAgain: boolean }>;
|
|
18
|
+
requestPermissionsAsync(): Promise<{ status: string; canAskAgain: boolean }>;
|
|
19
|
+
getExpoPushTokenAsync(options?: {
|
|
20
|
+
projectId?: string;
|
|
21
|
+
}): Promise<{ data: string }>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function loadNotifications(): ExpoNotificationsModule | null {
|
|
25
|
+
try {
|
|
26
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
27
|
+
const mod = require('expo-notifications') as ExpoNotificationsModule;
|
|
28
|
+
return typeof mod?.getPermissionsAsync === 'function' ? mod : null;
|
|
29
|
+
} catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const notifications = loadNotifications();
|
|
35
|
+
|
|
36
|
+
/** True when the host app has expo-notifications installed. */
|
|
37
|
+
export const isPushAvailable = notifications !== null;
|
|
38
|
+
|
|
39
|
+
export interface PushRegistration {
|
|
40
|
+
status: PushPermissionStatus;
|
|
41
|
+
/** Whether the OS prompt was actually presented during this call. */
|
|
42
|
+
asked: boolean;
|
|
43
|
+
token: string | null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function normalizeStatus(status: string): PushPermissionStatus {
|
|
47
|
+
if (status === 'granted') return 'granted';
|
|
48
|
+
if (status === 'denied') return 'denied';
|
|
49
|
+
return 'undetermined';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Ensures notification permission has been requested, and returns the
|
|
54
|
+
* outcome plus an Expo push token when one is obtainable.
|
|
55
|
+
*
|
|
56
|
+
* Never throws: any failure resolves to an 'undetermined' registration so
|
|
57
|
+
* callers can report it and move on.
|
|
58
|
+
*/
|
|
59
|
+
export async function ensurePushPermission(
|
|
60
|
+
expoProjectId?: string,
|
|
61
|
+
): Promise<PushRegistration> {
|
|
62
|
+
if (!notifications) {
|
|
63
|
+
return { status: 'undetermined', asked: false, token: null };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
const current = await notifications.getPermissionsAsync();
|
|
68
|
+
let status = normalizeStatus(current.status);
|
|
69
|
+
let asked = false;
|
|
70
|
+
|
|
71
|
+
// Only prompt when the OS will actually show something. Re-prompting a
|
|
72
|
+
// user who already decided is impossible on iOS and annoying on Android.
|
|
73
|
+
if (status === 'undetermined' && current.canAskAgain) {
|
|
74
|
+
const requested = await notifications.requestPermissionsAsync();
|
|
75
|
+
status = normalizeStatus(requested.status);
|
|
76
|
+
asked = true;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (status !== 'granted') {
|
|
80
|
+
return { status, asked, token: null };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Token minting needs a dev/production build with push credentials —
|
|
84
|
+
// it fails in Expo Go. A granted permission with no token is still
|
|
85
|
+
// useful information for the dashboard, so report it either way.
|
|
86
|
+
try {
|
|
87
|
+
const token = await notifications.getExpoPushTokenAsync(
|
|
88
|
+
expoProjectId ? { projectId: expoProjectId } : undefined,
|
|
89
|
+
);
|
|
90
|
+
return { status, asked, token: token.data };
|
|
91
|
+
} catch {
|
|
92
|
+
return { status, asked, token: null };
|
|
93
|
+
}
|
|
94
|
+
} catch {
|
|
95
|
+
return { status: 'undetermined', asked: false, token: null };
|
|
96
|
+
}
|
|
97
|
+
}
|