@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,11 +1,19 @@
1
- import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
- import { AppState, Pressable, Text, View } from 'react-native';
3
- import { getThreads, markRead, postMessage, submitQuery, SupportApiError, type ApiConfig } from '../api';
1
+ import { useCallback, useMemo, useState } from 'react';
2
+ import { Text, View } from 'react-native';
3
+ import {
4
+ postMessage,
5
+ registerPush,
6
+ submitQuery,
7
+ SupportApiError,
8
+ type ApiConfig,
9
+ } from '../api';
10
+ import { ensurePushPermission, isPushAvailable } from '../push';
4
11
  import { resolveTheme } from '../theme';
5
12
  import type { QueryType, SupportButtonProps, Thread } from '../types';
13
+ import { IDLE_POLL_MS, useSupportThreads } from '../useSupportThreads';
6
14
  import { validateProps } from '../utils';
7
15
  import { BottomSheet } from './BottomSheet';
8
- import { BubbleMark } from './icons';
16
+ import { LauncherButton } from './LauncherButton';
9
17
  import { ComposeScreen } from './screens/ComposeScreen';
10
18
  import { HomeScreen } from './screens/HomeScreen';
11
19
  import { ThreadScreen } from './screens/ThreadScreen';
@@ -16,13 +24,16 @@ type Screen =
16
24
  | { name: 'thread'; threadId: string };
17
25
 
18
26
  /**
19
- * The single entry point of the SDK: a button with an unread badge that
20
- * opens the support bottom sheet. Polls get-threads on mount and whenever
21
- * the sheet opens (no websockets in v1).
27
+ * The single entry point of the SDK: a launcher with an unread badge that
28
+ * opens the support bottom sheet. Threads, polling, and read receipts are
29
+ * handled by useSupportThreads.
22
30
  */
23
31
  export function SupportButton(props: SupportButtonProps) {
24
32
  const validationError = validateProps(props);
25
- const theme = resolveTheme(props.theme);
33
+ const theme = useMemo(
34
+ () => resolveTheme(props.theme, props.accentColor),
35
+ [props.theme, props.accentColor],
36
+ );
26
37
 
27
38
  const config: ApiConfig = useMemo(
28
39
  () => ({
@@ -35,71 +46,44 @@ export function SupportButton(props: SupportButtonProps) {
35
46
 
36
47
  const [sheetOpen, setSheetOpen] = useState(false);
37
48
  const [screen, setScreen] = useState<Screen>({ name: 'home' });
38
- const [threads, setThreads] = useState<Thread[] | null>(null);
39
- const [totalUnread, setTotalUnread] = useState(0);
40
- const [loading, setLoading] = useState(false);
41
- const [error, setError] = useState<string | null>(null);
42
- const mounted = useRef(true);
43
49
 
44
- useEffect(() => {
45
- mounted.current = true;
46
- return () => {
47
- mounted.current = false;
48
- };
49
- }, []);
50
+ const { threads, totalUnread, loading, refreshing, error, refresh, reload } =
51
+ useSupportThreads({
52
+ config,
53
+ revenuecatId: props.user.revenuecatId,
54
+ enabled: validationError === null,
55
+ sheetOpen,
56
+ activeThreadId: screen.name === 'thread' ? screen.threadId : null,
57
+ idlePollMs: props.pollIntervalMs ?? IDLE_POLL_MS,
58
+ });
50
59
 
51
- const refresh = useCallback(async () => {
52
- if (validationError) return;
53
- setLoading(true);
54
- setError(null);
60
+ /**
61
+ * Asks for notification permission the moment the sheet opens, then
62
+ * reports the outcome (including denials) so the dashboard knows whether
63
+ * this user is reachable. Deliberately fire-and-forget: nothing in the
64
+ * support flow waits on it or breaks if it fails.
65
+ */
66
+ const syncPushPermission = useCallback(async () => {
67
+ if (validationError !== null || !isPushAvailable) return;
55
68
  try {
56
- const data = await getThreads(config, props.user.revenuecatId);
57
- if (!mounted.current) return;
58
- setThreads(data.threads);
59
- setTotalUnread(data.total_unread);
60
- } catch (e) {
61
- if (!mounted.current) return;
62
- setError(
63
- e instanceof SupportApiError && e.offline
64
- ? "You're offline — check your connection"
65
- : "Couldn't load threads",
66
- );
67
- } finally {
68
- if (mounted.current) setLoading(false);
69
+ const registration = await ensurePushPermission(props.expoProjectId);
70
+ await registerPush(config, {
71
+ revenuecatId: props.user.revenuecatId,
72
+ permissionStatus: registration.status,
73
+ expoPushToken: registration.token,
74
+ asked: registration.asked,
75
+ });
76
+ } catch {
77
+ // Push is an enhancement — never surfaced to the user as an error.
69
78
  }
70
- }, [config, props.user.revenuecatId, validationError]);
71
-
72
- // Poll on mount…
73
- useEffect(() => {
74
- void refresh();
75
- }, [refresh]);
79
+ }, [config, props.user.revenuecatId, props.expoProjectId, validationError]);
76
80
 
77
- // …in the background so the badge stays current without opening the
78
- // sheet, and whenever the app returns to the foreground.
79
- const pollIntervalMs = props.pollIntervalMs ?? 30_000;
80
- useEffect(() => {
81
- // Clamp to a sane floor so a typo like `pollIntervalMs={30}` doesn't
82
- // hammer the API. 0 (or negative) disables interval polling.
83
- const interval =
84
- pollIntervalMs > 0
85
- ? setInterval(() => void refresh(), Math.max(pollIntervalMs, 5_000))
86
- : null;
87
-
88
- const appStateSub = AppState.addEventListener('change', (state) => {
89
- if (state === 'active') void refresh();
90
- });
91
-
92
- return () => {
93
- if (interval !== null) clearInterval(interval);
94
- appStateSub.remove();
95
- };
96
- }, [pollIntervalMs, refresh]);
97
-
98
- // …and on sheet open.
99
81
  function openSheet() {
100
82
  setScreen({ name: 'home' });
101
83
  setSheetOpen(true);
102
- void refresh();
84
+ // The OS prompt appears right as the sheet opens; if the user declines,
85
+ // everything below carries on exactly as before.
86
+ void syncPushPermission();
103
87
  }
104
88
 
105
89
  function closeSheet() {
@@ -107,7 +91,7 @@ export function SupportButton(props: SupportButtonProps) {
107
91
  setScreen({ name: 'home' });
108
92
  }
109
93
 
110
- if (validationError) {
94
+ if (validationError !== null) {
111
95
  // Dev-mode: loud and actionable. Production: render nothing rather than
112
96
  // a broken button.
113
97
  if (__DEV__) {
@@ -130,26 +114,6 @@ export function SupportButton(props: SupportButtonProps) {
130
114
  return null;
131
115
  }
132
116
 
133
- function openThread(thread: Thread) {
134
- setScreen({ name: 'thread', threadId: thread.id });
135
- if (thread.unread_count > 0) {
136
- // Optimistically clear the badge; persist via mark-read.
137
- setThreads(
138
- (prev) =>
139
- prev?.map((t) =>
140
- t.id === thread.id ? { ...t, unread_count: 0 } : t,
141
- ) ?? null,
142
- );
143
- setTotalUnread((n) => Math.max(0, n - thread.unread_count));
144
- void markRead(config, {
145
- queryId: thread.id,
146
- revenuecatId: props.user.revenuecatId,
147
- }).catch(() => {
148
- // Non-fatal: the next get-threads reconciles the badge.
149
- });
150
- }
151
- }
152
-
153
117
  function writeErrorMessage(e: unknown): string {
154
118
  if (e instanceof SupportApiError) {
155
119
  if (e.status === 429) return 'Too many requests — try again later';
@@ -171,7 +135,7 @@ export function SupportButton(props: SupportButtonProps) {
171
135
  revenuecatId: props.user.revenuecatId,
172
136
  appVersion: props.user.appVersion,
173
137
  });
174
- void refresh();
138
+ void reload();
175
139
  return null;
176
140
  } catch (e) {
177
141
  return writeErrorMessage(e);
@@ -188,84 +152,48 @@ export function SupportButton(props: SupportButtonProps) {
188
152
  revenuecatId: props.user.revenuecatId,
189
153
  message,
190
154
  });
191
- await refresh();
155
+ await reload();
192
156
  return null;
193
157
  } catch (e) {
194
158
  return writeErrorMessage(e);
195
159
  }
196
160
  }
197
161
 
198
- const activeThread =
162
+ const activeThread: Thread | null =
199
163
  screen.name === 'thread'
200
- ? (threads ?? []).find((t) => t.id === screen.threadId) ?? null
164
+ ? ((threads ?? []).find((t) => t.id === screen.threadId) ?? null)
201
165
  : null;
202
166
 
167
+ const home = (
168
+ <HomeScreen
169
+ theme={theme}
170
+ threads={threads}
171
+ loading={loading}
172
+ refreshing={refreshing}
173
+ error={error}
174
+ onRefresh={refresh}
175
+ onCompose={(type) => setScreen({ name: 'compose', type })}
176
+ onOpenThread={(thread) =>
177
+ setScreen({ name: 'thread', threadId: thread.id })
178
+ }
179
+ onClose={closeSheet}
180
+ />
181
+ );
182
+
203
183
  return (
204
184
  <>
205
- <Pressable
185
+ <LauncherButton
186
+ theme={theme}
187
+ unread={totalUnread}
188
+ variant={props.variant}
189
+ size={props.size}
190
+ label={props.label}
191
+ style={props.style}
206
192
  onPress={openSheet}
207
- accessibilityRole="button"
208
- accessibilityLabel={
209
- totalUnread > 0
210
- ? `Support, ${totalUnread} unread ${totalUnread === 1 ? 'reply' : 'replies'}`
211
- : 'Support'
212
- }
213
- style={({ pressed }) => ({
214
- width: 44,
215
- height: 44,
216
- borderRadius: 22,
217
- borderWidth: 1,
218
- borderColor: theme.colors.border,
219
- backgroundColor: pressed
220
- ? theme.colors.surfaceHover
221
- : theme.colors.surface,
222
- alignItems: 'center',
223
- justifyContent: 'center',
224
- })}
225
- >
226
- <BubbleMark color={theme.colors.textPrimary} size={18} />
227
- {totalUnread > 0 && (
228
- <View
229
- style={{
230
- position: 'absolute',
231
- top: -3,
232
- right: -3,
233
- minWidth: 16,
234
- height: 16,
235
- borderRadius: 8,
236
- paddingHorizontal: 4,
237
- backgroundColor: theme.colors.accent,
238
- alignItems: 'center',
239
- justifyContent: 'center',
240
- }}
241
- >
242
- <Text
243
- style={{
244
- fontSize: 10,
245
- fontWeight: '600',
246
- color: theme.colors.accentForeground,
247
- fontVariant: ['tabular-nums'],
248
- }}
249
- >
250
- {totalUnread > 9 ? '9+' : totalUnread}
251
- </Text>
252
- </View>
253
- )}
254
- </Pressable>
193
+ />
255
194
 
256
195
  <BottomSheet visible={sheetOpen} onClose={closeSheet} theme={theme}>
257
- {screen.name === 'home' && (
258
- <HomeScreen
259
- theme={theme}
260
- threads={threads}
261
- loading={loading}
262
- error={error}
263
- onRetry={() => void refresh()}
264
- onCompose={(type) => setScreen({ name: 'compose', type })}
265
- onOpenThread={openThread}
266
- onClose={closeSheet}
267
- />
268
- )}
196
+ {screen.name === 'home' && home}
269
197
  {screen.name === 'compose' && (
270
198
  <ComposeScreen
271
199
  theme={theme}
@@ -275,7 +203,7 @@ export function SupportButton(props: SupportButtonProps) {
275
203
  />
276
204
  )}
277
205
  {screen.name === 'thread' &&
278
- (activeThread ? (
206
+ (activeThread !== null ? (
279
207
  <ThreadScreen
280
208
  theme={theme}
281
209
  thread={activeThread}
@@ -283,16 +211,7 @@ export function SupportButton(props: SupportButtonProps) {
283
211
  onSend={(message) => handleSend(activeThread.id, message)}
284
212
  />
285
213
  ) : (
286
- <HomeScreen
287
- theme={theme}
288
- threads={threads}
289
- loading={loading}
290
- error={error}
291
- onRetry={() => void refresh()}
292
- onCompose={(type) => setScreen({ name: 'compose', type })}
293
- onOpenThread={openThread}
294
- onClose={closeSheet}
295
- />
214
+ home
296
215
  ))}
297
216
  </BottomSheet>
298
217
  </>
@@ -145,43 +145,101 @@ export function CheckIcon({ color, size = 16 }: { color: string; size?: number }
145
145
  );
146
146
  }
147
147
 
148
- /** The chat-bubble mark on the floating support button. */
149
- export function BubbleMark({ color, size = 20 }: { color: string; size?: number }) {
148
+ /** Send arrow: a stem plus a chevron head, both centered on the same axis. */
149
+ export function ArrowUpIcon({ color, size = 16 }: { color: string; size?: number }) {
150
+ const stroke = STROKE + 0.4;
151
+ const head = size * 0.44;
152
+ return (
153
+ <View
154
+ style={{
155
+ width: size,
156
+ height: size,
157
+ alignItems: 'center',
158
+ justifyContent: 'center',
159
+ }}
160
+ >
161
+ <View
162
+ style={{
163
+ position: 'absolute',
164
+ width: stroke,
165
+ height: size * 0.66,
166
+ borderRadius: stroke,
167
+ backgroundColor: color,
168
+ }}
169
+ />
170
+ <View
171
+ style={{
172
+ position: 'absolute',
173
+ // Rotating a square by 45° lifts its apex by ~0.207 × side, so
174
+ // offset by that to land the point on top of the stem.
175
+ top: size * 0.17 + head * 0.207,
176
+ width: head,
177
+ height: head,
178
+ borderTopWidth: stroke,
179
+ borderLeftWidth: stroke,
180
+ borderTopLeftRadius: stroke,
181
+ borderColor: color,
182
+ transform: [{ rotate: '45deg' }],
183
+ }}
184
+ />
185
+ </View>
186
+ );
187
+ }
188
+
189
+ /**
190
+ * The chat-bubble mark on the launcher button: a filled bubble with the
191
+ * dots knocked out in the button's own fill color. Solid shapes read far
192
+ * better than 1.5px strokes at launcher size and on top of a color fill.
193
+ */
194
+ export function ChatBubbleMark({
195
+ color,
196
+ dotColor,
197
+ size = 24,
198
+ }: {
199
+ color: string;
200
+ /** The surface behind the mark — the dots are punched out in this color. */
201
+ dotColor: string;
202
+ size?: number;
203
+ }) {
204
+ const bubbleHeight = size * 0.82;
150
205
  const dot: ViewStyle = {
151
- width: size * 0.12,
152
- height: size * 0.12,
206
+ width: size * 0.13,
207
+ height: size * 0.13,
153
208
  borderRadius: size,
154
- backgroundColor: color,
209
+ backgroundColor: dotColor,
155
210
  };
156
211
  return (
157
- <View style={{ alignItems: 'flex-start' }}>
212
+ <View style={{ width: size, height: size, justifyContent: 'flex-start' }}>
158
213
  <View
159
214
  style={{
160
215
  width: size,
161
- height: size * 0.78,
162
- borderWidth: STROKE + 0.25,
163
- borderColor: color,
164
- borderRadius: size * 0.28,
216
+ height: bubbleHeight,
217
+ borderRadius: size * 0.33,
218
+ backgroundColor: color,
165
219
  flexDirection: 'row',
166
220
  alignItems: 'center',
167
221
  justifyContent: 'center',
168
- gap: size * 0.1,
222
+ gap: size * 0.115,
169
223
  }}
170
224
  >
171
225
  <View style={dot} />
172
226
  <View style={dot} />
173
227
  <View style={dot} />
174
228
  </View>
229
+ {/* Tail, drawn as a border triangle so it needs no svg. */}
175
230
  <View
176
231
  style={{
177
- width: size * 0.22,
178
- height: size * 0.22,
179
- borderLeftWidth: STROKE + 0.25,
180
- borderBottomWidth: STROKE + 0.25,
181
- borderColor: color,
182
- marginLeft: size * 0.18,
183
- marginTop: -STROKE,
184
- transform: [{ skewX: '-12deg' }],
232
+ position: 'absolute',
233
+ left: size * 0.22,
234
+ top: bubbleHeight - 1,
235
+ width: 0,
236
+ height: 0,
237
+ borderLeftWidth: size * 0.09,
238
+ borderRightWidth: size * 0.16,
239
+ borderTopWidth: size * 0.2,
240
+ borderLeftColor: 'transparent',
241
+ borderRightColor: 'transparent',
242
+ borderTopColor: color,
185
243
  }}
186
244
  />
187
245
  </View>