@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.
Files changed (110) hide show
  1. package/lib/commonjs/api.js +29 -0
  2. package/lib/commonjs/api.js.map +1 -1
  3. package/lib/commonjs/components/BottomSheet.js +67 -47
  4. package/lib/commonjs/components/BottomSheet.js.map +1 -1
  5. package/lib/commonjs/components/LauncherButton.js +177 -0
  6. package/lib/commonjs/components/LauncherButton.js.map +1 -0
  7. package/lib/commonjs/components/SupportButton.js +76 -146
  8. package/lib/commonjs/components/SupportButton.js.map +1 -1
  9. package/lib/commonjs/components/icons.js +74 -23
  10. package/lib/commonjs/components/icons.js.map +1 -1
  11. package/lib/commonjs/components/primitives.js +183 -38
  12. package/lib/commonjs/components/primitives.js.map +1 -1
  13. package/lib/commonjs/components/screens/ComposeScreen.js +115 -58
  14. package/lib/commonjs/components/screens/ComposeScreen.js.map +1 -1
  15. package/lib/commonjs/components/screens/HomeScreen.js +170 -115
  16. package/lib/commonjs/components/screens/HomeScreen.js.map +1 -1
  17. package/lib/commonjs/components/screens/ThreadScreen.js +222 -108
  18. package/lib/commonjs/components/screens/ThreadScreen.js.map +1 -1
  19. package/lib/commonjs/index.js +7 -0
  20. package/lib/commonjs/index.js.map +1 -1
  21. package/lib/commonjs/push.js +103 -0
  22. package/lib/commonjs/push.js.map +1 -0
  23. package/lib/commonjs/theme.js +136 -17
  24. package/lib/commonjs/theme.js.map +1 -1
  25. package/lib/commonjs/types.js +7 -0
  26. package/lib/commonjs/types.js.map +1 -1
  27. package/lib/commonjs/unread.js +92 -0
  28. package/lib/commonjs/unread.js.map +1 -0
  29. package/lib/commonjs/useSupportThreads.js +286 -0
  30. package/lib/commonjs/useSupportThreads.js.map +1 -0
  31. package/lib/commonjs/utils.js +33 -0
  32. package/lib/commonjs/utils.js.map +1 -1
  33. package/lib/module/api.js +27 -0
  34. package/lib/module/api.js.map +1 -1
  35. package/lib/module/components/BottomSheet.js +68 -48
  36. package/lib/module/components/BottomSheet.js.map +1 -1
  37. package/lib/module/components/LauncherButton.js +172 -0
  38. package/lib/module/components/LauncherButton.js.map +1 -0
  39. package/lib/module/components/SupportButton.js +79 -149
  40. package/lib/module/components/SupportButton.js.map +1 -1
  41. package/lib/module/components/icons.js +72 -22
  42. package/lib/module/components/icons.js.map +1 -1
  43. package/lib/module/components/primitives.js +179 -39
  44. package/lib/module/components/primitives.js.map +1 -1
  45. package/lib/module/components/screens/ComposeScreen.js +118 -61
  46. package/lib/module/components/screens/ComposeScreen.js.map +1 -1
  47. package/lib/module/components/screens/HomeScreen.js +172 -117
  48. package/lib/module/components/screens/HomeScreen.js.map +1 -1
  49. package/lib/module/components/screens/ThreadScreen.js +228 -112
  50. package/lib/module/components/screens/ThreadScreen.js.map +1 -1
  51. package/lib/module/index.js +1 -0
  52. package/lib/module/index.js.map +1 -1
  53. package/lib/module/push.js +98 -0
  54. package/lib/module/push.js.map +1 -0
  55. package/lib/module/theme.js +135 -16
  56. package/lib/module/theme.js.map +1 -1
  57. package/lib/module/types.js +7 -0
  58. package/lib/module/types.js.map +1 -1
  59. package/lib/module/unread.js +83 -0
  60. package/lib/module/unread.js.map +1 -0
  61. package/lib/module/useSupportThreads.js +281 -0
  62. package/lib/module/useSupportThreads.js.map +1 -0
  63. package/lib/module/utils.js +30 -0
  64. package/lib/module/utils.js.map +1 -1
  65. package/lib/typescript/api.d.ts +17 -1
  66. package/lib/typescript/api.d.ts.map +1 -1
  67. package/lib/typescript/components/BottomSheet.d.ts.map +1 -1
  68. package/lib/typescript/components/LauncherButton.d.ts +14 -0
  69. package/lib/typescript/components/LauncherButton.d.ts.map +1 -0
  70. package/lib/typescript/components/SupportButton.d.ts +3 -3
  71. package/lib/typescript/components/SupportButton.d.ts.map +1 -1
  72. package/lib/typescript/components/icons.d.ts +13 -2
  73. package/lib/typescript/components/icons.d.ts.map +1 -1
  74. package/lib/typescript/components/primitives.d.ts +35 -4
  75. package/lib/typescript/components/primitives.d.ts.map +1 -1
  76. package/lib/typescript/components/screens/ComposeScreen.d.ts.map +1 -1
  77. package/lib/typescript/components/screens/HomeScreen.d.ts +3 -2
  78. package/lib/typescript/components/screens/HomeScreen.d.ts.map +1 -1
  79. package/lib/typescript/components/screens/ThreadScreen.d.ts.map +1 -1
  80. package/lib/typescript/index.d.ts +2 -1
  81. package/lib/typescript/index.d.ts.map +1 -1
  82. package/lib/typescript/push.d.ts +18 -0
  83. package/lib/typescript/push.d.ts.map +1 -0
  84. package/lib/typescript/theme.d.ts +44 -3
  85. package/lib/typescript/theme.d.ts.map +1 -1
  86. package/lib/typescript/types.d.ts +37 -4
  87. package/lib/typescript/types.d.ts.map +1 -1
  88. package/lib/typescript/unread.d.ts +22 -0
  89. package/lib/typescript/unread.d.ts.map +1 -0
  90. package/lib/typescript/useSupportThreads.d.ts +28 -0
  91. package/lib/typescript/useSupportThreads.d.ts.map +1 -0
  92. package/lib/typescript/utils.d.ts +5 -0
  93. package/lib/typescript/utils.d.ts.map +1 -1
  94. package/package.json +8 -2
  95. package/src/api.ts +41 -1
  96. package/src/components/BottomSheet.tsx +61 -43
  97. package/src/components/LauncherButton.tsx +217 -0
  98. package/src/components/SupportButton.tsx +81 -162
  99. package/src/components/icons.tsx +77 -19
  100. package/src/components/primitives.tsx +217 -36
  101. package/src/components/screens/ComposeScreen.tsx +131 -61
  102. package/src/components/screens/HomeScreen.tsx +168 -117
  103. package/src/components/screens/ThreadScreen.tsx +289 -119
  104. package/src/index.tsx +3 -0
  105. package/src/push.ts +97 -0
  106. package/src/theme.ts +165 -18
  107. package/src/types.ts +39 -4
  108. package/src/unread.ts +91 -0
  109. package/src/useSupportThreads.ts +372 -0
  110. 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
@@ -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
+ }