@appsupportkit/support-sdk 0.1.2 → 0.3.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 (224) hide show
  1. package/lib/commonjs/api.js +17 -2
  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 +168 -190
  8. package/lib/commonjs/components/SupportButton.js.map +1 -1
  9. package/lib/commonjs/components/icons.js +110 -23
  10. package/lib/commonjs/components/icons.js.map +1 -1
  11. package/lib/commonjs/components/primitives.js +191 -44
  12. package/lib/commonjs/components/primitives.js.map +1 -1
  13. package/lib/commonjs/components/screens/ComposeScreen.js +108 -60
  14. package/lib/commonjs/components/screens/ComposeScreen.js.map +1 -1
  15. package/lib/commonjs/components/screens/HomeScreen.js +221 -118
  16. package/lib/commonjs/components/screens/HomeScreen.js.map +1 -1
  17. package/lib/commonjs/components/screens/LanguageScreen.js +150 -0
  18. package/lib/commonjs/components/screens/LanguageScreen.js.map +1 -0
  19. package/lib/commonjs/components/screens/ThreadScreen.js +237 -111
  20. package/lib/commonjs/components/screens/ThreadScreen.js.map +1 -1
  21. package/lib/commonjs/i18n/index.js +101 -0
  22. package/lib/commonjs/i18n/index.js.map +1 -0
  23. package/lib/commonjs/i18n/languages.js +95 -0
  24. package/lib/commonjs/i18n/languages.js.map +1 -0
  25. package/lib/commonjs/i18n/strings/de.js +72 -0
  26. package/lib/commonjs/i18n/strings/de.js.map +1 -0
  27. package/lib/commonjs/i18n/strings/en.js +88 -0
  28. package/lib/commonjs/i18n/strings/en.js.map +1 -0
  29. package/lib/commonjs/i18n/strings/es.js +72 -0
  30. package/lib/commonjs/i18n/strings/es.js.map +1 -0
  31. package/lib/commonjs/i18n/strings/fr.js +72 -0
  32. package/lib/commonjs/i18n/strings/fr.js.map +1 -0
  33. package/lib/commonjs/i18n/strings/hi.js +72 -0
  34. package/lib/commonjs/i18n/strings/hi.js.map +1 -0
  35. package/lib/commonjs/i18n/strings/it.js +72 -0
  36. package/lib/commonjs/i18n/strings/it.js.map +1 -0
  37. package/lib/commonjs/i18n/strings/ja.js +72 -0
  38. package/lib/commonjs/i18n/strings/ja.js.map +1 -0
  39. package/lib/commonjs/i18n/strings/ko.js +72 -0
  40. package/lib/commonjs/i18n/strings/ko.js.map +1 -0
  41. package/lib/commonjs/i18n/strings/nl.js +72 -0
  42. package/lib/commonjs/i18n/strings/nl.js.map +1 -0
  43. package/lib/commonjs/i18n/strings/pl.js +72 -0
  44. package/lib/commonjs/i18n/strings/pl.js.map +1 -0
  45. package/lib/commonjs/i18n/strings/pt.js +72 -0
  46. package/lib/commonjs/i18n/strings/pt.js.map +1 -0
  47. package/lib/commonjs/i18n/strings/ru.js +72 -0
  48. package/lib/commonjs/i18n/strings/ru.js.map +1 -0
  49. package/lib/commonjs/i18n/strings/tr.js +72 -0
  50. package/lib/commonjs/i18n/strings/tr.js.map +1 -0
  51. package/lib/commonjs/i18n/strings/zh.js +72 -0
  52. package/lib/commonjs/i18n/strings/zh.js.map +1 -0
  53. package/lib/commonjs/index.js +31 -0
  54. package/lib/commonjs/index.js.map +1 -1
  55. package/lib/commonjs/theme.js +136 -17
  56. package/lib/commonjs/theme.js.map +1 -1
  57. package/lib/commonjs/types.js +18 -0
  58. package/lib/commonjs/types.js.map +1 -1
  59. package/lib/commonjs/unread.js +92 -0
  60. package/lib/commonjs/unread.js.map +1 -0
  61. package/lib/commonjs/useSupportThreads.js +296 -0
  62. package/lib/commonjs/useSupportThreads.js.map +1 -0
  63. package/lib/commonjs/utils.js +58 -10
  64. package/lib/commonjs/utils.js.map +1 -1
  65. package/lib/module/api.js +16 -2
  66. package/lib/module/api.js.map +1 -1
  67. package/lib/module/components/BottomSheet.js +68 -48
  68. package/lib/module/components/BottomSheet.js.map +1 -1
  69. package/lib/module/components/LauncherButton.js +172 -0
  70. package/lib/module/components/LauncherButton.js.map +1 -0
  71. package/lib/module/components/SupportButton.js +172 -194
  72. package/lib/module/components/SupportButton.js.map +1 -1
  73. package/lib/module/components/icons.js +107 -22
  74. package/lib/module/components/icons.js.map +1 -1
  75. package/lib/module/components/primitives.js +187 -45
  76. package/lib/module/components/primitives.js.map +1 -1
  77. package/lib/module/components/screens/ComposeScreen.js +114 -64
  78. package/lib/module/components/screens/ComposeScreen.js.map +1 -1
  79. package/lib/module/components/screens/HomeScreen.js +224 -121
  80. package/lib/module/components/screens/HomeScreen.js.map +1 -1
  81. package/lib/module/components/screens/LanguageScreen.js +147 -0
  82. package/lib/module/components/screens/LanguageScreen.js.map +1 -0
  83. package/lib/module/components/screens/ThreadScreen.js +243 -115
  84. package/lib/module/components/screens/ThreadScreen.js.map +1 -1
  85. package/lib/module/i18n/index.js +56 -0
  86. package/lib/module/i18n/index.js.map +1 -0
  87. package/lib/module/i18n/languages.js +88 -0
  88. package/lib/module/i18n/languages.js.map +1 -0
  89. package/lib/module/i18n/strings/de.js +68 -0
  90. package/lib/module/i18n/strings/de.js.map +1 -0
  91. package/lib/module/i18n/strings/en.js +84 -0
  92. package/lib/module/i18n/strings/en.js.map +1 -0
  93. package/lib/module/i18n/strings/es.js +68 -0
  94. package/lib/module/i18n/strings/es.js.map +1 -0
  95. package/lib/module/i18n/strings/fr.js +68 -0
  96. package/lib/module/i18n/strings/fr.js.map +1 -0
  97. package/lib/module/i18n/strings/hi.js +68 -0
  98. package/lib/module/i18n/strings/hi.js.map +1 -0
  99. package/lib/module/i18n/strings/it.js +68 -0
  100. package/lib/module/i18n/strings/it.js.map +1 -0
  101. package/lib/module/i18n/strings/ja.js +68 -0
  102. package/lib/module/i18n/strings/ja.js.map +1 -0
  103. package/lib/module/i18n/strings/ko.js +68 -0
  104. package/lib/module/i18n/strings/ko.js.map +1 -0
  105. package/lib/module/i18n/strings/nl.js +68 -0
  106. package/lib/module/i18n/strings/nl.js.map +1 -0
  107. package/lib/module/i18n/strings/pl.js +68 -0
  108. package/lib/module/i18n/strings/pl.js.map +1 -0
  109. package/lib/module/i18n/strings/pt.js +68 -0
  110. package/lib/module/i18n/strings/pt.js.map +1 -0
  111. package/lib/module/i18n/strings/ru.js +68 -0
  112. package/lib/module/i18n/strings/ru.js.map +1 -0
  113. package/lib/module/i18n/strings/tr.js +68 -0
  114. package/lib/module/i18n/strings/tr.js.map +1 -0
  115. package/lib/module/i18n/strings/zh.js +68 -0
  116. package/lib/module/i18n/strings/zh.js.map +1 -0
  117. package/lib/module/index.js +1 -0
  118. package/lib/module/index.js.map +1 -1
  119. package/lib/module/theme.js +135 -16
  120. package/lib/module/theme.js.map +1 -1
  121. package/lib/module/types.js +18 -0
  122. package/lib/module/types.js.map +1 -1
  123. package/lib/module/unread.js +83 -0
  124. package/lib/module/unread.js.map +1 -0
  125. package/lib/module/useSupportThreads.js +291 -0
  126. package/lib/module/useSupportThreads.js.map +1 -0
  127. package/lib/module/utils.js +55 -10
  128. package/lib/module/utils.js.map +1 -1
  129. package/lib/typescript/api.d.ts +9 -0
  130. package/lib/typescript/api.d.ts.map +1 -1
  131. package/lib/typescript/components/BottomSheet.d.ts.map +1 -1
  132. package/lib/typescript/components/LauncherButton.d.ts +14 -0
  133. package/lib/typescript/components/LauncherButton.d.ts.map +1 -0
  134. package/lib/typescript/components/SupportButton.d.ts +3 -3
  135. package/lib/typescript/components/SupportButton.d.ts.map +1 -1
  136. package/lib/typescript/components/icons.d.ts +18 -2
  137. package/lib/typescript/components/icons.d.ts.map +1 -1
  138. package/lib/typescript/components/primitives.d.ts +42 -7
  139. package/lib/typescript/components/primitives.d.ts.map +1 -1
  140. package/lib/typescript/components/screens/ComposeScreen.d.ts +3 -1
  141. package/lib/typescript/components/screens/ComposeScreen.d.ts.map +1 -1
  142. package/lib/typescript/components/screens/HomeScreen.d.ts +8 -2
  143. package/lib/typescript/components/screens/HomeScreen.d.ts.map +1 -1
  144. package/lib/typescript/components/screens/LanguageScreen.d.ts +30 -0
  145. package/lib/typescript/components/screens/LanguageScreen.d.ts.map +1 -0
  146. package/lib/typescript/components/screens/ThreadScreen.d.ts +3 -1
  147. package/lib/typescript/components/screens/ThreadScreen.d.ts.map +1 -1
  148. package/lib/typescript/i18n/index.d.ts +15 -0
  149. package/lib/typescript/i18n/index.d.ts.map +1 -0
  150. package/lib/typescript/i18n/languages.d.ts +36 -0
  151. package/lib/typescript/i18n/languages.d.ts.map +1 -0
  152. package/lib/typescript/i18n/strings/de.d.ts +3 -0
  153. package/lib/typescript/i18n/strings/de.d.ts.map +1 -0
  154. package/lib/typescript/i18n/strings/en.d.ts +83 -0
  155. package/lib/typescript/i18n/strings/en.d.ts.map +1 -0
  156. package/lib/typescript/i18n/strings/es.d.ts +3 -0
  157. package/lib/typescript/i18n/strings/es.d.ts.map +1 -0
  158. package/lib/typescript/i18n/strings/fr.d.ts +3 -0
  159. package/lib/typescript/i18n/strings/fr.d.ts.map +1 -0
  160. package/lib/typescript/i18n/strings/hi.d.ts +3 -0
  161. package/lib/typescript/i18n/strings/hi.d.ts.map +1 -0
  162. package/lib/typescript/i18n/strings/it.d.ts +3 -0
  163. package/lib/typescript/i18n/strings/it.d.ts.map +1 -0
  164. package/lib/typescript/i18n/strings/ja.d.ts +3 -0
  165. package/lib/typescript/i18n/strings/ja.d.ts.map +1 -0
  166. package/lib/typescript/i18n/strings/ko.d.ts +3 -0
  167. package/lib/typescript/i18n/strings/ko.d.ts.map +1 -0
  168. package/lib/typescript/i18n/strings/nl.d.ts +3 -0
  169. package/lib/typescript/i18n/strings/nl.d.ts.map +1 -0
  170. package/lib/typescript/i18n/strings/pl.d.ts +3 -0
  171. package/lib/typescript/i18n/strings/pl.d.ts.map +1 -0
  172. package/lib/typescript/i18n/strings/pt.d.ts +3 -0
  173. package/lib/typescript/i18n/strings/pt.d.ts.map +1 -0
  174. package/lib/typescript/i18n/strings/ru.d.ts +3 -0
  175. package/lib/typescript/i18n/strings/ru.d.ts.map +1 -0
  176. package/lib/typescript/i18n/strings/tr.d.ts +3 -0
  177. package/lib/typescript/i18n/strings/tr.d.ts.map +1 -0
  178. package/lib/typescript/i18n/strings/zh.d.ts +3 -0
  179. package/lib/typescript/i18n/strings/zh.d.ts.map +1 -0
  180. package/lib/typescript/index.d.ts +2 -1
  181. package/lib/typescript/index.d.ts.map +1 -1
  182. package/lib/typescript/theme.d.ts +44 -3
  183. package/lib/typescript/theme.d.ts.map +1 -1
  184. package/lib/typescript/types.d.ts +54 -2
  185. package/lib/typescript/types.d.ts.map +1 -1
  186. package/lib/typescript/unread.d.ts +22 -0
  187. package/lib/typescript/unread.d.ts.map +1 -0
  188. package/lib/typescript/useSupportThreads.d.ts +38 -0
  189. package/lib/typescript/useSupportThreads.d.ts.map +1 -0
  190. package/lib/typescript/utils.d.ts +8 -2
  191. package/lib/typescript/utils.d.ts.map +1 -1
  192. package/package.json +3 -3
  193. package/src/api.ts +24 -1
  194. package/src/components/BottomSheet.tsx +61 -43
  195. package/src/components/LauncherButton.tsx +217 -0
  196. package/src/components/SupportButton.tsx +195 -210
  197. package/src/components/icons.tsx +113 -19
  198. package/src/components/primitives.tsx +230 -39
  199. package/src/components/screens/ComposeScreen.tsx +129 -63
  200. package/src/components/screens/HomeScreen.tsx +230 -120
  201. package/src/components/screens/LanguageScreen.tsx +172 -0
  202. package/src/components/screens/ThreadScreen.tsx +319 -122
  203. package/src/i18n/index.ts +81 -0
  204. package/src/i18n/languages.ts +106 -0
  205. package/src/i18n/strings/de.ts +78 -0
  206. package/src/i18n/strings/en.ts +94 -0
  207. package/src/i18n/strings/es.ts +77 -0
  208. package/src/i18n/strings/fr.ts +78 -0
  209. package/src/i18n/strings/hi.ts +77 -0
  210. package/src/i18n/strings/it.ts +77 -0
  211. package/src/i18n/strings/ja.ts +77 -0
  212. package/src/i18n/strings/ko.ts +76 -0
  213. package/src/i18n/strings/nl.ts +77 -0
  214. package/src/i18n/strings/pl.ts +77 -0
  215. package/src/i18n/strings/pt.ts +77 -0
  216. package/src/i18n/strings/ru.ts +77 -0
  217. package/src/i18n/strings/tr.ts +77 -0
  218. package/src/i18n/strings/zh.ts +75 -0
  219. package/src/index.tsx +9 -0
  220. package/src/theme.ts +165 -18
  221. package/src/types.ts +56 -2
  222. package/src/unread.ts +91 -0
  223. package/src/useSupportThreads.ts +392 -0
  224. package/src/utils.ts +48 -10
@@ -1,47 +1,49 @@
1
- import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
- import { AppState, Pressable, Text, View } from 'react-native';
1
+ import { useCallback, useEffect, 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,
6
+ setLanguage as setLanguageRequest,
9
7
  submitQuery,
10
8
  SupportApiError,
11
9
  type ApiConfig,
12
10
  } from '../api';
11
+ import { createTranslator } from '../i18n';
12
+ import {
13
+ asLanguage,
14
+ DEFAULT_LANGUAGE,
15
+ deviceLanguage,
16
+ type Language,
17
+ } from '../i18n/languages';
13
18
  import { ensurePushPermission, isPushAvailable } from '../push';
14
19
  import { resolveTheme } from '../theme';
15
20
  import type { QueryType, SupportButtonProps, Thread } from '../types';
21
+ import { IDLE_POLL_MS, useSupportThreads } from '../useSupportThreads';
16
22
  import { validateProps } from '../utils';
17
23
  import { BottomSheet } from './BottomSheet';
18
- import { BubbleMark } from './icons';
24
+ import { LauncherButton } from './LauncherButton';
19
25
  import { ComposeScreen } from './screens/ComposeScreen';
20
26
  import { HomeScreen } from './screens/HomeScreen';
27
+ import { LanguageScreen } from './screens/LanguageScreen';
21
28
  import { ThreadScreen } from './screens/ThreadScreen';
22
29
 
23
30
  type Screen =
24
31
  | { name: 'home' }
32
+ | { name: 'language' }
25
33
  | { name: 'compose'; type: QueryType }
26
34
  | { name: 'thread'; threadId: string };
27
35
 
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
36
  /**
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).
37
+ * The single entry point of the SDK: a launcher with an unread badge that
38
+ * opens the support bottom sheet. Threads, polling, and read receipts are
39
+ * handled by useSupportThreads.
41
40
  */
42
41
  export function SupportButton(props: SupportButtonProps) {
43
42
  const validationError = validateProps(props);
44
- const theme = resolveTheme(props.theme);
43
+ const theme = useMemo(
44
+ () => resolveTheme(props.theme, props.accentColor),
45
+ [props.theme, props.accentColor],
46
+ );
45
47
 
46
48
  const config: ApiConfig = useMemo(
47
49
  () => ({
@@ -54,85 +56,57 @@ export function SupportButton(props: SupportButtonProps) {
54
56
 
55
57
  const [sheetOpen, setSheetOpen] = useState(false);
56
58
  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
59
 
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]);
60
+ const {
61
+ threads,
62
+ totalUnread,
63
+ loading,
64
+ refreshing,
65
+ errorKey,
66
+ serverLanguage,
67
+ languageLoaded,
68
+ refresh,
69
+ reload,
70
+ } = useSupportThreads({
71
+ config,
72
+ revenuecatId: props.user.revenuecatId,
73
+ enabled: validationError === null,
74
+ sheetOpen,
75
+ activeThreadId: screen.name === 'thread' ? screen.threadId : null,
76
+ idlePollMs: props.pollIntervalMs ?? IDLE_POLL_MS,
77
+ });
90
78
 
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]);
79
+ /**
80
+ * What the user picked, once they have picked. Until then the sheet is
81
+ * rendered in the host app's hint or the device's language, which is a
82
+ * guess — hence `chosen`, which is what decides whether to ask.
83
+ */
84
+ const [chosen, setChosen] = useState<Language | null>(null);
85
+ const [draftLanguage, setDraftLanguage] = useState<Language | null>(null);
86
+ const [savingLanguage, setSavingLanguage] = useState(false);
87
+ const [languageError, setLanguageError] = useState<string | null>(null);
106
88
 
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;
89
+ // A preference saved on another device (or an earlier install) wins over
90
+ // any local guess, and answers the "have they chosen?" question for free.
111
91
  useEffect(() => {
112
- const idle = !sheetOpen;
113
- const intervalMs = idle
114
- ? idlePollMs
115
- : screenName === 'thread'
116
- ? THREAD_POLL_MS
117
- : SHEET_POLL_MS;
92
+ const stored = asLanguage(serverLanguage);
93
+ if (stored !== null) setChosen(stored);
94
+ }, [serverLanguage]);
118
95
 
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
-
127
- const appStateSub = AppState.addEventListener('change', (state) => {
128
- if (state === 'active') tick();
129
- });
96
+ const fallbackLanguage =
97
+ props.defaultLanguage ?? deviceLanguage() ?? DEFAULT_LANGUAGE;
98
+ const language = chosen ?? fallbackLanguage;
99
+ const strings = useMemo(
100
+ () => createTranslator(draftLanguage ?? language),
101
+ [draftLanguage, language],
102
+ );
130
103
 
131
- return () => {
132
- if (timer !== null) clearInterval(timer);
133
- appStateSub.remove();
134
- };
135
- }, [sheetOpen, screenName, idlePollMs, refresh, refreshBadge]);
104
+ /**
105
+ * Ask once, on first open — but only once the server has told us whether
106
+ * this user already answered. Asking before then would show the picker to
107
+ * someone who chose months ago on another device.
108
+ */
109
+ const mustChooseLanguage = languageLoaded && chosen === null;
136
110
 
137
111
  /**
138
112
  * Asks for notification permission the moment the sheet opens, then
@@ -141,7 +115,7 @@ export function SupportButton(props: SupportButtonProps) {
141
115
  * support flow waits on it or breaks if it fails.
142
116
  */
143
117
  const syncPushPermission = useCallback(async () => {
144
- if (validationError || !isPushAvailable) return;
118
+ if (validationError !== null || !isPushAvailable) return;
145
119
  try {
146
120
  const registration = await ensurePushPermission(props.expoProjectId);
147
121
  await registerPush(config, {
@@ -155,11 +129,9 @@ export function SupportButton(props: SupportButtonProps) {
155
129
  }
156
130
  }, [config, props.user.revenuecatId, props.expoProjectId, validationError]);
157
131
 
158
- // …and on sheet open.
159
132
  function openSheet() {
160
133
  setScreen({ name: 'home' });
161
134
  setSheetOpen(true);
162
- void refresh();
163
135
  // The OS prompt appears right as the sheet opens; if the user declines,
164
136
  // everything below carries on exactly as before.
165
137
  void syncPushPermission();
@@ -168,9 +140,11 @@ export function SupportButton(props: SupportButtonProps) {
168
140
  function closeSheet() {
169
141
  setSheetOpen(false);
170
142
  setScreen({ name: 'home' });
143
+ setDraftLanguage(null);
144
+ setLanguageError(null);
171
145
  }
172
146
 
173
- if (validationError) {
147
+ if (validationError !== null) {
174
148
  // Dev-mode: loud and actionable. Production: render nothing rather than
175
149
  // a broken button.
176
150
  if (__DEV__) {
@@ -193,32 +167,45 @@ export function SupportButton(props: SupportButtonProps) {
193
167
  return null;
194
168
  }
195
169
 
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
- });
170
+ function writeErrorMessage(e: unknown): string {
171
+ if (e instanceof SupportApiError) {
172
+ if (e.status === 429) return strings.t('error.rateLimited');
173
+ if (e.offline) return strings.t('error.offline');
213
174
  }
175
+ return strings.t('error.sendFailed');
214
176
  }
215
177
 
216
- function writeErrorMessage(e: unknown): string {
217
- if (e instanceof SupportApiError) {
218
- if (e.status === 429) return 'Too many requests — try again later';
219
- if (e.offline) return "You're offline — check your connection";
178
+ /**
179
+ * Persists the picked language and leaves the picker.
180
+ *
181
+ * A failed save keeps the user on the screen with a retry: getting this
182
+ * wrong means every message they write is translated from the wrong
183
+ * language, which is worse than one more tap.
184
+ */
185
+ async function confirmLanguage() {
186
+ const next = draftLanguage ?? language;
187
+ setSavingLanguage(true);
188
+ setLanguageError(null);
189
+ try {
190
+ await setLanguageRequest(config, {
191
+ revenuecatId: props.user.revenuecatId,
192
+ language: next,
193
+ });
194
+ setChosen(next);
195
+ setDraftLanguage(null);
196
+ setScreen({ name: 'home' });
197
+ // On a change, the server re-queues existing replies for translation
198
+ // into the new language; they arrive over the next poll or two.
199
+ void reload();
200
+ } catch (e) {
201
+ setLanguageError(
202
+ e instanceof SupportApiError && e.offline
203
+ ? strings.t('error.offline')
204
+ : strings.t('lang.saveFailed'),
205
+ );
206
+ } finally {
207
+ setSavingLanguage(false);
220
208
  }
221
- return "Couldn't send — try again";
222
209
  }
223
210
 
224
211
  async function handleSubmit(
@@ -233,8 +220,9 @@ export function SupportButton(props: SupportButtonProps) {
233
220
  isPaid: props.user.isPaid,
234
221
  revenuecatId: props.user.revenuecatId,
235
222
  appVersion: props.user.appVersion,
223
+ language,
236
224
  });
237
- void refresh();
225
+ void reload();
238
226
  return null;
239
227
  } catch (e) {
240
228
  return writeErrorMessage(e);
@@ -250,113 +238,110 @@ export function SupportButton(props: SupportButtonProps) {
250
238
  queryId: threadId,
251
239
  revenuecatId: props.user.revenuecatId,
252
240
  message,
241
+ language,
253
242
  });
254
- await refresh();
243
+ await reload();
255
244
  return null;
256
245
  } catch (e) {
257
246
  return writeErrorMessage(e);
258
247
  }
259
248
  }
260
249
 
261
- const activeThread =
250
+ const activeThread: Thread | null =
262
251
  screen.name === 'thread'
263
- ? (threads ?? []).find((t) => t.id === screen.threadId) ?? null
252
+ ? ((threads ?? []).find((t) => t.id === screen.threadId) ?? null)
264
253
  : null;
265
254
 
255
+ const home = (
256
+ <HomeScreen
257
+ theme={theme}
258
+ strings={strings}
259
+ language={language}
260
+ threads={threads}
261
+ loading={loading}
262
+ refreshing={refreshing}
263
+ error={errorKey === null ? null : strings.t(errorKey)}
264
+ onRefresh={refresh}
265
+ onCompose={(type) => setScreen({ name: 'compose', type })}
266
+ onOpenThread={(thread) =>
267
+ setScreen({ name: 'thread', threadId: thread.id })
268
+ }
269
+ onChangeLanguage={() => {
270
+ setDraftLanguage(language);
271
+ setLanguageError(null);
272
+ setScreen({ name: 'language' });
273
+ }}
274
+ onClose={closeSheet}
275
+ />
276
+ );
277
+
278
+ const languagePicker = (
279
+ <LanguageScreen
280
+ theme={theme}
281
+ strings={strings}
282
+ selected={draftLanguage ?? language}
283
+ onSelect={setDraftLanguage}
284
+ onConfirm={() => void confirmLanguage()}
285
+ // No way back on first run: there is nothing behind it yet, so the
286
+ // only exit is choosing — or closing the sheet, which asks again.
287
+ onBack={
288
+ mustChooseLanguage
289
+ ? undefined
290
+ : () => {
291
+ setDraftLanguage(null);
292
+ setLanguageError(null);
293
+ setScreen({ name: 'home' });
294
+ }
295
+ }
296
+ onDismiss={mustChooseLanguage ? closeSheet : undefined}
297
+ saving={savingLanguage}
298
+ error={languageError}
299
+ />
300
+ );
301
+
266
302
  return (
267
303
  <>
268
- <Pressable
304
+ <LauncherButton
305
+ theme={theme}
306
+ unread={totalUnread}
307
+ variant={props.variant}
308
+ size={props.size}
309
+ label={props.label}
310
+ style={props.style}
269
311
  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>
312
+ />
318
313
 
319
314
  <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
- )}
332
- {screen.name === 'compose' && (
333
- <ComposeScreen
334
- theme={theme}
335
- type={screen.type}
336
- onBack={() => setScreen({ name: 'home' })}
337
- onSubmit={(message) => handleSubmit(screen.type, message)}
338
- />
315
+ {/* The one-time prompt outranks whatever screen the user is on: it
316
+ only ever fires before they have sent anything. */}
317
+ {mustChooseLanguage || screen.name === 'language' ? (
318
+ languagePicker
319
+ ) : (
320
+ <>
321
+ {screen.name === 'home' && home}
322
+ {screen.name === 'compose' && (
323
+ <ComposeScreen
324
+ theme={theme}
325
+ strings={strings}
326
+ type={screen.type}
327
+ onBack={() => setScreen({ name: 'home' })}
328
+ onSubmit={(message) => handleSubmit(screen.type, message)}
329
+ />
330
+ )}
331
+ {screen.name === 'thread' &&
332
+ (activeThread !== null ? (
333
+ <ThreadScreen
334
+ theme={theme}
335
+ strings={strings}
336
+ thread={activeThread}
337
+ onBack={() => setScreen({ name: 'home' })}
338
+ onSend={(message) => handleSend(activeThread.id, message)}
339
+ />
340
+ ) : (
341
+ home
342
+ ))}
343
+ </>
339
344
  )}
340
- {screen.name === 'thread' &&
341
- (activeThread ? (
342
- <ThreadScreen
343
- theme={theme}
344
- thread={activeThread}
345
- onBack={() => setScreen({ name: 'home' })}
346
- onSend={(message) => handleSend(activeThread.id, message)}
347
- />
348
- ) : (
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
- />
359
- ))}
360
345
  </BottomSheet>
361
346
  </>
362
347
  );
@@ -145,43 +145,137 @@ 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
+ /** Globe: circle, equator, and a pill standing in for the meridian curve. */
149
+ export function GlobeIcon({ color, size = 16 }: { color: string; size?: number }) {
150
+ const inner = size - STROKE * 2;
151
+ return (
152
+ <View
153
+ style={{
154
+ width: size,
155
+ height: size,
156
+ borderWidth: STROKE,
157
+ borderColor: color,
158
+ borderRadius: size / 2,
159
+ alignItems: 'center',
160
+ justifyContent: 'center',
161
+ }}
162
+ >
163
+ <View
164
+ style={{
165
+ position: 'absolute',
166
+ width: inner,
167
+ height: STROKE,
168
+ backgroundColor: color,
169
+ }}
170
+ />
171
+ <View
172
+ style={{
173
+ width: size * 0.52,
174
+ height: inner,
175
+ borderWidth: STROKE,
176
+ borderColor: color,
177
+ borderRadius: size * 0.26,
178
+ }}
179
+ />
180
+ </View>
181
+ );
182
+ }
183
+
184
+ /** Send arrow: a stem plus a chevron head, both centered on the same axis. */
185
+ export function ArrowUpIcon({ color, size = 16 }: { color: string; size?: number }) {
186
+ const stroke = STROKE + 0.4;
187
+ const head = size * 0.44;
188
+ return (
189
+ <View
190
+ style={{
191
+ width: size,
192
+ height: size,
193
+ alignItems: 'center',
194
+ justifyContent: 'center',
195
+ }}
196
+ >
197
+ <View
198
+ style={{
199
+ position: 'absolute',
200
+ width: stroke,
201
+ height: size * 0.66,
202
+ borderRadius: stroke,
203
+ backgroundColor: color,
204
+ }}
205
+ />
206
+ <View
207
+ style={{
208
+ position: 'absolute',
209
+ // Rotating a square by 45° lifts its apex by ~0.207 × side, so
210
+ // offset by that to land the point on top of the stem.
211
+ top: size * 0.17 + head * 0.207,
212
+ width: head,
213
+ height: head,
214
+ borderTopWidth: stroke,
215
+ borderLeftWidth: stroke,
216
+ borderTopLeftRadius: stroke,
217
+ borderColor: color,
218
+ transform: [{ rotate: '45deg' }],
219
+ }}
220
+ />
221
+ </View>
222
+ );
223
+ }
224
+
225
+ /**
226
+ * The chat-bubble mark on the launcher button: a filled bubble with the
227
+ * dots knocked out in the button's own fill color. Solid shapes read far
228
+ * better than 1.5px strokes at launcher size and on top of a color fill.
229
+ */
230
+ export function ChatBubbleMark({
231
+ color,
232
+ dotColor,
233
+ size = 24,
234
+ }: {
235
+ color: string;
236
+ /** The surface behind the mark — the dots are punched out in this color. */
237
+ dotColor: string;
238
+ size?: number;
239
+ }) {
240
+ const bubbleHeight = size * 0.82;
150
241
  const dot: ViewStyle = {
151
- width: size * 0.12,
152
- height: size * 0.12,
242
+ width: size * 0.13,
243
+ height: size * 0.13,
153
244
  borderRadius: size,
154
- backgroundColor: color,
245
+ backgroundColor: dotColor,
155
246
  };
156
247
  return (
157
- <View style={{ alignItems: 'flex-start' }}>
248
+ <View style={{ width: size, height: size, justifyContent: 'flex-start' }}>
158
249
  <View
159
250
  style={{
160
251
  width: size,
161
- height: size * 0.78,
162
- borderWidth: STROKE + 0.25,
163
- borderColor: color,
164
- borderRadius: size * 0.28,
252
+ height: bubbleHeight,
253
+ borderRadius: size * 0.33,
254
+ backgroundColor: color,
165
255
  flexDirection: 'row',
166
256
  alignItems: 'center',
167
257
  justifyContent: 'center',
168
- gap: size * 0.1,
258
+ gap: size * 0.115,
169
259
  }}
170
260
  >
171
261
  <View style={dot} />
172
262
  <View style={dot} />
173
263
  <View style={dot} />
174
264
  </View>
265
+ {/* Tail, drawn as a border triangle so it needs no svg. */}
175
266
  <View
176
267
  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' }],
268
+ position: 'absolute',
269
+ left: size * 0.22,
270
+ top: bubbleHeight - 1,
271
+ width: 0,
272
+ height: 0,
273
+ borderLeftWidth: size * 0.09,
274
+ borderRightWidth: size * 0.16,
275
+ borderTopWidth: size * 0.2,
276
+ borderLeftColor: 'transparent',
277
+ borderRightColor: 'transparent',
278
+ borderTopColor: color,
185
279
  }}
186
280
  />
187
281
  </View>