@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
@@ -0,0 +1,372 @@
1
+ // Threads + unread state for the SDK: fetching, poll cadence, and read
2
+ // tracking. Kept out of SupportButton so the rules below live in one place.
3
+ //
4
+ // Three invariants make the badge trustworthy:
5
+ //
6
+ // 1. Responses are applied in issue order (`appliedSeq`), so a slow poll
7
+ // can never overwrite a newer one.
8
+ // 2. The badge-only endpoint returns a bare number that cannot be
9
+ // reconciled against local reads, so its answer is dropped when a read
10
+ // happened while it was in flight (`readEpoch`), and it isn't used at
11
+ // all while any `mark-read` is still unconfirmed — the full endpoint is
12
+ // polled instead, since that payload *can* be reconciled.
13
+ // 3. `mark-read` failures are retried instead of swallowed, and the local
14
+ // watermark holds the UI correct in the meantime.
15
+ import { useCallback, useEffect, useRef, useState } from 'react';
16
+ import { AppState } from 'react-native';
17
+ import {
18
+ getThreads,
19
+ getUnreadCount,
20
+ markRead,
21
+ SupportApiError,
22
+ type ApiConfig,
23
+ } from './api';
24
+ import type { Thread } from './types';
25
+ import {
26
+ latestAdminMessageAt,
27
+ pruneWatermarks,
28
+ reconcileThreads,
29
+ sumUnread,
30
+ type ReadWatermarks,
31
+ } from './unread';
32
+
33
+ // Poll cadence by context: fast where latency is felt (staring at a thread
34
+ // waiting for a reply), slow when the sheet is closed — and the idle tick
35
+ // uses the count-only endpoint, so it costs ~18 bytes instead of the full
36
+ // thread list.
37
+ const THREAD_POLL_MS = 5_000;
38
+ const SHEET_POLL_MS = 15_000;
39
+ export const IDLE_POLL_MS = 60_000;
40
+ const MIN_POLL_MS = 3_000;
41
+
42
+ // A failed mark-read used to be dropped on the floor, which resurrected the
43
+ // badge on the next poll. Retry a few times with backoff; anything still
44
+ // unconfirmed after that is picked up by the next poll tick or foreground.
45
+ const MARK_READ_MAX_ATTEMPTS = 4;
46
+ const MARK_READ_BACKOFF_MS = 1_500;
47
+
48
+ /**
49
+ * Load failures the user can act on say so; the rest stay neutral. A 401 is
50
+ * an integration mistake — the end user retrying forever won't fix a key
51
+ * that doesn't match the app — so the real cause is logged for whoever is
52
+ * running the build instead of hidden behind "couldn't load".
53
+ */
54
+ function loadErrorMessage(e: unknown): string {
55
+ if (e instanceof SupportApiError) {
56
+ if (e.offline) return "You're offline — check your connection";
57
+ if (e.status === 401 || e.status === 403) {
58
+ if (__DEV__) {
59
+ console.error(
60
+ 'SupportButton: the support API rejected this app (401). Check that ' +
61
+ '`apiKey` is the key shown when the app was registered and that ' +
62
+ '`appName` matches that app exactly.',
63
+ );
64
+ }
65
+ return 'Support is unavailable right now';
66
+ }
67
+ if (e.status === 429) return 'Too many requests — try again in a bit';
68
+ }
69
+ return "Couldn't load your conversations";
70
+ }
71
+
72
+ interface ReadAttempt {
73
+ /** Newest reply timestamp this thread's receipt still has to cover. */
74
+ target: number;
75
+ /** Timestamp covered by the request currently in flight, if any. */
76
+ inFlight: number | null;
77
+ attempts: number;
78
+ timer: ReturnType<typeof setTimeout> | null;
79
+ }
80
+
81
+ export interface SupportThreadsState {
82
+ /** Server threads with unread counts narrowed by local reads. */
83
+ threads: Thread[] | null;
84
+ totalUnread: number;
85
+ /** First load only — there is nothing cached to show yet. */
86
+ loading: boolean;
87
+ /** User-pulled refresh with content already on screen. */
88
+ refreshing: boolean;
89
+ /** Set only when there is nothing cached; background failures stay quiet. */
90
+ error: string | null;
91
+ /** User-initiated refresh (pull-to-refresh, retry button). */
92
+ refresh: () => void;
93
+ /** Awaited by send/submit handlers so the list reflects the write. */
94
+ reload: () => Promise<void>;
95
+ }
96
+
97
+ export function useSupportThreads({
98
+ config,
99
+ revenuecatId,
100
+ enabled,
101
+ sheetOpen,
102
+ activeThreadId,
103
+ idlePollMs,
104
+ }: {
105
+ config: ApiConfig;
106
+ revenuecatId: string;
107
+ /** False when props failed validation — no requests are made. */
108
+ enabled: boolean;
109
+ sheetOpen: boolean;
110
+ activeThreadId: string | null;
111
+ idlePollMs: number;
112
+ }): SupportThreadsState {
113
+ const [threads, setThreads] = useState<Thread[] | null>(null);
114
+ const [totalUnread, setTotalUnread] = useState(0);
115
+ const [loading, setLoading] = useState(false);
116
+ const [refreshing, setRefreshing] = useState(false);
117
+ const [error, setError] = useState<string | null>(null);
118
+
119
+ const mounted = useRef(true);
120
+ /** Last server payload, before local read reconciliation. */
121
+ const rawThreads = useRef<Thread[] | null>(null);
122
+ const watermarks = useRef<ReadWatermarks>(new Map());
123
+ const reads = useRef(new Map<string, ReadAttempt>());
124
+ const requestSeq = useRef(0);
125
+ const appliedSeq = useRef(0);
126
+ const readEpoch = useRef(0);
127
+ const activeThreadIdRef = useRef<string | null>(activeThreadId);
128
+
129
+ useEffect(() => {
130
+ activeThreadIdRef.current = activeThreadId;
131
+ }, [activeThreadId]);
132
+
133
+ useEffect(() => {
134
+ mounted.current = true;
135
+ const timers = reads.current;
136
+ return () => {
137
+ mounted.current = false;
138
+ for (const attempt of timers.values()) {
139
+ if (attempt.timer !== null) clearTimeout(attempt.timer);
140
+ }
141
+ timers.clear();
142
+ };
143
+ }, []);
144
+
145
+ const publish = useCallback(() => {
146
+ const raw = rawThreads.current;
147
+ if (raw === null) return;
148
+ const reconciled = reconcileThreads(raw, watermarks.current);
149
+ setThreads(reconciled);
150
+ setTotalUnread(sumUnread(reconciled));
151
+ }, []);
152
+
153
+ /** POSTs the receipt, retrying transient failures with backoff. */
154
+ const sendRead = useCallback(
155
+ (threadId: string) => {
156
+ const attempt = reads.current.get(threadId);
157
+ if (!attempt || attempt.inFlight !== null) return;
158
+ const covers = attempt.target;
159
+ attempt.inFlight = covers;
160
+ attempt.timer = null;
161
+
162
+ markRead(config, { queryId: threadId, revenuecatId })
163
+ .then(() => {
164
+ if (!mounted.current) return;
165
+ const current = reads.current.get(threadId);
166
+ if (!current) return;
167
+ current.inFlight = null;
168
+ // A reply that landed while this request was in flight isn't
169
+ // covered by the receipt it just wrote — send one more.
170
+ if (current.target > covers) {
171
+ current.attempts = 0;
172
+ sendRead(threadId);
173
+ return;
174
+ }
175
+ reads.current.delete(threadId);
176
+ // The receipt now exists server-side, so any response still in
177
+ // flight predates it and must not be trusted for unread counts.
178
+ readEpoch.current += 1;
179
+ })
180
+ .catch(() => {
181
+ if (!mounted.current) return;
182
+ const current = reads.current.get(threadId);
183
+ if (!current) return;
184
+ current.inFlight = null;
185
+ current.attempts += 1;
186
+ if (current.attempts >= MARK_READ_MAX_ATTEMPTS) return;
187
+ current.timer = setTimeout(
188
+ () => sendRead(threadId),
189
+ MARK_READ_BACKOFF_MS * 2 ** (current.attempts - 1),
190
+ );
191
+ });
192
+ },
193
+ [config, revenuecatId],
194
+ );
195
+
196
+ /** Re-drives receipts that gave up earlier (next poll tick / foreground). */
197
+ const flushReads = useCallback(() => {
198
+ for (const [threadId, attempt] of reads.current) {
199
+ if (attempt.inFlight !== null || attempt.timer !== null) continue;
200
+ attempt.attempts = 0;
201
+ sendRead(threadId);
202
+ }
203
+ }, [sendRead]);
204
+
205
+ /**
206
+ * Records that the user has seen every reply currently in `threadId` and
207
+ * persists it. Idempotent: re-marking a thread with nothing new is a
208
+ * no-op, so the 5s in-thread poll doesn't spam the endpoint.
209
+ */
210
+ const markThreadRead = useCallback(
211
+ (threadId: string) => {
212
+ const thread = rawThreads.current?.find((t) => t.id === threadId);
213
+ if (!thread) return;
214
+ const latest = latestAdminMessageAt(thread);
215
+ if (latest === 0) return;
216
+ if (latest <= (watermarks.current.get(threadId) ?? 0)) return;
217
+
218
+ watermarks.current.set(threadId, latest);
219
+ // Invalidate the bare counts of any request already in flight.
220
+ readEpoch.current += 1;
221
+ publish();
222
+
223
+ const attempt = reads.current.get(threadId);
224
+ if (attempt) attempt.target = latest;
225
+ else {
226
+ reads.current.set(threadId, {
227
+ target: latest,
228
+ inFlight: null,
229
+ attempts: 0,
230
+ timer: null,
231
+ });
232
+ }
233
+ sendRead(threadId);
234
+ },
235
+ [publish, sendRead],
236
+ );
237
+
238
+ const fetchThreads = useCallback(
239
+ async (mode: 'initial' | 'user' | 'background') => {
240
+ if (!enabled) return;
241
+ const seq = (requestSeq.current += 1);
242
+ // With nothing cached there is no list to pull down, so a retry shows
243
+ // the skeleton rather than a spinner nobody can see.
244
+ const blank = rawThreads.current === null;
245
+ const spinner = mode === 'user' && !blank;
246
+ const skeleton = mode !== 'background' && blank;
247
+ if (spinner) setRefreshing(true);
248
+ if (skeleton) setLoading(true);
249
+
250
+ try {
251
+ const data = await getThreads(config, revenuecatId);
252
+ if (!mounted.current) return;
253
+ // A slower earlier request must never clobber a newer answer.
254
+ if (seq <= appliedSeq.current) return;
255
+ appliedSeq.current = seq;
256
+
257
+ rawThreads.current = data.threads;
258
+ watermarks.current = pruneWatermarks(watermarks.current, data.threads);
259
+ setError(null);
260
+ publish();
261
+
262
+ // Replies that arrived while the user is sitting in the thread are
263
+ // read on arrival — otherwise the badge climbed while they read.
264
+ const active = activeThreadIdRef.current;
265
+ if (active !== null) markThreadRead(active);
266
+ flushReads();
267
+ } catch (e) {
268
+ if (!mounted.current) return;
269
+ // A failed background poll must not blow away cached threads.
270
+ if (rawThreads.current === null) setError(loadErrorMessage(e));
271
+ } finally {
272
+ if (mounted.current) {
273
+ if (spinner) setRefreshing(false);
274
+ if (skeleton) setLoading(false);
275
+ }
276
+ }
277
+ },
278
+ [config, enabled, revenuecatId, publish, markThreadRead, flushReads],
279
+ );
280
+
281
+ /** Badge-only refresh — the cheap endpoint, used while idle. */
282
+ const fetchCount = useCallback(async () => {
283
+ if (!enabled) return;
284
+ const seq = (requestSeq.current += 1);
285
+ const epoch = readEpoch.current;
286
+ try {
287
+ const data = await getUnreadCount(config, revenuecatId);
288
+ if (!mounted.current) return;
289
+ // This payload is just a number: there is no way to subtract reads
290
+ // that happened while it was in flight, so discard it instead.
291
+ if (readEpoch.current !== epoch) return;
292
+ if (seq <= appliedSeq.current) return;
293
+ appliedSeq.current = seq;
294
+ setTotalUnread(data.total_unread);
295
+ } catch {
296
+ // Best effort: the next tick or the next sheet open reconciles.
297
+ }
298
+ }, [config, enabled, revenuecatId]);
299
+
300
+ // Initial load.
301
+ useEffect(() => {
302
+ void fetchThreads('initial');
303
+ }, [fetchThreads]);
304
+
305
+ // Opening the sheet always shows fresh data.
306
+ useEffect(() => {
307
+ if (sheetOpen) void fetchThreads('background');
308
+ }, [sheetOpen, fetchThreads]);
309
+
310
+ // Opening a thread marks it read immediately, before the next poll.
311
+ useEffect(() => {
312
+ if (activeThreadId !== null) markThreadRead(activeThreadId);
313
+ }, [activeThreadId, markThreadRead]);
314
+
315
+ // Cadence follows what the user is looking at; the app returning to the
316
+ // foreground always ticks.
317
+ useEffect(() => {
318
+ if (!enabled) return;
319
+ const idle = !sheetOpen;
320
+ const intervalMs = idle
321
+ ? idlePollMs
322
+ : activeThreadId !== null
323
+ ? THREAD_POLL_MS
324
+ : SHEET_POLL_MS;
325
+
326
+ const tick = () => {
327
+ // While a receipt is unconfirmed the count-only endpoint would report
328
+ // replies the user has already read, so pay for the full payload.
329
+ if (idle && reads.current.size === 0) void fetchCount();
330
+ else void fetchThreads('background');
331
+ };
332
+
333
+ // Clamped so a typo like `pollIntervalMs={30}` can't hammer the API;
334
+ // 0 (or negative) disables idle polling entirely.
335
+ const timer =
336
+ intervalMs > 0
337
+ ? setInterval(tick, Math.max(intervalMs, MIN_POLL_MS))
338
+ : null;
339
+
340
+ const appStateSub = AppState.addEventListener('change', (state) => {
341
+ if (state === 'active') tick();
342
+ });
343
+
344
+ return () => {
345
+ if (timer !== null) clearInterval(timer);
346
+ appStateSub.remove();
347
+ };
348
+ }, [
349
+ enabled,
350
+ sheetOpen,
351
+ activeThreadId,
352
+ idlePollMs,
353
+ fetchThreads,
354
+ fetchCount,
355
+ ]);
356
+
357
+ const refresh = useCallback(() => {
358
+ void fetchThreads('user');
359
+ }, [fetchThreads]);
360
+
361
+ const reload = useCallback(() => fetchThreads('background'), [fetchThreads]);
362
+
363
+ return {
364
+ threads,
365
+ totalUnread,
366
+ loading,
367
+ refreshing,
368
+ error,
369
+ refresh,
370
+ reload,
371
+ };
372
+ }
package/src/utils.ts CHANGED
@@ -24,6 +24,39 @@ export function formatDate(iso: string): string {
24
24
  });
25
25
  }
26
26
 
27
+ /** "14:32" in the device's locale — shown under a group of messages. */
28
+ export function formatTime(iso: string): string {
29
+ return new Date(iso).toLocaleTimeString(undefined, {
30
+ hour: 'numeric',
31
+ minute: '2-digit',
32
+ });
33
+ }
34
+
35
+ export function isSameDay(a: string, b: string): boolean {
36
+ const first = new Date(a);
37
+ const second = new Date(b);
38
+ return (
39
+ first.getFullYear() === second.getFullYear() &&
40
+ first.getMonth() === second.getMonth() &&
41
+ first.getDate() === second.getDate()
42
+ );
43
+ }
44
+
45
+ /** Date separator label: Today / Yesterday / "Mon, 5 Jan". */
46
+ export function dayLabel(iso: string): string {
47
+ const now = new Date();
48
+ if (isSameDay(iso, now.toISOString())) return 'Today';
49
+ const yesterday = new Date(now.getTime() - 86_400_000);
50
+ if (isSameDay(iso, yesterday.toISOString())) return 'Yesterday';
51
+ const date = new Date(iso);
52
+ return date.toLocaleDateString(undefined, {
53
+ weekday: 'short',
54
+ month: 'short',
55
+ day: 'numeric',
56
+ ...(date.getFullYear() === now.getFullYear() ? {} : { year: 'numeric' }),
57
+ });
58
+ }
59
+
27
60
  /** Dev-mode prop validation with actionable messages. */
28
61
  export function validateProps(props: {
29
62
  appName?: unknown;