@appsupportkit/support-sdk 0.2.0 → 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 (196) hide show
  1. package/lib/commonjs/api.js +17 -2
  2. package/lib/commonjs/api.js.map +1 -1
  3. package/lib/commonjs/components/SupportButton.js +123 -23
  4. package/lib/commonjs/components/SupportButton.js.map +1 -1
  5. package/lib/commonjs/components/icons.js +36 -0
  6. package/lib/commonjs/components/icons.js.map +1 -1
  7. package/lib/commonjs/components/primitives.js +9 -7
  8. package/lib/commonjs/components/primitives.js.map +1 -1
  9. package/lib/commonjs/components/screens/ComposeScreen.js +14 -23
  10. package/lib/commonjs/components/screens/ComposeScreen.js.map +1 -1
  11. package/lib/commonjs/components/screens/HomeScreen.js +70 -22
  12. package/lib/commonjs/components/screens/HomeScreen.js.map +1 -1
  13. package/lib/commonjs/components/screens/LanguageScreen.js +150 -0
  14. package/lib/commonjs/components/screens/LanguageScreen.js.map +1 -0
  15. package/lib/commonjs/components/screens/ThreadScreen.js +31 -19
  16. package/lib/commonjs/components/screens/ThreadScreen.js.map +1 -1
  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 +72 -0
  22. package/lib/commonjs/i18n/strings/de.js.map +1 -0
  23. package/lib/commonjs/i18n/strings/en.js +88 -0
  24. package/lib/commonjs/i18n/strings/en.js.map +1 -0
  25. package/lib/commonjs/i18n/strings/es.js +72 -0
  26. package/lib/commonjs/i18n/strings/es.js.map +1 -0
  27. package/lib/commonjs/i18n/strings/fr.js +72 -0
  28. package/lib/commonjs/i18n/strings/fr.js.map +1 -0
  29. package/lib/commonjs/i18n/strings/hi.js +72 -0
  30. package/lib/commonjs/i18n/strings/hi.js.map +1 -0
  31. package/lib/commonjs/i18n/strings/it.js +72 -0
  32. package/lib/commonjs/i18n/strings/it.js.map +1 -0
  33. package/lib/commonjs/i18n/strings/ja.js +72 -0
  34. package/lib/commonjs/i18n/strings/ja.js.map +1 -0
  35. package/lib/commonjs/i18n/strings/ko.js +72 -0
  36. package/lib/commonjs/i18n/strings/ko.js.map +1 -0
  37. package/lib/commonjs/i18n/strings/nl.js +72 -0
  38. package/lib/commonjs/i18n/strings/nl.js.map +1 -0
  39. package/lib/commonjs/i18n/strings/pl.js +72 -0
  40. package/lib/commonjs/i18n/strings/pl.js.map +1 -0
  41. package/lib/commonjs/i18n/strings/pt.js +72 -0
  42. package/lib/commonjs/i18n/strings/pt.js.map +1 -0
  43. package/lib/commonjs/i18n/strings/ru.js +72 -0
  44. package/lib/commonjs/i18n/strings/ru.js.map +1 -0
  45. package/lib/commonjs/i18n/strings/tr.js +72 -0
  46. package/lib/commonjs/i18n/strings/tr.js.map +1 -0
  47. package/lib/commonjs/i18n/strings/zh.js +72 -0
  48. package/lib/commonjs/i18n/strings/zh.js.map +1 -0
  49. package/lib/commonjs/index.js +31 -0
  50. package/lib/commonjs/index.js.map +1 -1
  51. package/lib/commonjs/types.js +11 -0
  52. package/lib/commonjs/types.js.map +1 -1
  53. package/lib/commonjs/useSupportThreads.js +19 -9
  54. package/lib/commonjs/useSupportThreads.js.map +1 -1
  55. package/lib/commonjs/utils.js +32 -17
  56. package/lib/commonjs/utils.js.map +1 -1
  57. package/lib/module/api.js +16 -2
  58. package/lib/module/api.js.map +1 -1
  59. package/lib/module/components/SupportButton.js +126 -26
  60. package/lib/module/components/SupportButton.js.map +1 -1
  61. package/lib/module/components/icons.js +35 -0
  62. package/lib/module/components/icons.js.map +1 -1
  63. package/lib/module/components/primitives.js +9 -7
  64. package/lib/module/components/primitives.js.map +1 -1
  65. package/lib/module/components/screens/ComposeScreen.js +15 -22
  66. package/lib/module/components/screens/ComposeScreen.js.map +1 -1
  67. package/lib/module/components/screens/HomeScreen.js +71 -23
  68. package/lib/module/components/screens/HomeScreen.js.map +1 -1
  69. package/lib/module/components/screens/LanguageScreen.js +147 -0
  70. package/lib/module/components/screens/LanguageScreen.js.map +1 -0
  71. package/lib/module/components/screens/ThreadScreen.js +31 -19
  72. package/lib/module/components/screens/ThreadScreen.js.map +1 -1
  73. package/lib/module/i18n/index.js +56 -0
  74. package/lib/module/i18n/index.js.map +1 -0
  75. package/lib/module/i18n/languages.js +88 -0
  76. package/lib/module/i18n/languages.js.map +1 -0
  77. package/lib/module/i18n/strings/de.js +68 -0
  78. package/lib/module/i18n/strings/de.js.map +1 -0
  79. package/lib/module/i18n/strings/en.js +84 -0
  80. package/lib/module/i18n/strings/en.js.map +1 -0
  81. package/lib/module/i18n/strings/es.js +68 -0
  82. package/lib/module/i18n/strings/es.js.map +1 -0
  83. package/lib/module/i18n/strings/fr.js +68 -0
  84. package/lib/module/i18n/strings/fr.js.map +1 -0
  85. package/lib/module/i18n/strings/hi.js +68 -0
  86. package/lib/module/i18n/strings/hi.js.map +1 -0
  87. package/lib/module/i18n/strings/it.js +68 -0
  88. package/lib/module/i18n/strings/it.js.map +1 -0
  89. package/lib/module/i18n/strings/ja.js +68 -0
  90. package/lib/module/i18n/strings/ja.js.map +1 -0
  91. package/lib/module/i18n/strings/ko.js +68 -0
  92. package/lib/module/i18n/strings/ko.js.map +1 -0
  93. package/lib/module/i18n/strings/nl.js +68 -0
  94. package/lib/module/i18n/strings/nl.js.map +1 -0
  95. package/lib/module/i18n/strings/pl.js +68 -0
  96. package/lib/module/i18n/strings/pl.js.map +1 -0
  97. package/lib/module/i18n/strings/pt.js +68 -0
  98. package/lib/module/i18n/strings/pt.js.map +1 -0
  99. package/lib/module/i18n/strings/ru.js +68 -0
  100. package/lib/module/i18n/strings/ru.js.map +1 -0
  101. package/lib/module/i18n/strings/tr.js +68 -0
  102. package/lib/module/i18n/strings/tr.js.map +1 -0
  103. package/lib/module/i18n/strings/zh.js +68 -0
  104. package/lib/module/i18n/strings/zh.js.map +1 -0
  105. package/lib/module/index.js +1 -0
  106. package/lib/module/index.js.map +1 -1
  107. package/lib/module/types.js +11 -0
  108. package/lib/module/types.js.map +1 -1
  109. package/lib/module/useSupportThreads.js +19 -9
  110. package/lib/module/useSupportThreads.js.map +1 -1
  111. package/lib/module/utils.js +32 -17
  112. package/lib/module/utils.js.map +1 -1
  113. package/lib/typescript/api.d.ts +9 -0
  114. package/lib/typescript/api.d.ts.map +1 -1
  115. package/lib/typescript/components/SupportButton.d.ts.map +1 -1
  116. package/lib/typescript/components/icons.d.ts +5 -0
  117. package/lib/typescript/components/icons.d.ts.map +1 -1
  118. package/lib/typescript/components/primitives.d.ts +7 -3
  119. package/lib/typescript/components/primitives.d.ts.map +1 -1
  120. package/lib/typescript/components/screens/ComposeScreen.d.ts +3 -1
  121. package/lib/typescript/components/screens/ComposeScreen.d.ts.map +1 -1
  122. package/lib/typescript/components/screens/HomeScreen.d.ts +6 -1
  123. package/lib/typescript/components/screens/HomeScreen.d.ts.map +1 -1
  124. package/lib/typescript/components/screens/LanguageScreen.d.ts +30 -0
  125. package/lib/typescript/components/screens/LanguageScreen.d.ts.map +1 -0
  126. package/lib/typescript/components/screens/ThreadScreen.d.ts +3 -1
  127. package/lib/typescript/components/screens/ThreadScreen.d.ts.map +1 -1
  128. package/lib/typescript/i18n/index.d.ts +15 -0
  129. package/lib/typescript/i18n/index.d.ts.map +1 -0
  130. package/lib/typescript/i18n/languages.d.ts +36 -0
  131. package/lib/typescript/i18n/languages.d.ts.map +1 -0
  132. package/lib/typescript/i18n/strings/de.d.ts +3 -0
  133. package/lib/typescript/i18n/strings/de.d.ts.map +1 -0
  134. package/lib/typescript/i18n/strings/en.d.ts +83 -0
  135. package/lib/typescript/i18n/strings/en.d.ts.map +1 -0
  136. package/lib/typescript/i18n/strings/es.d.ts +3 -0
  137. package/lib/typescript/i18n/strings/es.d.ts.map +1 -0
  138. package/lib/typescript/i18n/strings/fr.d.ts +3 -0
  139. package/lib/typescript/i18n/strings/fr.d.ts.map +1 -0
  140. package/lib/typescript/i18n/strings/hi.d.ts +3 -0
  141. package/lib/typescript/i18n/strings/hi.d.ts.map +1 -0
  142. package/lib/typescript/i18n/strings/it.d.ts +3 -0
  143. package/lib/typescript/i18n/strings/it.d.ts.map +1 -0
  144. package/lib/typescript/i18n/strings/ja.d.ts +3 -0
  145. package/lib/typescript/i18n/strings/ja.d.ts.map +1 -0
  146. package/lib/typescript/i18n/strings/ko.d.ts +3 -0
  147. package/lib/typescript/i18n/strings/ko.d.ts.map +1 -0
  148. package/lib/typescript/i18n/strings/nl.d.ts +3 -0
  149. package/lib/typescript/i18n/strings/nl.d.ts.map +1 -0
  150. package/lib/typescript/i18n/strings/pl.d.ts +3 -0
  151. package/lib/typescript/i18n/strings/pl.d.ts.map +1 -0
  152. package/lib/typescript/i18n/strings/pt.d.ts +3 -0
  153. package/lib/typescript/i18n/strings/pt.d.ts.map +1 -0
  154. package/lib/typescript/i18n/strings/ru.d.ts +3 -0
  155. package/lib/typescript/i18n/strings/ru.d.ts.map +1 -0
  156. package/lib/typescript/i18n/strings/tr.d.ts +3 -0
  157. package/lib/typescript/i18n/strings/tr.d.ts.map +1 -0
  158. package/lib/typescript/i18n/strings/zh.d.ts +3 -0
  159. package/lib/typescript/i18n/strings/zh.d.ts.map +1 -0
  160. package/lib/typescript/index.d.ts +1 -0
  161. package/lib/typescript/index.d.ts.map +1 -1
  162. package/lib/typescript/types.d.ts +32 -2
  163. package/lib/typescript/types.d.ts.map +1 -1
  164. package/lib/typescript/useSupportThreads.d.ts +11 -1
  165. package/lib/typescript/useSupportThreads.d.ts.map +1 -1
  166. package/lib/typescript/utils.d.ts +6 -5
  167. package/lib/typescript/utils.d.ts.map +1 -1
  168. package/package.json +2 -2
  169. package/src/api.ts +24 -1
  170. package/src/components/SupportButton.tsx +162 -33
  171. package/src/components/icons.tsx +36 -0
  172. package/src/components/primitives.tsx +14 -4
  173. package/src/components/screens/ComposeScreen.tsx +19 -23
  174. package/src/components/screens/HomeScreen.tsx +84 -25
  175. package/src/components/screens/LanguageScreen.tsx +172 -0
  176. package/src/components/screens/ThreadScreen.tsx +48 -21
  177. package/src/i18n/index.ts +81 -0
  178. package/src/i18n/languages.ts +106 -0
  179. package/src/i18n/strings/de.ts +78 -0
  180. package/src/i18n/strings/en.ts +94 -0
  181. package/src/i18n/strings/es.ts +77 -0
  182. package/src/i18n/strings/fr.ts +78 -0
  183. package/src/i18n/strings/hi.ts +77 -0
  184. package/src/i18n/strings/it.ts +77 -0
  185. package/src/i18n/strings/ja.ts +77 -0
  186. package/src/i18n/strings/ko.ts +76 -0
  187. package/src/i18n/strings/nl.ts +77 -0
  188. package/src/i18n/strings/pl.ts +77 -0
  189. package/src/i18n/strings/pt.ts +77 -0
  190. package/src/i18n/strings/ru.ts +77 -0
  191. package/src/i18n/strings/tr.ts +77 -0
  192. package/src/i18n/strings/zh.ts +75 -0
  193. package/src/index.tsx +8 -0
  194. package/src/types.ts +32 -2
  195. package/src/useSupportThreads.ts +30 -10
  196. package/src/utils.ts +22 -17
@@ -1,9 +1,10 @@
1
1
  import { RefreshControl, ScrollView, Text, View } from 'react-native';
2
+ import type { Translator } from '../../i18n';
3
+ import { LANGUAGE_LABELS, type Language } from '../../i18n/languages';
2
4
  import type { SupportTheme } from '../../theme';
3
5
  import type { QueryType, Thread } from '../../types';
4
- import { QUERY_TYPE_LABEL } from '../../types';
5
6
  import { relativeTime } from '../../utils';
6
- import { Chevron, CloseIcon } from '../icons';
7
+ import { Chevron, CloseIcon, GlobeIcon } from '../icons';
7
8
  import {
8
9
  EmptyState,
9
10
  ErrorState,
@@ -18,25 +19,22 @@ import {
18
19
 
19
20
  const COMPOSE_TYPES: QueryType[] = ['feedback', 'feature_request', 'bug_report'];
20
21
 
21
- const COMPOSE_HINT: Record<QueryType, string> = {
22
- feedback: 'Tell us what you think',
23
- feature_request: 'Suggest an improvement',
24
- bug_report: "Something isn't working",
25
- };
26
-
27
22
  function ThreadRow({
28
23
  thread,
29
24
  theme,
25
+ strings,
30
26
  onPress,
31
27
  last,
32
28
  }: {
33
29
  thread: Thread;
34
30
  theme: SupportTheme;
31
+ strings: Translator;
35
32
  onPress: () => void;
36
33
  last: boolean;
37
34
  }) {
38
35
  const unread = thread.unread_count > 0;
39
- const preview = thread.last_message?.body ?? 'No messages yet';
36
+ const type = strings.t(`type.${thread.type}`);
37
+ const preview = thread.last_message?.body ?? strings.t('home.noMessages');
40
38
  const at = thread.last_message?.created_at ?? thread.created_at;
41
39
 
42
40
  return (
@@ -45,8 +43,11 @@ function ThreadRow({
45
43
  theme={theme}
46
44
  accessibilityLabel={
47
45
  unread
48
- ? `${QUERY_TYPE_LABEL[thread.type]} thread, ${thread.unread_count} unread`
49
- : `${QUERY_TYPE_LABEL[thread.type]} thread`
46
+ ? strings.t('home.threadUnreadA11y', {
47
+ type,
48
+ count: thread.unread_count,
49
+ })
50
+ : strings.t('home.threadA11y', { type })
50
51
  }
51
52
  style={{
52
53
  flexDirection: 'row',
@@ -73,9 +74,9 @@ function ThreadRow({
73
74
  color: theme.colors.textPrimary,
74
75
  }}
75
76
  >
76
- {QUERY_TYPE_LABEL[thread.type]}
77
+ {type}
77
78
  </Text>
78
- <Text style={textStyles.meta(theme)}>{relativeTime(at)}</Text>
79
+ <Text style={textStyles.meta(theme)}>{relativeTime(at, strings)}</Text>
79
80
  </View>
80
81
 
81
82
  <View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
@@ -88,7 +89,9 @@ function ThreadRow({
88
89
  color: unread ? theme.colors.textPrimary : theme.colors.textSecondary,
89
90
  }}
90
91
  >
91
- {thread.last_message?.sender === 'user' ? 'You: ' : ''}
92
+ {thread.last_message?.sender === 'user'
93
+ ? `${strings.t('home.you')}: `
94
+ : ''}
92
95
  {preview}
93
96
  </Text>
94
97
  {unread ? (
@@ -106,11 +109,13 @@ function ThreadGroup({
106
109
  label,
107
110
  threads,
108
111
  theme,
112
+ strings,
109
113
  onOpenThread,
110
114
  }: {
111
115
  label: string;
112
116
  threads: Thread[];
113
117
  theme: SupportTheme;
118
+ strings: Translator;
114
119
  onOpenThread: (thread: Thread) => void;
115
120
  }) {
116
121
  if (threads.length === 0) return null;
@@ -134,6 +139,7 @@ function ThreadGroup({
134
139
  key={thread.id}
135
140
  thread={thread}
136
141
  theme={theme}
142
+ strings={strings}
137
143
  onPress={() => onOpenThread(thread)}
138
144
  last={i === threads.length - 1}
139
145
  />
@@ -145,6 +151,8 @@ function ThreadGroup({
145
151
 
146
152
  export function HomeScreen({
147
153
  theme,
154
+ strings,
155
+ language,
148
156
  threads,
149
157
  loading,
150
158
  refreshing,
@@ -152,9 +160,12 @@ export function HomeScreen({
152
160
  onRefresh,
153
161
  onCompose,
154
162
  onOpenThread,
163
+ onChangeLanguage,
155
164
  onClose,
156
165
  }: {
157
166
  theme: SupportTheme;
167
+ strings: Translator;
168
+ language: Language;
158
169
  threads: Thread[] | null;
159
170
  loading: boolean;
160
171
  refreshing: boolean;
@@ -162,6 +173,7 @@ export function HomeScreen({
162
173
  onRefresh: () => void;
163
174
  onCompose: (type: QueryType) => void;
164
175
  onOpenThread: (thread: Thread) => void;
176
+ onChangeLanguage: () => void;
165
177
  onClose: () => void;
166
178
  }) {
167
179
  const open = (threads ?? []).filter((t) => t.status === 'open');
@@ -180,15 +192,15 @@ export function HomeScreen({
180
192
  }}
181
193
  >
182
194
  <View style={{ flex: 1, gap: 2 }}>
183
- <Text style={textStyles.title(theme)}>Support</Text>
195
+ <Text style={textStyles.title(theme)}>{strings.t('home.title')}</Text>
184
196
  <Text style={textStyles.secondary(theme)}>
185
- Start a conversation — we reply right here.
197
+ {strings.t('home.subtitle')}
186
198
  </Text>
187
199
  </View>
188
200
  <IconButton
189
201
  onPress={onClose}
190
202
  theme={theme}
191
- accessibilityLabel="Close support"
203
+ accessibilityLabel={strings.t('home.close')}
192
204
  size={32}
193
205
  >
194
206
  <CloseIcon color={theme.colors.textSecondary} size={12} />
@@ -225,7 +237,9 @@ export function HomeScreen({
225
237
  key={type}
226
238
  onPress={() => onCompose(type)}
227
239
  theme={theme}
228
- accessibilityLabel={`New ${QUERY_TYPE_LABEL[type].toLowerCase()}`}
240
+ accessibilityLabel={strings.t('home.newA11y', {
241
+ type: strings.t(`type.${type}`),
242
+ })}
229
243
  style={{
230
244
  flexDirection: 'row',
231
245
  alignItems: 'center',
@@ -239,20 +253,63 @@ export function HomeScreen({
239
253
  <TypeGlyph type={type} theme={theme} size={34} />
240
254
  <View style={{ flex: 1, gap: 1 }}>
241
255
  <Text style={textStyles.heading(theme)}>
242
- {QUERY_TYPE_LABEL[type]}
256
+ {strings.t(`type.${type}`)}
257
+ </Text>
258
+ <Text style={textStyles.meta(theme)}>
259
+ {strings.t(`hint.${type}`)}
243
260
  </Text>
244
- <Text style={textStyles.meta(theme)}>{COMPOSE_HINT[type]}</Text>
245
261
  </View>
246
262
  <Chevron color={theme.colors.textTertiary} />
247
263
  </PressableRow>
248
264
  ))}
249
265
  </View>
250
266
 
267
+ {/* Language — a quiet row rather than a settings screen, since it is
268
+ the only preference the sheet has. */}
269
+ <View
270
+ style={{
271
+ marginTop: 12,
272
+ marginHorizontal: 16,
273
+ borderWidth: 1,
274
+ borderColor: theme.colors.border,
275
+ borderRadius: theme.radii.card,
276
+ backgroundColor: theme.colors.surface,
277
+ overflow: 'hidden',
278
+ }}
279
+ >
280
+ <PressableRow
281
+ onPress={onChangeLanguage}
282
+ theme={theme}
283
+ accessibilityLabel={strings.t('lang.change')}
284
+ style={{
285
+ flexDirection: 'row',
286
+ alignItems: 'center',
287
+ gap: 12,
288
+ paddingHorizontal: 14,
289
+ paddingVertical: 11,
290
+ }}
291
+ >
292
+ <GlobeIcon color={theme.colors.textTertiary} size={16} />
293
+ <Text style={[textStyles.secondary(theme), { flex: 1 }]}>
294
+ {strings.t('home.language')}
295
+ </Text>
296
+ <Text style={textStyles.meta(theme)}>
297
+ {LANGUAGE_LABELS[language]}
298
+ </Text>
299
+ <Chevron color={theme.colors.textTertiary} />
300
+ </PressableRow>
301
+ </View>
302
+
251
303
  {/* Threads */}
252
304
  {error !== null && threads === null ? (
253
305
  // Only take over the screen when there's nothing cached to show —
254
306
  // a failed background poll shouldn't hide an already-loaded list.
255
- <ErrorState message={error} onRetry={onRefresh} theme={theme} />
307
+ <ErrorState
308
+ message={error}
309
+ onRetry={onRefresh}
310
+ theme={theme}
311
+ strings={strings}
312
+ />
256
313
  ) : loading && threads === null ? (
257
314
  <View style={{ paddingHorizontal: 16, paddingTop: 28, gap: 12 }}>
258
315
  <SkeletonBlock width={110} height={11} theme={theme} radius={4} />
@@ -261,22 +318,24 @@ export function HomeScreen({
261
318
  </View>
262
319
  ) : (threads ?? []).length === 0 ? (
263
320
  <EmptyState
264
- message="No conversations yet"
265
- hint="Anything you send lands with the team, and their replies come back to this sheet."
321
+ message={strings.t('home.empty')}
322
+ hint={strings.t('home.emptyHint')}
266
323
  theme={theme}
267
324
  />
268
325
  ) : (
269
326
  <>
270
327
  <ThreadGroup
271
- label={`Open · ${open.length}`}
328
+ label={`${strings.t('home.open')} · ${open.length}`}
272
329
  threads={open}
273
330
  theme={theme}
331
+ strings={strings}
274
332
  onOpenThread={onOpenThread}
275
333
  />
276
334
  <ThreadGroup
277
- label="Resolved"
335
+ label={strings.t('home.resolved')}
278
336
  threads={resolved}
279
337
  theme={theme}
338
+ strings={strings}
280
339
  onOpenThread={onOpenThread}
281
340
  />
282
341
  </>
@@ -0,0 +1,172 @@
1
+ import { useState } from 'react';
2
+ import { ScrollView, Text, View } from 'react-native';
3
+ import type { Translator } from '../../i18n';
4
+ import {
5
+ LANGUAGE_LABELS,
6
+ SUPPORTED_LANGUAGES,
7
+ type Language,
8
+ } from '../../i18n/languages';
9
+ import type { SupportTheme } from '../../theme';
10
+ import { CheckIcon, Chevron, CloseIcon } from '../icons';
11
+ import {
12
+ IconButton,
13
+ PressableRow,
14
+ PrimaryButton,
15
+ textStyles,
16
+ } from '../primitives';
17
+
18
+ /**
19
+ * The one-time language prompt, and the same screen again whenever the user
20
+ * changes their mind from the home header.
21
+ *
22
+ * Selecting a row re-renders the whole sheet in that language immediately —
23
+ * the confirmation button is the only way to see what you are choosing before
24
+ * you commit to it.
25
+ */
26
+ export function LanguageScreen({
27
+ theme,
28
+ strings,
29
+ selected,
30
+ onSelect,
31
+ onConfirm,
32
+ onBack,
33
+ onDismiss,
34
+ saving,
35
+ error,
36
+ }: {
37
+ theme: SupportTheme;
38
+ strings: Translator;
39
+ selected: Language;
40
+ onSelect: (language: Language) => void;
41
+ onConfirm: () => void;
42
+ /** Absent on first run: there is nothing to go back to yet. */
43
+ onBack?: () => void;
44
+ /**
45
+ * First run only — dismissing without choosing closes the sheet and leaves
46
+ * the preference unset, so the prompt appears again next time. Saving a
47
+ * language the user never actually picked would be worse: every message
48
+ * they write would be translated from the wrong one.
49
+ */
50
+ onDismiss?: () => void;
51
+ saving: boolean;
52
+ error: string | null;
53
+ }) {
54
+ // Held so the list doesn't reorder under a finger mid-scroll.
55
+ const [languages] = useState(() => [...SUPPORTED_LANGUAGES]);
56
+
57
+ return (
58
+ <View style={{ flex: 1 }}>
59
+ <View
60
+ style={{
61
+ flexDirection: 'row',
62
+ alignItems: 'flex-start',
63
+ paddingHorizontal: 16,
64
+ paddingTop: 4,
65
+ paddingBottom: 16,
66
+ gap: 10,
67
+ }}
68
+ >
69
+ {onBack !== undefined && (
70
+ <IconButton
71
+ onPress={onBack}
72
+ theme={theme}
73
+ accessibilityLabel={strings.t('compose.back')}
74
+ size={32}
75
+ >
76
+ <Chevron
77
+ color={theme.colors.textSecondary}
78
+ direction="left"
79
+ size={12}
80
+ />
81
+ </IconButton>
82
+ )}
83
+ <View style={{ flex: 1, gap: 2 }}>
84
+ <Text style={textStyles.title(theme)}>{strings.t('lang.title')}</Text>
85
+ <Text style={textStyles.secondary(theme)}>
86
+ {strings.t('lang.subtitle')}
87
+ </Text>
88
+ </View>
89
+ {onDismiss !== undefined && (
90
+ <IconButton
91
+ onPress={onDismiss}
92
+ theme={theme}
93
+ accessibilityLabel={strings.t('home.close')}
94
+ size={32}
95
+ >
96
+ <CloseIcon color={theme.colors.textSecondary} size={12} />
97
+ </IconButton>
98
+ )}
99
+ </View>
100
+
101
+ <ScrollView style={{ flex: 1 }} contentContainerStyle={{ paddingBottom: 8 }}>
102
+ <View
103
+ style={{
104
+ marginHorizontal: 16,
105
+ borderWidth: 1,
106
+ borderColor: theme.colors.border,
107
+ borderRadius: theme.radii.card,
108
+ backgroundColor: theme.colors.surface,
109
+ overflow: 'hidden',
110
+ }}
111
+ >
112
+ {languages.map((language, i) => {
113
+ const active = language === selected;
114
+ return (
115
+ <PressableRow
116
+ key={language}
117
+ onPress={() => onSelect(language)}
118
+ theme={theme}
119
+ accessibilityLabel={LANGUAGE_LABELS[language]}
120
+ style={{
121
+ flexDirection: 'row',
122
+ alignItems: 'center',
123
+ gap: 12,
124
+ paddingHorizontal: 16,
125
+ paddingVertical: 13,
126
+ borderTopWidth: i === 0 ? 0 : 1,
127
+ borderTopColor: theme.colors.border,
128
+ }}
129
+ >
130
+ <Text
131
+ style={{
132
+ flex: 1,
133
+ fontSize: 15,
134
+ fontWeight: active ? '600' : '400',
135
+ color: theme.colors.textPrimary,
136
+ }}
137
+ >
138
+ {LANGUAGE_LABELS[language]}
139
+ </Text>
140
+ {active && <CheckIcon color={theme.colors.accent} size={16} />}
141
+ </PressableRow>
142
+ );
143
+ })}
144
+ </View>
145
+ </ScrollView>
146
+
147
+ <View
148
+ style={{
149
+ paddingHorizontal: 16,
150
+ paddingTop: 12,
151
+ paddingBottom: 8,
152
+ gap: 8,
153
+ borderTopWidth: 1,
154
+ borderTopColor: theme.colors.border,
155
+ }}
156
+ >
157
+ {error !== null && (
158
+ <Text style={{ fontSize: 12, color: theme.colors.statusDanger }}>
159
+ {error}
160
+ </Text>
161
+ )}
162
+ <PrimaryButton
163
+ label={saving ? strings.t('compose.sending') : strings.t('lang.continue')}
164
+ onPress={onConfirm}
165
+ disabled={saving}
166
+ theme={theme}
167
+ fullWidth
168
+ />
169
+ </View>
170
+ </View>
171
+ );
172
+ }
@@ -7,6 +7,7 @@ import {
7
7
  TextInput,
8
8
  View,
9
9
  } from 'react-native';
10
+ import type { Translator } from '../../i18n';
10
11
  import type { SupportTheme } from '../../theme';
11
12
  import type { Thread, ThreadMessage } from '../../types';
12
13
  import { USER_MESSAGE_MAX, USER_MESSAGE_MIN } from '../../types';
@@ -34,7 +35,11 @@ interface Row {
34
35
  startsUnread: boolean;
35
36
  }
36
37
 
37
- function buildRows(messages: ThreadMessage[], unreadCount: number): Row[] {
38
+ function buildRows(
39
+ messages: ThreadMessage[],
40
+ unreadCount: number,
41
+ strings: Translator,
42
+ ): Row[] {
38
43
  // Unread messages are always the newest admin ones, so the divider goes
39
44
  // above the Nth-from-last admin message.
40
45
  let remaining = unreadCount;
@@ -49,16 +54,18 @@ function buildRows(messages: ThreadMessage[], unreadCount: number): Row[] {
49
54
  return messages.map((message, i) => {
50
55
  const previous = i > 0 ? (messages[i - 1] ?? null) : null;
51
56
  const next = i < messages.length - 1 ? (messages[i + 1] ?? null) : null;
57
+ // Grouped by sender alone. Two replies from two different admins are two
58
+ // messages from "Support" as far as this side is concerned, so breaking
59
+ // the group between them would be a visible seam with no visible cause.
52
60
  const withinGroup = (a: ThreadMessage | null, b: ThreadMessage) =>
53
61
  a !== null &&
54
62
  a.sender === b.sender &&
55
- a.admin_name === b.admin_name &&
56
63
  Math.abs(Date.parse(b.created_at) - Date.parse(a.created_at)) <
57
64
  GROUP_WINDOW_MS;
58
65
 
59
66
  const day =
60
67
  previous === null || !isSameDay(previous.created_at, message.created_at)
61
- ? dayLabel(message.created_at)
68
+ ? dayLabel(message.created_at, strings)
62
69
  : null;
63
70
  const startsUnread = message.id === firstUnreadId;
64
71
 
@@ -127,7 +134,15 @@ function Separator({
127
134
  );
128
135
  }
129
136
 
130
- function MessageBubble({ row, theme }: { row: Row; theme: SupportTheme }) {
137
+ function MessageBubble({
138
+ row,
139
+ theme,
140
+ strings,
141
+ }: {
142
+ row: Row;
143
+ theme: SupportTheme;
144
+ strings: Translator;
145
+ }) {
131
146
  const { message, firstInGroup, lastInGroup } = row;
132
147
  const own = message.sender === 'user';
133
148
  const round = theme.radii.bubble;
@@ -145,7 +160,7 @@ function MessageBubble({ row, theme }: { row: Row; theme: SupportTheme }) {
145
160
  >
146
161
  {!own && firstInGroup && (
147
162
  <Text style={[textStyles.meta(theme), { paddingHorizontal: 4 }]}>
148
- {message.admin_name ?? 'Support'}
163
+ {strings.t('thread.support')}
149
164
  </Text>
150
165
  )}
151
166
  <View
@@ -172,7 +187,11 @@ function MessageBubble({ row, theme }: { row: Row; theme: SupportTheme }) {
172
187
  </View>
173
188
  {lastInGroup && (
174
189
  <Text style={[textStyles.meta(theme), { paddingHorizontal: 4 }]}>
175
- {formatTime(message.created_at)}
190
+ {/* A reply whose translation is still in flight says so, rather
191
+ than showing English that changes under the reader. */}
192
+ {message.translation_pending
193
+ ? strings.t('thread.translating')
194
+ : formatTime(message.created_at, strings)}
176
195
  </Text>
177
196
  )}
178
197
  </View>
@@ -181,11 +200,13 @@ function MessageBubble({ row, theme }: { row: Row; theme: SupportTheme }) {
181
200
 
182
201
  export function ThreadScreen({
183
202
  theme,
203
+ strings,
184
204
  thread,
185
205
  onBack,
186
206
  onSend,
187
207
  }: {
188
208
  theme: SupportTheme;
209
+ strings: Translator;
189
210
  thread: Thread;
190
211
  onBack: () => void;
191
212
  /** Resolves null on success, or a user-facing error message. */
@@ -206,8 +227,8 @@ export function ThreadScreen({
206
227
  const tooShort = trimmed.length > 0 && trimmed.length < USER_MESSAGE_MIN;
207
228
 
208
229
  const rows = useMemo(
209
- () => buildRows(thread.messages, unreadOnOpen),
210
- [thread.messages, unreadOnOpen],
230
+ () => buildRows(thread.messages, unreadOnOpen, strings),
231
+ [thread.messages, unreadOnOpen, strings],
211
232
  );
212
233
 
213
234
  useEffect(() => {
@@ -244,15 +265,15 @@ export function ThreadScreen({
244
265
  <IconButton
245
266
  onPress={onBack}
246
267
  theme={theme}
247
- accessibilityLabel="Back to conversations"
268
+ accessibilityLabel={strings.t('compose.back')}
248
269
  size={32}
249
270
  >
250
271
  <Chevron color={theme.colors.textSecondary} direction="left" size={12} />
251
272
  </IconButton>
252
- <TypeChipView type={thread.type} theme={theme} />
253
- <StatusIndicator status={thread.status} theme={theme} />
273
+ <TypeChipView type={thread.type} theme={theme} strings={strings} />
274
+ <StatusIndicator status={thread.status} theme={theme} strings={strings} />
254
275
  <Text style={[textStyles.meta(theme), { marginLeft: 'auto' }]}>
255
- {relativeTime(thread.created_at)}
276
+ {relativeTime(thread.created_at, strings)}
256
277
  </Text>
257
278
  </View>
258
279
 
@@ -270,18 +291,22 @@ export function ThreadScreen({
270
291
  <View key={row.message.id}>
271
292
  {row.day !== null && <Separator label={row.day} theme={theme} />}
272
293
  {row.startsUnread && (
273
- <Separator label="New" theme={theme} tone="accent" />
294
+ <Separator
295
+ label={strings.t('thread.new')}
296
+ theme={theme}
297
+ tone="accent"
298
+ />
274
299
  )}
275
- <MessageBubble row={row} theme={theme} />
300
+ <MessageBubble row={row} theme={theme} strings={strings} />
276
301
  </View>
277
302
  ))}
278
303
 
279
304
  {resolved && thread.resolved_at !== null && (
280
305
  <View style={{ marginTop: 16 }}>
281
306
  <Separator
282
- label={`Resolved by ${thread.resolved_by_name ?? 'support'} · ${formatDate(
283
- thread.resolved_at,
284
- )}`}
307
+ label={strings.t('thread.resolvedAt', {
308
+ date: formatDate(thread.resolved_at, strings),
309
+ })}
285
310
  theme={theme}
286
311
  />
287
312
  </View>
@@ -308,7 +333,7 @@ export function ThreadScreen({
308
333
  }}
309
334
  >
310
335
  <Text style={textStyles.secondary(theme)}>
311
- This conversation is resolved and read-only
336
+ {strings.t('thread.readOnly')}
312
337
  </Text>
313
338
  </View>
314
339
  ) : (
@@ -322,7 +347,7 @@ export function ThreadScreen({
322
347
  }}
323
348
  multiline
324
349
  editable={!sending}
325
- placeholder="Write a reply…"
350
+ placeholder={strings.t('thread.placeholder')}
326
351
  placeholderTextColor={theme.colors.textTertiary}
327
352
  style={{
328
353
  flex: 1,
@@ -347,7 +372,9 @@ export function ThreadScreen({
347
372
  tone="accent"
348
373
  size={44}
349
374
  disabled={!valid || sending}
350
- accessibilityLabel={sending ? 'Sending' : 'Send reply'}
375
+ accessibilityLabel={strings.t(
376
+ sending ? 'thread.sendingA11y' : 'thread.sendA11y',
377
+ )}
351
378
  >
352
379
  <ArrowUpIcon color={theme.colors.accentForeground} size={19} />
353
380
  </IconButton>
@@ -371,7 +398,7 @@ export function ThreadScreen({
371
398
  </Text>
372
399
  ) : tooShort ? (
373
400
  <Text style={textStyles.meta(theme)}>
374
- At least {USER_MESSAGE_MIN} characters
401
+ {strings.t('compose.minChars', { count: USER_MESSAGE_MIN })}
375
402
  </Text>
376
403
  ) : null}
377
404
  {draft.length >= COUNTER_VISIBLE_FROM && (
@@ -0,0 +1,81 @@
1
+ // UI copy, in the user's language.
2
+ //
3
+ // Message bodies are translated by the backend at write time; the sheet's own
4
+ // chrome is not — it is a fixed set of strings that never changes, so it ships
5
+ // as a bundled table. A model call to render the word "Send" would be absurd,
6
+ // and it would also mean the SDK couldn't draw its own UI offline.
7
+
8
+ import { DEFAULT_LANGUAGE, type Language } from './languages';
9
+ import { en, type StringKey, type StringTable } from './strings/en';
10
+ import { de } from './strings/de';
11
+ import { es } from './strings/es';
12
+ import { fr } from './strings/fr';
13
+ import { hi } from './strings/hi';
14
+ import { it } from './strings/it';
15
+ import { ja } from './strings/ja';
16
+ import { ko } from './strings/ko';
17
+ import { nl } from './strings/nl';
18
+ import { pl } from './strings/pl';
19
+ import { pt } from './strings/pt';
20
+ import { ru } from './strings/ru';
21
+ import { tr } from './strings/tr';
22
+ import { zh } from './strings/zh';
23
+
24
+ const TABLES: Record<Language, StringTable> = {
25
+ en,
26
+ es,
27
+ fr,
28
+ de,
29
+ nl,
30
+ pt,
31
+ it,
32
+ pl,
33
+ tr,
34
+ ru,
35
+ ja,
36
+ ko,
37
+ zh,
38
+ hi,
39
+ };
40
+
41
+ /** Fills `{name}` placeholders. Unknown tokens are left alone, not blanked. */
42
+ function format(
43
+ template: string,
44
+ vars?: Record<string, string | number>,
45
+ ): string {
46
+ if (!vars) return template;
47
+ return template.replace(/\{(\w+)\}/g, (match, key: string) =>
48
+ key in vars ? String(vars[key]) : match,
49
+ );
50
+ }
51
+
52
+ export interface Translator {
53
+ language: Language;
54
+ /**
55
+ * BCP-47 tag for Intl — dates and times stay in the user's chosen language
56
+ * rather than the device's, so a sheet never mixes the two.
57
+ */
58
+ locale: string;
59
+ t: (key: StringKey, vars?: Record<string, string | number>) => string;
60
+ }
61
+
62
+ export function createTranslator(language: Language): Translator {
63
+ const table = TABLES[language] ?? TABLES[DEFAULT_LANGUAGE];
64
+ return {
65
+ language,
66
+ locale: language,
67
+ t: (key, vars) => format(table[key] ?? en[key], vars),
68
+ };
69
+ }
70
+
71
+ export type { StringKey, StringTable };
72
+ export {
73
+ asLanguage,
74
+ baseLanguage,
75
+ DEFAULT_LANGUAGE,
76
+ deviceLanguage,
77
+ isSupportedLanguage,
78
+ LANGUAGE_LABELS,
79
+ SUPPORTED_LANGUAGES,
80
+ type Language,
81
+ } from './languages';