@appsupportkit/support-sdk 0.2.0 → 0.4.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 (242) hide show
  1. package/lib/commonjs/api.js +61 -12
  2. package/lib/commonjs/api.js.map +1 -1
  3. package/lib/commonjs/components/BottomSheet.js +1 -1
  4. package/lib/commonjs/components/BottomSheet.js.map +1 -1
  5. package/lib/commonjs/components/LauncherButton.js +4 -4
  6. package/lib/commonjs/components/LauncherButton.js.map +1 -1
  7. package/lib/commonjs/components/SupportButton.js +192 -66
  8. package/lib/commonjs/components/SupportButton.js.map +1 -1
  9. package/lib/commonjs/components/icons.js +117 -110
  10. package/lib/commonjs/components/icons.js.map +1 -1
  11. package/lib/commonjs/components/primitives.js +3 -154
  12. package/lib/commonjs/components/primitives.js.map +1 -1
  13. package/lib/commonjs/components/screens/ChatScreen.js +487 -0
  14. package/lib/commonjs/components/screens/ChatScreen.js.map +1 -0
  15. package/lib/commonjs/components/screens/LanguageScreen.js +150 -0
  16. package/lib/commonjs/components/screens/LanguageScreen.js.map +1 -0
  17. package/lib/commonjs/i18n/index.js +101 -0
  18. package/lib/commonjs/i18n/index.js.map +1 -0
  19. package/lib/commonjs/i18n/languages.js +95 -0
  20. package/lib/commonjs/i18n/languages.js.map +1 -0
  21. package/lib/commonjs/i18n/strings/de.js +75 -0
  22. package/lib/commonjs/i18n/strings/de.js.map +1 -0
  23. package/lib/commonjs/i18n/strings/en.js +91 -0
  24. package/lib/commonjs/i18n/strings/en.js.map +1 -0
  25. package/lib/commonjs/i18n/strings/es.js +75 -0
  26. package/lib/commonjs/i18n/strings/es.js.map +1 -0
  27. package/lib/commonjs/i18n/strings/fr.js +75 -0
  28. package/lib/commonjs/i18n/strings/fr.js.map +1 -0
  29. package/lib/commonjs/i18n/strings/hi.js +75 -0
  30. package/lib/commonjs/i18n/strings/hi.js.map +1 -0
  31. package/lib/commonjs/i18n/strings/it.js +75 -0
  32. package/lib/commonjs/i18n/strings/it.js.map +1 -0
  33. package/lib/commonjs/i18n/strings/ja.js +75 -0
  34. package/lib/commonjs/i18n/strings/ja.js.map +1 -0
  35. package/lib/commonjs/i18n/strings/ko.js +75 -0
  36. package/lib/commonjs/i18n/strings/ko.js.map +1 -0
  37. package/lib/commonjs/i18n/strings/nl.js +75 -0
  38. package/lib/commonjs/i18n/strings/nl.js.map +1 -0
  39. package/lib/commonjs/i18n/strings/pl.js +75 -0
  40. package/lib/commonjs/i18n/strings/pl.js.map +1 -0
  41. package/lib/commonjs/i18n/strings/pt.js +75 -0
  42. package/lib/commonjs/i18n/strings/pt.js.map +1 -0
  43. package/lib/commonjs/i18n/strings/ru.js +75 -0
  44. package/lib/commonjs/i18n/strings/ru.js.map +1 -0
  45. package/lib/commonjs/i18n/strings/tr.js +75 -0
  46. package/lib/commonjs/i18n/strings/tr.js.map +1 -0
  47. package/lib/commonjs/i18n/strings/zh.js +75 -0
  48. package/lib/commonjs/i18n/strings/zh.js.map +1 -0
  49. package/lib/commonjs/index.js +38 -0
  50. package/lib/commonjs/index.js.map +1 -1
  51. package/lib/commonjs/push.js +78 -3
  52. package/lib/commonjs/push.js.map +1 -1
  53. package/lib/commonjs/review.js +54 -0
  54. package/lib/commonjs/review.js.map +1 -0
  55. package/lib/commonjs/theme.js +142 -23
  56. package/lib/commonjs/theme.js.map +1 -1
  57. package/lib/commonjs/types.js +32 -3
  58. package/lib/commonjs/types.js.map +1 -1
  59. package/lib/commonjs/useSupportThreads.js +41 -24
  60. package/lib/commonjs/useSupportThreads.js.map +1 -1
  61. package/lib/commonjs/utils.js +25 -30
  62. package/lib/commonjs/utils.js.map +1 -1
  63. package/lib/commonjs/version.js +13 -0
  64. package/lib/commonjs/version.js.map +1 -0
  65. package/lib/module/api.js +57 -10
  66. package/lib/module/api.js.map +1 -1
  67. package/lib/module/components/BottomSheet.js +1 -1
  68. package/lib/module/components/BottomSheet.js.map +1 -1
  69. package/lib/module/components/LauncherButton.js +5 -5
  70. package/lib/module/components/LauncherButton.js.map +1 -1
  71. package/lib/module/components/SupportButton.js +199 -73
  72. package/lib/module/components/SupportButton.js.map +1 -1
  73. package/lib/module/components/icons.js +115 -107
  74. package/lib/module/components/icons.js.map +1 -1
  75. package/lib/module/components/primitives.js +5 -147
  76. package/lib/module/components/primitives.js.map +1 -1
  77. package/lib/module/components/screens/ChatScreen.js +485 -0
  78. package/lib/module/components/screens/ChatScreen.js.map +1 -0
  79. package/lib/module/components/screens/LanguageScreen.js +147 -0
  80. package/lib/module/components/screens/LanguageScreen.js.map +1 -0
  81. package/lib/module/i18n/index.js +56 -0
  82. package/lib/module/i18n/index.js.map +1 -0
  83. package/lib/module/i18n/languages.js +88 -0
  84. package/lib/module/i18n/languages.js.map +1 -0
  85. package/lib/module/i18n/strings/de.js +71 -0
  86. package/lib/module/i18n/strings/de.js.map +1 -0
  87. package/lib/module/i18n/strings/en.js +87 -0
  88. package/lib/module/i18n/strings/en.js.map +1 -0
  89. package/lib/module/i18n/strings/es.js +71 -0
  90. package/lib/module/i18n/strings/es.js.map +1 -0
  91. package/lib/module/i18n/strings/fr.js +71 -0
  92. package/lib/module/i18n/strings/fr.js.map +1 -0
  93. package/lib/module/i18n/strings/hi.js +71 -0
  94. package/lib/module/i18n/strings/hi.js.map +1 -0
  95. package/lib/module/i18n/strings/it.js +71 -0
  96. package/lib/module/i18n/strings/it.js.map +1 -0
  97. package/lib/module/i18n/strings/ja.js +71 -0
  98. package/lib/module/i18n/strings/ja.js.map +1 -0
  99. package/lib/module/i18n/strings/ko.js +71 -0
  100. package/lib/module/i18n/strings/ko.js.map +1 -0
  101. package/lib/module/i18n/strings/nl.js +71 -0
  102. package/lib/module/i18n/strings/nl.js.map +1 -0
  103. package/lib/module/i18n/strings/pl.js +71 -0
  104. package/lib/module/i18n/strings/pl.js.map +1 -0
  105. package/lib/module/i18n/strings/pt.js +71 -0
  106. package/lib/module/i18n/strings/pt.js.map +1 -0
  107. package/lib/module/i18n/strings/ru.js +71 -0
  108. package/lib/module/i18n/strings/ru.js.map +1 -0
  109. package/lib/module/i18n/strings/tr.js +71 -0
  110. package/lib/module/i18n/strings/tr.js.map +1 -0
  111. package/lib/module/i18n/strings/zh.js +71 -0
  112. package/lib/module/i18n/strings/zh.js.map +1 -0
  113. package/lib/module/index.js +2 -0
  114. package/lib/module/index.js.map +1 -1
  115. package/lib/module/push.js +77 -3
  116. package/lib/module/push.js.map +1 -1
  117. package/lib/module/review.js +48 -0
  118. package/lib/module/review.js.map +1 -0
  119. package/lib/module/theme.js +141 -23
  120. package/lib/module/theme.js.map +1 -1
  121. package/lib/module/types.js +32 -3
  122. package/lib/module/types.js.map +1 -1
  123. package/lib/module/useSupportThreads.js +41 -24
  124. package/lib/module/useSupportThreads.js.map +1 -1
  125. package/lib/module/utils.js +24 -29
  126. package/lib/module/utils.js.map +1 -1
  127. package/lib/module/version.js +9 -0
  128. package/lib/module/version.js.map +1 -0
  129. package/lib/typescript/api.d.ts +40 -10
  130. package/lib/typescript/api.d.ts.map +1 -1
  131. package/lib/typescript/components/SupportButton.d.ts +2 -2
  132. package/lib/typescript/components/SupportButton.d.ts.map +1 -1
  133. package/lib/typescript/components/icons.d.ts +10 -14
  134. package/lib/typescript/components/icons.d.ts.map +1 -1
  135. package/lib/typescript/components/primitives.d.ts +3 -30
  136. package/lib/typescript/components/primitives.d.ts.map +1 -1
  137. package/lib/typescript/components/screens/ChatScreen.d.ts +21 -0
  138. package/lib/typescript/components/screens/ChatScreen.d.ts.map +1 -0
  139. package/lib/typescript/components/screens/LanguageScreen.d.ts +30 -0
  140. package/lib/typescript/components/screens/LanguageScreen.d.ts.map +1 -0
  141. package/lib/typescript/i18n/index.d.ts +15 -0
  142. package/lib/typescript/i18n/index.d.ts.map +1 -0
  143. package/lib/typescript/i18n/languages.d.ts +36 -0
  144. package/lib/typescript/i18n/languages.d.ts.map +1 -0
  145. package/lib/typescript/i18n/strings/de.d.ts +3 -0
  146. package/lib/typescript/i18n/strings/de.d.ts.map +1 -0
  147. package/lib/typescript/i18n/strings/en.d.ts +86 -0
  148. package/lib/typescript/i18n/strings/en.d.ts.map +1 -0
  149. package/lib/typescript/i18n/strings/es.d.ts +3 -0
  150. package/lib/typescript/i18n/strings/es.d.ts.map +1 -0
  151. package/lib/typescript/i18n/strings/fr.d.ts +3 -0
  152. package/lib/typescript/i18n/strings/fr.d.ts.map +1 -0
  153. package/lib/typescript/i18n/strings/hi.d.ts +3 -0
  154. package/lib/typescript/i18n/strings/hi.d.ts.map +1 -0
  155. package/lib/typescript/i18n/strings/it.d.ts +3 -0
  156. package/lib/typescript/i18n/strings/it.d.ts.map +1 -0
  157. package/lib/typescript/i18n/strings/ja.d.ts +3 -0
  158. package/lib/typescript/i18n/strings/ja.d.ts.map +1 -0
  159. package/lib/typescript/i18n/strings/ko.d.ts +3 -0
  160. package/lib/typescript/i18n/strings/ko.d.ts.map +1 -0
  161. package/lib/typescript/i18n/strings/nl.d.ts +3 -0
  162. package/lib/typescript/i18n/strings/nl.d.ts.map +1 -0
  163. package/lib/typescript/i18n/strings/pl.d.ts +3 -0
  164. package/lib/typescript/i18n/strings/pl.d.ts.map +1 -0
  165. package/lib/typescript/i18n/strings/pt.d.ts +3 -0
  166. package/lib/typescript/i18n/strings/pt.d.ts.map +1 -0
  167. package/lib/typescript/i18n/strings/ru.d.ts +3 -0
  168. package/lib/typescript/i18n/strings/ru.d.ts.map +1 -0
  169. package/lib/typescript/i18n/strings/tr.d.ts +3 -0
  170. package/lib/typescript/i18n/strings/tr.d.ts.map +1 -0
  171. package/lib/typescript/i18n/strings/zh.d.ts +3 -0
  172. package/lib/typescript/i18n/strings/zh.d.ts.map +1 -0
  173. package/lib/typescript/index.d.ts +4 -2
  174. package/lib/typescript/index.d.ts.map +1 -1
  175. package/lib/typescript/push.d.ts +14 -2
  176. package/lib/typescript/push.d.ts.map +1 -1
  177. package/lib/typescript/review.d.ts +15 -0
  178. package/lib/typescript/review.d.ts.map +1 -0
  179. package/lib/typescript/theme.d.ts +23 -5
  180. package/lib/typescript/theme.d.ts.map +1 -1
  181. package/lib/typescript/types.d.ts +86 -13
  182. package/lib/typescript/types.d.ts.map +1 -1
  183. package/lib/typescript/useSupportThreads.d.ts +20 -4
  184. package/lib/typescript/useSupportThreads.d.ts.map +1 -1
  185. package/lib/typescript/utils.d.ts +8 -6
  186. package/lib/typescript/utils.d.ts.map +1 -1
  187. package/lib/typescript/version.d.ts +7 -0
  188. package/lib/typescript/version.d.ts.map +1 -0
  189. package/package.json +2 -2
  190. package/src/api.ts +75 -23
  191. package/src/components/BottomSheet.tsx +1 -1
  192. package/src/components/LauncherButton.tsx +4 -4
  193. package/src/components/SupportButton.tsx +244 -86
  194. package/src/components/icons.tsx +96 -101
  195. package/src/components/primitives.tsx +9 -161
  196. package/src/components/screens/ChatScreen.tsx +541 -0
  197. package/src/components/screens/LanguageScreen.tsx +172 -0
  198. package/src/i18n/index.ts +81 -0
  199. package/src/i18n/languages.ts +106 -0
  200. package/src/i18n/strings/de.ts +81 -0
  201. package/src/i18n/strings/en.ts +97 -0
  202. package/src/i18n/strings/es.ts +80 -0
  203. package/src/i18n/strings/fr.ts +81 -0
  204. package/src/i18n/strings/hi.ts +80 -0
  205. package/src/i18n/strings/it.ts +80 -0
  206. package/src/i18n/strings/ja.ts +80 -0
  207. package/src/i18n/strings/ko.ts +79 -0
  208. package/src/i18n/strings/nl.ts +80 -0
  209. package/src/i18n/strings/pl.ts +80 -0
  210. package/src/i18n/strings/pt.ts +80 -0
  211. package/src/i18n/strings/ru.ts +80 -0
  212. package/src/i18n/strings/tr.ts +80 -0
  213. package/src/i18n/strings/zh.ts +78 -0
  214. package/src/index.tsx +13 -0
  215. package/src/push.ts +100 -2
  216. package/src/review.ts +54 -0
  217. package/src/theme.ts +182 -28
  218. package/src/types.ts +85 -13
  219. package/src/useSupportThreads.ts +61 -28
  220. package/src/utils.ts +27 -29
  221. package/src/version.ts +6 -0
  222. package/lib/commonjs/components/screens/ComposeScreen.js +0 -228
  223. package/lib/commonjs/components/screens/ComposeScreen.js.map +0 -1
  224. package/lib/commonjs/components/screens/HomeScreen.js +0 -286
  225. package/lib/commonjs/components/screens/HomeScreen.js.map +0 -1
  226. package/lib/commonjs/components/screens/ThreadScreen.js +0 -327
  227. package/lib/commonjs/components/screens/ThreadScreen.js.map +0 -1
  228. package/lib/module/components/screens/ComposeScreen.js +0 -224
  229. package/lib/module/components/screens/ComposeScreen.js.map +0 -1
  230. package/lib/module/components/screens/HomeScreen.js +0 -282
  231. package/lib/module/components/screens/HomeScreen.js.map +0 -1
  232. package/lib/module/components/screens/ThreadScreen.js +0 -325
  233. package/lib/module/components/screens/ThreadScreen.js.map +0 -1
  234. package/lib/typescript/components/screens/ComposeScreen.d.ts +0 -10
  235. package/lib/typescript/components/screens/ComposeScreen.d.ts.map +0 -1
  236. package/lib/typescript/components/screens/HomeScreen.d.ts +0 -14
  237. package/lib/typescript/components/screens/HomeScreen.d.ts.map +0 -1
  238. package/lib/typescript/components/screens/ThreadScreen.d.ts +0 -10
  239. package/lib/typescript/components/screens/ThreadScreen.d.ts.map +0 -1
  240. package/src/components/screens/ComposeScreen.tsx +0 -233
  241. package/src/components/screens/HomeScreen.tsx +0 -287
  242. package/src/components/screens/ThreadScreen.tsx +0 -397
@@ -1,38 +1,65 @@
1
- import { useCallback, useMemo, useState } from 'react';
2
- import { Text, View } from 'react-native';
1
+ import { useCallback, useEffect, useMemo, useState } from 'react';
2
+ import { Platform, Text, useColorScheme, View } from 'react-native';
3
3
  import {
4
- postMessage,
5
4
  registerPush,
6
- submitQuery,
5
+ registerUser,
6
+ reviewOpened,
7
+ sendMessage,
8
+ setLanguage as setLanguageRequest,
7
9
  SupportApiError,
8
10
  type ApiConfig,
9
11
  } from '../api';
10
- import { ensurePushPermission, isPushAvailable } from '../push';
11
- import { resolveTheme } from '../theme';
12
- import type { QueryType, SupportButtonProps, Thread } from '../types';
12
+ import { createTranslator } from '../i18n';
13
+ import {
14
+ asLanguage,
15
+ DEFAULT_LANGUAGE,
16
+ deviceLanguage,
17
+ type Language,
18
+ } from '../i18n/languages';
19
+ import {
20
+ ensurePushPermission,
21
+ isPushAvailable,
22
+ onSupportNotificationTap,
23
+ } from '../push';
24
+ import { openStoreReview, storeLinks, storePlatform } from '../review';
25
+ import { themeFor } from '../theme';
26
+ import type { SupportButtonProps, ThemeName } from '../types';
13
27
  import { IDLE_POLL_MS, useSupportThreads } from '../useSupportThreads';
14
- import { validateProps } from '../utils';
28
+ import { userPlan, validateProps } from '../utils';
15
29
  import { BottomSheet } from './BottomSheet';
16
30
  import { LauncherButton } from './LauncherButton';
17
- import { ComposeScreen } from './screens/ComposeScreen';
18
- import { HomeScreen } from './screens/HomeScreen';
19
- import { ThreadScreen } from './screens/ThreadScreen';
31
+ import { ChatScreen } from './screens/ChatScreen';
32
+ import { LanguageScreen } from './screens/LanguageScreen';
20
33
 
21
- type Screen =
22
- | { name: 'home' }
23
- | { name: 'compose'; type: QueryType }
24
- | { name: 'thread'; threadId: string };
34
+ type Screen = 'chat' | 'language';
25
35
 
26
36
  /**
27
37
  * 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.
38
+ * opens the support chat in a bottom sheet. Conversations, polling, and read
39
+ * receipts are handled by useSupportThreads.
30
40
  */
31
41
  export function SupportButton(props: SupportButtonProps) {
32
42
  const validationError = validateProps(props);
43
+
44
+ // "system" (the default) follows the host app's appearance and re-renders
45
+ // when it changes.
46
+ const colorScheme = useColorScheme();
47
+ const themeName: ThemeName =
48
+ props.theme === 'light' || props.theme === 'dark'
49
+ ? props.theme
50
+ : colorScheme === 'dark'
51
+ ? 'dark'
52
+ : 'light';
53
+ // Picked down to one string first, so an inline `{ light, dark }` object
54
+ // doesn't rebuild the theme on every render.
55
+ const perModeAccent =
56
+ typeof props.accentColor === 'object' && props.accentColor !== null;
57
+ const accent = perModeAccent
58
+ ? (props.accentColor as { light: string; dark: string })[themeName]
59
+ : (props.accentColor as string | undefined);
33
60
  const theme = useMemo(
34
- () => resolveTheme(props.theme, props.accentColor),
35
- [props.theme, props.accentColor],
61
+ () => themeFor(themeName, accent, !perModeAccent),
62
+ [themeName, accent, perModeAccent],
36
63
  );
37
64
 
38
65
  const config: ApiConfig = useMemo(
@@ -45,17 +72,61 @@ export function SupportButton(props: SupportButtonProps) {
45
72
  );
46
73
 
47
74
  const [sheetOpen, setSheetOpen] = useState(false);
48
- const [screen, setScreen] = useState<Screen>({ name: 'home' });
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
- });
75
+ const [screen, setScreen] = useState<Screen>('chat');
76
+
77
+ /**
78
+ * What the user picked, once they have picked. Until then the sheet is
79
+ * rendered in the host app's hint or the device's language, which is a
80
+ * guess — hence `chosen`, which is what decides whether to ask.
81
+ */
82
+ const [chosen, setChosen] = useState<Language | null>(null);
83
+
84
+ const {
85
+ threads,
86
+ totalUnread,
87
+ loading,
88
+ errorKey,
89
+ serverLanguage,
90
+ languageLoaded,
91
+ store,
92
+ refresh,
93
+ reload,
94
+ } = useSupportThreads({
95
+ config,
96
+ revenuecatId: props.user.revenuecatId,
97
+ enabled: validationError === null,
98
+ sheetOpen,
99
+ // Not while the first-run language prompt covers it: nothing behind
100
+ // the prompt has been seen, so nothing may be marked read.
101
+ chatVisible: sheetOpen && screen === 'chat' && chosen !== null,
102
+ idlePollMs: props.pollIntervalMs ?? IDLE_POLL_MS,
103
+ });
104
+
105
+ const [draftLanguage, setDraftLanguage] = useState<Language | null>(null);
106
+ const [savingLanguage, setSavingLanguage] = useState(false);
107
+ const [languageError, setLanguageError] = useState<string | null>(null);
108
+
109
+ // A preference saved on another device (or an earlier install) wins over
110
+ // any local guess, and answers the "have they chosen?" question for free.
111
+ useEffect(() => {
112
+ const stored = asLanguage(serverLanguage);
113
+ if (stored !== null) setChosen(stored);
114
+ }, [serverLanguage]);
115
+
116
+ const fallbackLanguage =
117
+ props.defaultLanguage ?? deviceLanguage() ?? DEFAULT_LANGUAGE;
118
+ const language = chosen ?? fallbackLanguage;
119
+ const strings = useMemo(
120
+ () => createTranslator(draftLanguage ?? language),
121
+ [draftLanguage, language],
122
+ );
123
+
124
+ /**
125
+ * Ask once, on first open — but only once the server has told us whether
126
+ * this user already answered. Asking before then would show the picker to
127
+ * someone who chose months ago on another device.
128
+ */
129
+ const mustChooseLanguage = languageLoaded && chosen === null;
59
130
 
60
131
  /**
61
132
  * Asks for notification permission the moment the sheet opens, then
@@ -78,8 +149,60 @@ export function SupportButton(props: SupportButtonProps) {
78
149
  }
79
150
  }, [config, props.user.revenuecatId, props.expoProjectId, validationError]);
80
151
 
152
+ const { name: userName, revenuecatId, appVersion } = props.user;
153
+ const plan = userPlan(props.user);
154
+
155
+ /**
156
+ * Reports the user on load, and whenever who they are changes, so the team
157
+ * can find and message them before they ever open support. Refreshes the
158
+ * push token too when permission was already granted — never prompting:
159
+ * the OS prompt only appears once the user opens support.
160
+ */
161
+ useEffect(() => {
162
+ if (validationError !== null) return;
163
+ registerUser(config, {
164
+ revenuecatId,
165
+ user: { name: userName, plan, appVersion, platform: storePlatform() },
166
+ }).catch(() => {
167
+ // Best effort; the next load reports again.
168
+ });
169
+ if (!isPushAvailable) return;
170
+ void (async () => {
171
+ const registration = await ensurePushPermission(props.expoProjectId, {
172
+ prompt: false,
173
+ });
174
+ if (registration.status !== 'granted') return;
175
+ await registerPush(config, {
176
+ revenuecatId,
177
+ permissionStatus: registration.status,
178
+ expoPushToken: registration.token,
179
+ asked: false,
180
+ });
181
+ })().catch(() => {});
182
+ }, [
183
+ config,
184
+ validationError,
185
+ revenuecatId,
186
+ userName,
187
+ plan,
188
+ appVersion,
189
+ props.expoProjectId,
190
+ ]);
191
+
192
+ // A tapped support-reply notification opens the chat. If the threads
193
+ // haven't loaded yet (cold start), it fills in as soon as they do.
194
+ useEffect(() => {
195
+ if (validationError !== null) return;
196
+ return onSupportNotificationTap(() => {
197
+ setDraftLanguage(null);
198
+ setLanguageError(null);
199
+ setScreen('chat');
200
+ setSheetOpen(true);
201
+ });
202
+ }, [validationError]);
203
+
81
204
  function openSheet() {
82
- setScreen({ name: 'home' });
205
+ setScreen('chat');
83
206
  setSheetOpen(true);
84
207
  // The OS prompt appears right as the sheet opens; if the user declines,
85
208
  // everything below carries on exactly as before.
@@ -88,7 +211,9 @@ export function SupportButton(props: SupportButtonProps) {
88
211
 
89
212
  function closeSheet() {
90
213
  setSheetOpen(false);
91
- setScreen({ name: 'home' });
214
+ setScreen('chat');
215
+ setDraftLanguage(null);
216
+ setLanguageError(null);
92
217
  }
93
218
 
94
219
  if (validationError !== null) {
@@ -116,41 +241,52 @@ export function SupportButton(props: SupportButtonProps) {
116
241
 
117
242
  function writeErrorMessage(e: unknown): string {
118
243
  if (e instanceof SupportApiError) {
119
- if (e.status === 429) return 'Too many requests — try again later';
120
- if (e.offline) return "You're offline — check your connection";
244
+ if (e.status === 429) return strings.t('error.rateLimited');
245
+ if (e.offline) return strings.t('error.offline');
121
246
  }
122
- return "Couldn't send — try again";
247
+ return strings.t('error.sendFailed');
123
248
  }
124
249
 
125
- async function handleSubmit(
126
- type: QueryType,
127
- message: string,
128
- ): Promise<string | null> {
250
+ /**
251
+ * Persists the picked language and leaves the picker.
252
+ *
253
+ * A failed save keeps the user on the screen with a retry: getting this
254
+ * wrong means every message they write is translated from the wrong
255
+ * language, which is worse than one more tap.
256
+ */
257
+ async function confirmLanguage() {
258
+ const next = draftLanguage ?? language;
259
+ setSavingLanguage(true);
260
+ setLanguageError(null);
129
261
  try {
130
- await submitQuery(config, {
131
- queryType: type,
132
- message,
133
- userName: props.user.name,
134
- isPaid: props.user.isPaid,
262
+ await setLanguageRequest(config, {
135
263
  revenuecatId: props.user.revenuecatId,
136
- appVersion: props.user.appVersion,
264
+ language: next,
137
265
  });
266
+ setChosen(next);
267
+ setDraftLanguage(null);
268
+ setScreen('chat');
269
+ // On a change, the server re-queues existing replies for translation
270
+ // into the new language; they arrive over the next poll or two.
138
271
  void reload();
139
- return null;
140
272
  } catch (e) {
141
- return writeErrorMessage(e);
273
+ setLanguageError(
274
+ e instanceof SupportApiError && e.offline
275
+ ? strings.t('error.offline')
276
+ : strings.t('lang.saveFailed'),
277
+ );
278
+ } finally {
279
+ setSavingLanguage(false);
142
280
  }
143
281
  }
144
282
 
145
- async function handleSend(
146
- threadId: string,
147
- message: string,
148
- ): Promise<string | null> {
283
+ async function handleSend(message: string): Promise<string | null> {
149
284
  try {
150
- await postMessage(config, {
151
- queryId: threadId,
152
- revenuecatId: props.user.revenuecatId,
285
+ await sendMessage(config, {
286
+ revenuecatId,
153
287
  message,
288
+ language,
289
+ user: { name: userName, plan, appVersion, platform: storePlatform() },
154
290
  });
155
291
  await reload();
156
292
  return null;
@@ -159,24 +295,42 @@ export function SupportButton(props: SupportButtonProps) {
159
295
  }
160
296
  }
161
297
 
162
- const activeThread: Thread | null =
163
- screen.name === 'thread'
164
- ? ((threads ?? []).find((t) => t.id === screen.threadId) ?? null)
165
- : null;
298
+ const reviewLinks = storeLinks(store);
299
+ const reviewLabel =
300
+ reviewLinks === null
301
+ ? null
302
+ : strings.t(Platform.OS === 'ios' ? 'review.appStore' : 'review.playStore');
303
+
304
+ /** Opens the store's review page and lets the team know it was tapped. */
305
+ function openReview(messageId: string) {
306
+ if (reviewLinks === null) return;
307
+ void openStoreReview(reviewLinks);
308
+ reviewOpened(config, { revenuecatId, messageId }).catch(() => {
309
+ // The receipt is for the dashboard; the user already has the store.
310
+ });
311
+ }
166
312
 
167
- const home = (
168
- <HomeScreen
313
+ const languagePicker = (
314
+ <LanguageScreen
169
315
  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 })
316
+ strings={strings}
317
+ selected={draftLanguage ?? language}
318
+ onSelect={setDraftLanguage}
319
+ onConfirm={() => void confirmLanguage()}
320
+ // No way back on first run: there is nothing behind it yet, so the
321
+ // only exit is choosing — or closing the sheet, which asks again.
322
+ onBack={
323
+ mustChooseLanguage
324
+ ? undefined
325
+ : () => {
326
+ setDraftLanguage(null);
327
+ setLanguageError(null);
328
+ setScreen('chat');
329
+ }
178
330
  }
179
- onClose={closeSheet}
331
+ onDismiss={mustChooseLanguage ? closeSheet : undefined}
332
+ saving={savingLanguage}
333
+ error={languageError}
180
334
  />
181
335
  );
182
336
 
@@ -193,26 +347,30 @@ export function SupportButton(props: SupportButtonProps) {
193
347
  />
194
348
 
195
349
  <BottomSheet visible={sheetOpen} onClose={closeSheet} theme={theme}>
196
- {screen.name === 'home' && home}
197
- {screen.name === 'compose' && (
198
- <ComposeScreen
350
+ {/* The one-time prompt outranks whatever screen the user is on: it
351
+ only ever fires before they have sent anything. */}
352
+ {mustChooseLanguage || screen === 'language' ? (
353
+ languagePicker
354
+ ) : (
355
+ <ChatScreen
199
356
  theme={theme}
200
- type={screen.type}
201
- onBack={() => setScreen({ name: 'home' })}
202
- onSubmit={(message) => handleSubmit(screen.type, message)}
357
+ strings={strings}
358
+ language={language}
359
+ threads={threads}
360
+ loading={loading}
361
+ error={errorKey === null ? null : strings.t(errorKey)}
362
+ onRetry={refresh}
363
+ onSend={handleSend}
364
+ onChangeLanguage={() => {
365
+ setDraftLanguage(language);
366
+ setLanguageError(null);
367
+ setScreen('language');
368
+ }}
369
+ onClose={closeSheet}
370
+ reviewLabel={reviewLabel}
371
+ onOpenReview={openReview}
203
372
  />
204
373
  )}
205
- {screen.name === 'thread' &&
206
- (activeThread !== null ? (
207
- <ThreadScreen
208
- theme={theme}
209
- thread={activeThread}
210
- onBack={() => setScreen({ name: 'home' })}
211
- onSend={(message) => handleSend(activeThread.id, message)}
212
- />
213
- ) : (
214
- home
215
- ))}
216
374
  </BottomSheet>
217
375
  </>
218
376
  );
@@ -33,60 +33,6 @@ export function ChatIcon({ color, size = 16 }: { color: string; size?: number })
33
33
  );
34
34
  }
35
35
 
36
- export function PlusCircleIcon({ color, size = 16 }: { color: string; size?: number }) {
37
- const bar = size * 0.45;
38
- return (
39
- <View
40
- style={{
41
- width: size,
42
- height: size,
43
- borderWidth: STROKE,
44
- borderColor: color,
45
- borderRadius: size,
46
- alignItems: 'center',
47
- justifyContent: 'center',
48
- }}
49
- >
50
- <View style={{ width: bar, height: STROKE, backgroundColor: color }} />
51
- <View
52
- style={{
53
- width: STROKE,
54
- height: bar,
55
- backgroundColor: color,
56
- position: 'absolute',
57
- }}
58
- />
59
- </View>
60
- );
61
- }
62
-
63
- export function AlertCircleIcon({ color, size = 16 }: { color: string; size?: number }) {
64
- return (
65
- <View
66
- style={{
67
- width: size,
68
- height: size,
69
- borderWidth: STROKE,
70
- borderColor: color,
71
- borderRadius: size,
72
- alignItems: 'center',
73
- paddingTop: size * 0.2,
74
- }}
75
- >
76
- <View style={{ width: STROKE, height: size * 0.32, backgroundColor: color }} />
77
- <View
78
- style={{
79
- width: STROKE + 0.5,
80
- height: STROKE + 0.5,
81
- borderRadius: 2,
82
- backgroundColor: color,
83
- marginTop: size * 0.09,
84
- }}
85
- />
86
- </View>
87
- );
88
- }
89
-
90
36
  /** Chevron built from two borders, rotated. direction: right | left | down. */
91
37
  export function Chevron({
92
38
  color,
@@ -145,6 +91,42 @@ export function CheckIcon({ color, size = 16 }: { color: string; size?: number }
145
91
  );
146
92
  }
147
93
 
94
+ /** Globe: circle, equator, and a pill standing in for the meridian curve. */
95
+ export function GlobeIcon({ color, size = 16 }: { color: string; size?: number }) {
96
+ const inner = size - STROKE * 2;
97
+ return (
98
+ <View
99
+ style={{
100
+ width: size,
101
+ height: size,
102
+ borderWidth: STROKE,
103
+ borderColor: color,
104
+ borderRadius: size / 2,
105
+ alignItems: 'center',
106
+ justifyContent: 'center',
107
+ }}
108
+ >
109
+ <View
110
+ style={{
111
+ position: 'absolute',
112
+ width: inner,
113
+ height: STROKE,
114
+ backgroundColor: color,
115
+ }}
116
+ />
117
+ <View
118
+ style={{
119
+ width: size * 0.52,
120
+ height: inner,
121
+ borderWidth: STROKE,
122
+ borderColor: color,
123
+ borderRadius: size * 0.26,
124
+ }}
125
+ />
126
+ </View>
127
+ );
128
+ }
129
+
148
130
  /** Send arrow: a stem plus a chevron head, both centered on the same axis. */
149
131
  export function ArrowUpIcon({ color, size = 16 }: { color: string; size?: number }) {
150
132
  const stroke = STROKE + 0.4;
@@ -187,61 +169,74 @@ export function ArrowUpIcon({ color, size = 16 }: { color: string; size?: number
187
169
  }
188
170
 
189
171
  /**
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.
172
+ * Geometry of the product mark on a 24-unit square: two overlapping speech
173
+ * bubbles, the front one cut free of the back by a gap. Kept to rounded
174
+ * rects and right-triangle tails so Views can draw it with no svg; the
175
+ * dashboard's LogoMark (apps/web/components/logo.tsx) draws the same numbers.
176
+ */
177
+ const MARK = {
178
+ back: { x: 1, y: 2.5, w: 14.5, h: 11, r: 4.5 },
179
+ /** Right angle at top-left, apex straight down. */
180
+ backTail: { x: 3.5, y: 12.25, w: 4.25, h: 4.25 },
181
+ front: { x: 8.5, y: 8.75, w: 14.5, h: 10.5, r: 4.5 },
182
+ /** Right angle at top-right, apex straight down. */
183
+ frontTail: { x: 16.5, y: 18.25, w: 4.25, h: 4.25 },
184
+ gap: 2,
185
+ } as const;
186
+
187
+ /**
188
+ * The mark on the launcher button. Solid shapes read far better than 1.5px
189
+ * strokes at launcher size and on top of a color fill.
193
190
  */
194
- export function ChatBubbleMark({
191
+ export function ConversationMark({
195
192
  color,
196
- dotColor,
193
+ holeColor,
197
194
  size = 24,
198
195
  }: {
199
196
  color: string;
200
- /** The surface behind the mark — the dots are punched out in this color. */
201
- dotColor: string;
197
+ /** The surface behind the mark — the gap between the bubbles is cut in this color. */
198
+ holeColor: string;
202
199
  size?: number;
203
200
  }) {
204
- const bubbleHeight = size * 0.82;
205
- const dot: ViewStyle = {
206
- width: size * 0.13,
207
- height: size * 0.13,
208
- borderRadius: size,
209
- backgroundColor: dotColor,
210
- };
201
+ const u = size / 24;
202
+ const rect = (
203
+ o: { x: number; y: number; w: number; h: number; r: number },
204
+ fill: string,
205
+ grow = 0,
206
+ ): ViewStyle => ({
207
+ position: 'absolute',
208
+ left: (o.x - grow) * u,
209
+ top: (o.y - grow) * u,
210
+ width: (o.w + grow * 2) * u,
211
+ height: (o.h + grow * 2) * u,
212
+ borderRadius: (o.r + grow) * u,
213
+ backgroundColor: fill,
214
+ });
215
+ // A zero-size box whose top border is colored and one side border is
216
+ // transparent renders as a right triangle; which side picks the corner.
217
+ const tail = (
218
+ o: { x: number; y: number; w: number; h: number },
219
+ side: 'left' | 'right',
220
+ ): ViewStyle => ({
221
+ position: 'absolute',
222
+ left: o.x * u,
223
+ top: o.y * u,
224
+ width: 0,
225
+ height: 0,
226
+ borderTopWidth: o.h * u,
227
+ borderTopColor: color,
228
+ ...(side === 'left'
229
+ ? { borderRightWidth: o.w * u, borderRightColor: 'transparent' }
230
+ : { borderLeftWidth: o.w * u, borderLeftColor: 'transparent' }),
231
+ });
232
+
211
233
  return (
212
- <View style={{ width: size, height: size, justifyContent: 'flex-start' }}>
213
- <View
214
- style={{
215
- width: size,
216
- height: bubbleHeight,
217
- borderRadius: size * 0.33,
218
- backgroundColor: color,
219
- flexDirection: 'row',
220
- alignItems: 'center',
221
- justifyContent: 'center',
222
- gap: size * 0.115,
223
- }}
224
- >
225
- <View style={dot} />
226
- <View style={dot} />
227
- <View style={dot} />
228
- </View>
229
- {/* Tail, drawn as a border triangle so it needs no svg. */}
230
- <View
231
- style={{
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,
243
- }}
244
- />
234
+ <View style={{ width: size, height: size }}>
235
+ <View style={rect(MARK.back, color)} />
236
+ <View style={tail(MARK.backTail, 'left')} />
237
+ <View style={rect(MARK.front, holeColor, MARK.gap)} />
238
+ <View style={rect(MARK.front, color)} />
239
+ <View style={tail(MARK.frontTail, 'right')} />
245
240
  </View>
246
241
  );
247
242
  }