@appsupportkit/support-sdk 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/lib/commonjs/components/BottomSheet.js +67 -47
  2. package/lib/commonjs/components/BottomSheet.js.map +1 -1
  3. package/lib/commonjs/components/LauncherButton.js +177 -0
  4. package/lib/commonjs/components/LauncherButton.js.map +1 -0
  5. package/lib/commonjs/components/SupportButton.js +54 -176
  6. package/lib/commonjs/components/SupportButton.js.map +1 -1
  7. package/lib/commonjs/components/icons.js +74 -23
  8. package/lib/commonjs/components/icons.js.map +1 -1
  9. package/lib/commonjs/components/primitives.js +183 -38
  10. package/lib/commonjs/components/primitives.js.map +1 -1
  11. package/lib/commonjs/components/screens/ComposeScreen.js +115 -58
  12. package/lib/commonjs/components/screens/ComposeScreen.js.map +1 -1
  13. package/lib/commonjs/components/screens/HomeScreen.js +170 -115
  14. package/lib/commonjs/components/screens/HomeScreen.js.map +1 -1
  15. package/lib/commonjs/components/screens/ThreadScreen.js +222 -108
  16. package/lib/commonjs/components/screens/ThreadScreen.js.map +1 -1
  17. package/lib/commonjs/index.js.map +1 -1
  18. package/lib/commonjs/theme.js +136 -17
  19. package/lib/commonjs/theme.js.map +1 -1
  20. package/lib/commonjs/types.js +7 -0
  21. package/lib/commonjs/types.js.map +1 -1
  22. package/lib/commonjs/unread.js +92 -0
  23. package/lib/commonjs/unread.js.map +1 -0
  24. package/lib/commonjs/useSupportThreads.js +286 -0
  25. package/lib/commonjs/useSupportThreads.js.map +1 -0
  26. package/lib/commonjs/utils.js +33 -0
  27. package/lib/commonjs/utils.js.map +1 -1
  28. package/lib/module/components/BottomSheet.js +68 -48
  29. package/lib/module/components/BottomSheet.js.map +1 -1
  30. package/lib/module/components/LauncherButton.js +172 -0
  31. package/lib/module/components/LauncherButton.js.map +1 -0
  32. package/lib/module/components/SupportButton.js +57 -179
  33. package/lib/module/components/SupportButton.js.map +1 -1
  34. package/lib/module/components/icons.js +72 -22
  35. package/lib/module/components/icons.js.map +1 -1
  36. package/lib/module/components/primitives.js +179 -39
  37. package/lib/module/components/primitives.js.map +1 -1
  38. package/lib/module/components/screens/ComposeScreen.js +118 -61
  39. package/lib/module/components/screens/ComposeScreen.js.map +1 -1
  40. package/lib/module/components/screens/HomeScreen.js +172 -117
  41. package/lib/module/components/screens/HomeScreen.js.map +1 -1
  42. package/lib/module/components/screens/ThreadScreen.js +228 -112
  43. package/lib/module/components/screens/ThreadScreen.js.map +1 -1
  44. package/lib/module/index.js.map +1 -1
  45. package/lib/module/theme.js +135 -16
  46. package/lib/module/theme.js.map +1 -1
  47. package/lib/module/types.js +7 -0
  48. package/lib/module/types.js.map +1 -1
  49. package/lib/module/unread.js +83 -0
  50. package/lib/module/unread.js.map +1 -0
  51. package/lib/module/useSupportThreads.js +281 -0
  52. package/lib/module/useSupportThreads.js.map +1 -0
  53. package/lib/module/utils.js +30 -0
  54. package/lib/module/utils.js.map +1 -1
  55. package/lib/typescript/components/BottomSheet.d.ts.map +1 -1
  56. package/lib/typescript/components/LauncherButton.d.ts +14 -0
  57. package/lib/typescript/components/LauncherButton.d.ts.map +1 -0
  58. package/lib/typescript/components/SupportButton.d.ts +3 -3
  59. package/lib/typescript/components/SupportButton.d.ts.map +1 -1
  60. package/lib/typescript/components/icons.d.ts +13 -2
  61. package/lib/typescript/components/icons.d.ts.map +1 -1
  62. package/lib/typescript/components/primitives.d.ts +35 -4
  63. package/lib/typescript/components/primitives.d.ts.map +1 -1
  64. package/lib/typescript/components/screens/ComposeScreen.d.ts.map +1 -1
  65. package/lib/typescript/components/screens/HomeScreen.d.ts +3 -2
  66. package/lib/typescript/components/screens/HomeScreen.d.ts.map +1 -1
  67. package/lib/typescript/components/screens/ThreadScreen.d.ts.map +1 -1
  68. package/lib/typescript/index.d.ts +1 -1
  69. package/lib/typescript/index.d.ts.map +1 -1
  70. package/lib/typescript/theme.d.ts +44 -3
  71. package/lib/typescript/theme.d.ts.map +1 -1
  72. package/lib/typescript/types.d.ts +22 -0
  73. package/lib/typescript/types.d.ts.map +1 -1
  74. package/lib/typescript/unread.d.ts +22 -0
  75. package/lib/typescript/unread.d.ts.map +1 -0
  76. package/lib/typescript/useSupportThreads.d.ts +28 -0
  77. package/lib/typescript/useSupportThreads.d.ts.map +1 -0
  78. package/lib/typescript/utils.d.ts +5 -0
  79. package/lib/typescript/utils.d.ts.map +1 -1
  80. package/package.json +2 -2
  81. package/src/components/BottomSheet.tsx +61 -43
  82. package/src/components/LauncherButton.tsx +217 -0
  83. package/src/components/SupportButton.tsx +52 -196
  84. package/src/components/icons.tsx +77 -19
  85. package/src/components/primitives.tsx +217 -36
  86. package/src/components/screens/ComposeScreen.tsx +131 -61
  87. package/src/components/screens/HomeScreen.tsx +168 -117
  88. package/src/components/screens/ThreadScreen.tsx +289 -119
  89. package/src/index.tsx +1 -0
  90. package/src/theme.ts +165 -18
  91. package/src/types.ts +24 -0
  92. package/src/unread.ts +91 -0
  93. package/src/useSupportThreads.ts +372 -0
  94. package/src/utils.ts +33 -0
@@ -1,9 +1,6 @@
1
- import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
- import { AppState, Pressable, Text, View } from 'react-native';
1
+ import { useCallback, useMemo, useState } from 'react';
2
+ import { Text, View } from 'react-native';
3
3
  import {
4
- getThreads,
5
- getUnreadCount,
6
- markRead,
7
4
  postMessage,
8
5
  registerPush,
9
6
  submitQuery,
@@ -13,9 +10,10 @@ import {
13
10
  import { ensurePushPermission, isPushAvailable } from '../push';
14
11
  import { resolveTheme } from '../theme';
15
12
  import type { QueryType, SupportButtonProps, Thread } from '../types';
13
+ import { IDLE_POLL_MS, useSupportThreads } from '../useSupportThreads';
16
14
  import { validateProps } from '../utils';
17
15
  import { BottomSheet } from './BottomSheet';
18
- import { BubbleMark } from './icons';
16
+ import { LauncherButton } from './LauncherButton';
19
17
  import { ComposeScreen } from './screens/ComposeScreen';
20
18
  import { HomeScreen } from './screens/HomeScreen';
21
19
  import { ThreadScreen } from './screens/ThreadScreen';
@@ -25,23 +23,17 @@ type Screen =
25
23
  | { name: 'compose'; type: QueryType }
26
24
  | { name: 'thread'; threadId: string };
27
25
 
28
- // Poll cadence by context: fast where latency is felt (staring at a thread
29
- // waiting for a reply), slow when the sheet is closed — and the idle tick
30
- // uses the count-only endpoint, so it costs ~18 bytes instead of the full
31
- // thread list.
32
- const THREAD_POLL_MS = 5_000;
33
- const SHEET_POLL_MS = 15_000;
34
- const IDLE_POLL_MS = 60_000;
35
- const MIN_POLL_MS = 3_000;
36
-
37
26
  /**
38
- * The single entry point of the SDK: a button with an unread badge that
39
- * opens the support bottom sheet. Polls get-threads on mount and whenever
40
- * 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.
41
30
  */
42
31
  export function SupportButton(props: SupportButtonProps) {
43
32
  const validationError = validateProps(props);
44
- const theme = resolveTheme(props.theme);
33
+ const theme = useMemo(
34
+ () => resolveTheme(props.theme, props.accentColor),
35
+ [props.theme, props.accentColor],
36
+ );
45
37
 
46
38
  const config: ApiConfig = useMemo(
47
39
  () => ({
@@ -54,86 +46,17 @@ export function SupportButton(props: SupportButtonProps) {
54
46
 
55
47
  const [sheetOpen, setSheetOpen] = useState(false);
56
48
  const [screen, setScreen] = useState<Screen>({ name: 'home' });
57
- const [threads, setThreads] = useState<Thread[] | null>(null);
58
- const [totalUnread, setTotalUnread] = useState(0);
59
- const [loading, setLoading] = useState(false);
60
- const [error, setError] = useState<string | null>(null);
61
- const mounted = useRef(true);
62
-
63
- useEffect(() => {
64
- mounted.current = true;
65
- return () => {
66
- mounted.current = false;
67
- };
68
- }, []);
69
-
70
- const refresh = useCallback(async () => {
71
- if (validationError) return;
72
- setLoading(true);
73
- setError(null);
74
- try {
75
- const data = await getThreads(config, props.user.revenuecatId);
76
- if (!mounted.current) return;
77
- setThreads(data.threads);
78
- setTotalUnread(data.total_unread);
79
- } catch (e) {
80
- if (!mounted.current) return;
81
- setError(
82
- e instanceof SupportApiError && e.offline
83
- ? "You're offline — check your connection"
84
- : "Couldn't load threads",
85
- );
86
- } finally {
87
- if (mounted.current) setLoading(false);
88
- }
89
- }, [config, props.user.revenuecatId, validationError]);
90
-
91
- /** Badge-only refresh — the cheap endpoint used while idle. */
92
- const refreshBadge = useCallback(async () => {
93
- if (validationError) return;
94
- try {
95
- const data = await getUnreadCount(config, props.user.revenuecatId);
96
- if (mounted.current) setTotalUnread(data.total_unread);
97
- } catch {
98
- // Best-effort: the next tick or the next sheet open reconciles.
99
- }
100
- }, [config, props.user.revenuecatId, validationError]);
101
-
102
- // Poll on mount…
103
- useEffect(() => {
104
- void refresh();
105
- }, [refresh]);
106
-
107
- // …then adapt the cadence to what the user is looking at, and refresh
108
- // whenever the app returns to the foreground.
109
- const idlePollMs = props.pollIntervalMs ?? IDLE_POLL_MS;
110
- const screenName = screen.name;
111
- useEffect(() => {
112
- const idle = !sheetOpen;
113
- const intervalMs = idle
114
- ? idlePollMs
115
- : screenName === 'thread'
116
- ? THREAD_POLL_MS
117
- : SHEET_POLL_MS;
118
-
119
- // While idle only the badge matters, so use the count-only endpoint.
120
- const tick = () => void (idle ? refreshBadge() : refresh());
121
-
122
- // Clamped so a typo like `pollIntervalMs={30}` can't hammer the API;
123
- // 0 (or negative) disables idle polling entirely.
124
- const timer =
125
- intervalMs > 0 ? setInterval(tick, Math.max(intervalMs, MIN_POLL_MS)) : null;
126
49
 
127
- const appStateSub = AppState.addEventListener('change', (state) => {
128
- if (state === 'active') tick();
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,
129
58
  });
130
59
 
131
- return () => {
132
- if (timer !== null) clearInterval(timer);
133
- appStateSub.remove();
134
- };
135
- }, [sheetOpen, screenName, idlePollMs, refresh, refreshBadge]);
136
-
137
60
  /**
138
61
  * Asks for notification permission the moment the sheet opens, then
139
62
  * reports the outcome (including denials) so the dashboard knows whether
@@ -141,7 +64,7 @@ export function SupportButton(props: SupportButtonProps) {
141
64
  * support flow waits on it or breaks if it fails.
142
65
  */
143
66
  const syncPushPermission = useCallback(async () => {
144
- if (validationError || !isPushAvailable) return;
67
+ if (validationError !== null || !isPushAvailable) return;
145
68
  try {
146
69
  const registration = await ensurePushPermission(props.expoProjectId);
147
70
  await registerPush(config, {
@@ -155,11 +78,9 @@ export function SupportButton(props: SupportButtonProps) {
155
78
  }
156
79
  }, [config, props.user.revenuecatId, props.expoProjectId, validationError]);
157
80
 
158
- // …and on sheet open.
159
81
  function openSheet() {
160
82
  setScreen({ name: 'home' });
161
83
  setSheetOpen(true);
162
- void refresh();
163
84
  // The OS prompt appears right as the sheet opens; if the user declines,
164
85
  // everything below carries on exactly as before.
165
86
  void syncPushPermission();
@@ -170,7 +91,7 @@ export function SupportButton(props: SupportButtonProps) {
170
91
  setScreen({ name: 'home' });
171
92
  }
172
93
 
173
- if (validationError) {
94
+ if (validationError !== null) {
174
95
  // Dev-mode: loud and actionable. Production: render nothing rather than
175
96
  // a broken button.
176
97
  if (__DEV__) {
@@ -193,26 +114,6 @@ export function SupportButton(props: SupportButtonProps) {
193
114
  return null;
194
115
  }
195
116
 
196
- function openThread(thread: Thread) {
197
- setScreen({ name: 'thread', threadId: thread.id });
198
- if (thread.unread_count > 0) {
199
- // Optimistically clear the badge; persist via mark-read.
200
- setThreads(
201
- (prev) =>
202
- prev?.map((t) =>
203
- t.id === thread.id ? { ...t, unread_count: 0 } : t,
204
- ) ?? null,
205
- );
206
- setTotalUnread((n) => Math.max(0, n - thread.unread_count));
207
- void markRead(config, {
208
- queryId: thread.id,
209
- revenuecatId: props.user.revenuecatId,
210
- }).catch(() => {
211
- // Non-fatal: the next get-threads reconciles the badge.
212
- });
213
- }
214
- }
215
-
216
117
  function writeErrorMessage(e: unknown): string {
217
118
  if (e instanceof SupportApiError) {
218
119
  if (e.status === 429) return 'Too many requests — try again later';
@@ -234,7 +135,7 @@ export function SupportButton(props: SupportButtonProps) {
234
135
  revenuecatId: props.user.revenuecatId,
235
136
  appVersion: props.user.appVersion,
236
137
  });
237
- void refresh();
138
+ void reload();
238
139
  return null;
239
140
  } catch (e) {
240
141
  return writeErrorMessage(e);
@@ -251,84 +152,48 @@ export function SupportButton(props: SupportButtonProps) {
251
152
  revenuecatId: props.user.revenuecatId,
252
153
  message,
253
154
  });
254
- await refresh();
155
+ await reload();
255
156
  return null;
256
157
  } catch (e) {
257
158
  return writeErrorMessage(e);
258
159
  }
259
160
  }
260
161
 
261
- const activeThread =
162
+ const activeThread: Thread | null =
262
163
  screen.name === 'thread'
263
- ? (threads ?? []).find((t) => t.id === screen.threadId) ?? null
164
+ ? ((threads ?? []).find((t) => t.id === screen.threadId) ?? null)
264
165
  : null;
265
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
+
266
183
  return (
267
184
  <>
268
- <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}
269
192
  onPress={openSheet}
270
- accessibilityRole="button"
271
- accessibilityLabel={
272
- totalUnread > 0
273
- ? `Support, ${totalUnread} unread ${totalUnread === 1 ? 'reply' : 'replies'}`
274
- : 'Support'
275
- }
276
- style={({ pressed }) => ({
277
- width: 44,
278
- height: 44,
279
- borderRadius: 22,
280
- borderWidth: 1,
281
- borderColor: theme.colors.border,
282
- backgroundColor: pressed
283
- ? theme.colors.surfaceHover
284
- : theme.colors.surface,
285
- alignItems: 'center',
286
- justifyContent: 'center',
287
- })}
288
- >
289
- <BubbleMark color={theme.colors.textPrimary} size={18} />
290
- {totalUnread > 0 && (
291
- <View
292
- style={{
293
- position: 'absolute',
294
- top: -3,
295
- right: -3,
296
- minWidth: 16,
297
- height: 16,
298
- borderRadius: 8,
299
- paddingHorizontal: 4,
300
- backgroundColor: theme.colors.accent,
301
- alignItems: 'center',
302
- justifyContent: 'center',
303
- }}
304
- >
305
- <Text
306
- style={{
307
- fontSize: 10,
308
- fontWeight: '600',
309
- color: theme.colors.accentForeground,
310
- fontVariant: ['tabular-nums'],
311
- }}
312
- >
313
- {totalUnread > 9 ? '9+' : totalUnread}
314
- </Text>
315
- </View>
316
- )}
317
- </Pressable>
193
+ />
318
194
 
319
195
  <BottomSheet visible={sheetOpen} onClose={closeSheet} theme={theme}>
320
- {screen.name === 'home' && (
321
- <HomeScreen
322
- theme={theme}
323
- threads={threads}
324
- loading={loading}
325
- error={error}
326
- onRetry={() => void refresh()}
327
- onCompose={(type) => setScreen({ name: 'compose', type })}
328
- onOpenThread={openThread}
329
- onClose={closeSheet}
330
- />
331
- )}
196
+ {screen.name === 'home' && home}
332
197
  {screen.name === 'compose' && (
333
198
  <ComposeScreen
334
199
  theme={theme}
@@ -338,7 +203,7 @@ export function SupportButton(props: SupportButtonProps) {
338
203
  />
339
204
  )}
340
205
  {screen.name === 'thread' &&
341
- (activeThread ? (
206
+ (activeThread !== null ? (
342
207
  <ThreadScreen
343
208
  theme={theme}
344
209
  thread={activeThread}
@@ -346,16 +211,7 @@ export function SupportButton(props: SupportButtonProps) {
346
211
  onSend={(message) => handleSend(activeThread.id, message)}
347
212
  />
348
213
  ) : (
349
- <HomeScreen
350
- theme={theme}
351
- threads={threads}
352
- loading={loading}
353
- error={error}
354
- onRetry={() => void refresh()}
355
- onCompose={(type) => setScreen({ name: 'compose', type })}
356
- onOpenThread={openThread}
357
- onClose={closeSheet}
358
- />
214
+ home
359
215
  ))}
360
216
  </BottomSheet>
361
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>