@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,4 +1,4 @@
1
- import { useEffect, useRef, useState } from 'react';
1
+ import { useEffect, useMemo, useRef, useState } from 'react';
2
2
  import {
3
3
  KeyboardAvoidingView,
4
4
  Platform,
@@ -7,26 +7,206 @@ 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
- import type { Thread } from '../../types';
12
+ import type { Thread, ThreadMessage } from '../../types';
12
13
  import { USER_MESSAGE_MAX, USER_MESSAGE_MIN } from '../../types';
13
- import { formatDate, relativeTime } from '../../utils';
14
- import { Chevron } from '../icons';
14
+ import { dayLabel, formatDate, formatTime, isSameDay, relativeTime } from '../../utils';
15
+ import { ArrowUpIcon, Chevron } from '../icons';
15
16
  import {
16
- PressableRow,
17
- PrimaryButton,
17
+ IconButton,
18
18
  StatusIndicator,
19
19
  textStyles,
20
20
  TypeChipView,
21
21
  } from '../primitives';
22
22
 
23
+ /** Consecutive messages from one sender inside this window form a group. */
24
+ const GROUP_WINDOW_MS = 5 * 60 * 1000;
25
+ /** The counter only appears once the limit is actually in play. */
26
+ const COUNTER_VISIBLE_FROM = USER_MESSAGE_MAX - 60;
27
+
28
+ interface Row {
29
+ message: ThreadMessage;
30
+ firstInGroup: boolean;
31
+ lastInGroup: boolean;
32
+ /** Date separator to draw above this message, if any. */
33
+ day: string | null;
34
+ /** "New" separator above the first message the user hasn't seen. */
35
+ startsUnread: boolean;
36
+ }
37
+
38
+ function buildRows(
39
+ messages: ThreadMessage[],
40
+ unreadCount: number,
41
+ strings: Translator,
42
+ ): Row[] {
43
+ // Unread messages are always the newest admin ones, so the divider goes
44
+ // above the Nth-from-last admin message.
45
+ let remaining = unreadCount;
46
+ let firstUnreadId: string | null = null;
47
+ for (let i = messages.length - 1; i >= 0 && remaining > 0; i -= 1) {
48
+ const candidate = messages[i];
49
+ if (candidate === undefined || candidate.sender !== 'admin') continue;
50
+ remaining -= 1;
51
+ firstUnreadId = candidate.id;
52
+ }
53
+
54
+ return messages.map((message, i) => {
55
+ const previous = i > 0 ? (messages[i - 1] ?? null) : null;
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.
60
+ const withinGroup = (a: ThreadMessage | null, b: ThreadMessage) =>
61
+ a !== null &&
62
+ a.sender === b.sender &&
63
+ Math.abs(Date.parse(b.created_at) - Date.parse(a.created_at)) <
64
+ GROUP_WINDOW_MS;
65
+
66
+ const day =
67
+ previous === null || !isSameDay(previous.created_at, message.created_at)
68
+ ? dayLabel(message.created_at, strings)
69
+ : null;
70
+ const startsUnread = message.id === firstUnreadId;
71
+
72
+ return {
73
+ message,
74
+ // A separator always breaks a group.
75
+ firstInGroup: day !== null || startsUnread || !withinGroup(previous, message),
76
+ lastInGroup:
77
+ next === null ||
78
+ next.id === firstUnreadId ||
79
+ !isSameDay(next.created_at, message.created_at) ||
80
+ !withinGroup(next, message),
81
+ day,
82
+ startsUnread,
83
+ };
84
+ });
85
+ }
86
+
87
+ function Separator({
88
+ label,
89
+ theme,
90
+ tone = 'muted',
91
+ }: {
92
+ label: string;
93
+ theme: SupportTheme;
94
+ tone?: 'muted' | 'accent';
95
+ }) {
96
+ const color =
97
+ tone === 'accent' ? theme.colors.accent : theme.colors.textTertiary;
98
+ return (
99
+ <View
100
+ style={{
101
+ flexDirection: 'row',
102
+ alignItems: 'center',
103
+ gap: 10,
104
+ paddingVertical: 6,
105
+ }}
106
+ >
107
+ <View
108
+ style={{
109
+ flex: 1,
110
+ height: 1,
111
+ backgroundColor:
112
+ tone === 'accent' ? theme.colors.accentMuted : theme.colors.border,
113
+ }}
114
+ />
115
+ <Text
116
+ style={{
117
+ fontSize: 11,
118
+ fontWeight: '600',
119
+ letterSpacing: 0.3,
120
+ color,
121
+ }}
122
+ >
123
+ {label}
124
+ </Text>
125
+ <View
126
+ style={{
127
+ flex: 1,
128
+ height: 1,
129
+ backgroundColor:
130
+ tone === 'accent' ? theme.colors.accentMuted : theme.colors.border,
131
+ }}
132
+ />
133
+ </View>
134
+ );
135
+ }
136
+
137
+ function MessageBubble({
138
+ row,
139
+ theme,
140
+ strings,
141
+ }: {
142
+ row: Row;
143
+ theme: SupportTheme;
144
+ strings: Translator;
145
+ }) {
146
+ const { message, firstInGroup, lastInGroup } = row;
147
+ const own = message.sender === 'user';
148
+ const round = theme.radii.bubble;
149
+ const tight = theme.radii.bubbleTail;
150
+
151
+ return (
152
+ <View
153
+ style={{
154
+ maxWidth: '86%',
155
+ alignSelf: own ? 'flex-end' : 'flex-start',
156
+ alignItems: own ? 'flex-end' : 'flex-start',
157
+ marginTop: firstInGroup ? 10 : 2,
158
+ gap: 4,
159
+ }}
160
+ >
161
+ {!own && firstInGroup && (
162
+ <Text style={[textStyles.meta(theme), { paddingHorizontal: 4 }]}>
163
+ {strings.t('thread.support')}
164
+ </Text>
165
+ )}
166
+ <View
167
+ style={{
168
+ paddingHorizontal: 14,
169
+ paddingVertical: 10,
170
+ backgroundColor: own ? theme.colors.accent : theme.colors.surface,
171
+ borderWidth: own ? 0 : 1,
172
+ borderColor: theme.colors.border,
173
+ borderTopLeftRadius: own || firstInGroup ? round : tight,
174
+ borderBottomLeftRadius: own || lastInGroup ? round : tight,
175
+ borderTopRightRadius: !own || firstInGroup ? round : tight,
176
+ borderBottomRightRadius: !own || lastInGroup ? round : tight,
177
+ }}
178
+ >
179
+ <Text
180
+ style={[
181
+ textStyles.body(theme),
182
+ own && { color: theme.colors.accentForeground },
183
+ ]}
184
+ >
185
+ {message.body}
186
+ </Text>
187
+ </View>
188
+ {lastInGroup && (
189
+ <Text style={[textStyles.meta(theme), { paddingHorizontal: 4 }]}>
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)}
195
+ </Text>
196
+ )}
197
+ </View>
198
+ );
199
+ }
200
+
23
201
  export function ThreadScreen({
24
202
  theme,
203
+ strings,
25
204
  thread,
26
205
  onBack,
27
206
  onSend,
28
207
  }: {
29
208
  theme: SupportTheme;
209
+ strings: Translator;
30
210
  thread: Thread;
31
211
  onBack: () => void;
32
212
  /** Resolves null on success, or a user-facing error message. */
@@ -36,11 +216,20 @@ export function ThreadScreen({
36
216
  const [sending, setSending] = useState(false);
37
217
  const [failedMessage, setFailedMessage] = useState<string | null>(null);
38
218
  const scrollRef = useRef<ScrollView>(null);
219
+ // Captured once: opening the thread marks it read within a tick, but the
220
+ // "New" divider should stay put while the user is reading.
221
+ const [unreadOnOpen] = useState(thread.unread_count);
39
222
 
40
223
  const resolved = thread.status === 'resolved';
41
224
  const trimmed = draft.trim();
42
225
  const valid =
43
226
  trimmed.length >= USER_MESSAGE_MIN && trimmed.length <= USER_MESSAGE_MAX;
227
+ const tooShort = trimmed.length > 0 && trimmed.length < USER_MESSAGE_MIN;
228
+
229
+ const rows = useMemo(
230
+ () => buildRows(thread.messages, unreadOnOpen, strings),
231
+ [thread.messages, unreadOnOpen, strings],
232
+ );
44
233
 
45
234
  useEffect(() => {
46
235
  scrollRef.current?.scrollToEnd({ animated: false });
@@ -66,25 +255,25 @@ export function ThreadScreen({
66
255
  style={{
67
256
  flexDirection: 'row',
68
257
  alignItems: 'center',
69
- gap: 8,
258
+ gap: 10,
70
259
  paddingHorizontal: 12,
71
- paddingBottom: 10,
260
+ paddingBottom: 12,
72
261
  borderBottomWidth: 1,
73
262
  borderBottomColor: theme.colors.border,
74
263
  }}
75
264
  >
76
- <PressableRow
265
+ <IconButton
77
266
  onPress={onBack}
78
267
  theme={theme}
79
- accessibilityLabel="Back"
80
- style={{ padding: 6, borderRadius: theme.radii.control }}
268
+ accessibilityLabel={strings.t('compose.back')}
269
+ size={32}
81
270
  >
82
271
  <Chevron color={theme.colors.textSecondary} direction="left" size={12} />
83
- </PressableRow>
84
- <TypeChipView type={thread.type} theme={theme} />
85
- <StatusIndicator status={thread.status} theme={theme} />
272
+ </IconButton>
273
+ <TypeChipView type={thread.type} theme={theme} strings={strings} />
274
+ <StatusIndicator status={thread.status} theme={theme} strings={strings} />
86
275
  <Text style={[textStyles.meta(theme), { marginLeft: 'auto' }]}>
87
- {relativeTime(thread.created_at)}
276
+ {relativeTime(thread.created_at, strings)}
88
277
  </Text>
89
278
  </View>
90
279
 
@@ -92,54 +281,34 @@ export function ThreadScreen({
92
281
  <ScrollView
93
282
  ref={scrollRef}
94
283
  style={{ flex: 1 }}
95
- contentContainerStyle={{ padding: 16, gap: 10 }}
284
+ contentContainerStyle={{ paddingHorizontal: 16, paddingVertical: 12 }}
285
+ keyboardShouldPersistTaps="handled"
286
+ onContentSizeChange={() =>
287
+ scrollRef.current?.scrollToEnd({ animated: false })
288
+ }
96
289
  >
97
- {thread.messages.map((m) => {
98
- const own = m.sender === 'user';
99
- return (
100
- <View
101
- key={m.id}
102
- style={{
103
- maxWidth: '85%',
104
- alignSelf: own ? 'flex-end' : 'flex-start',
105
- alignItems: own ? 'flex-end' : 'flex-start',
106
- gap: 3,
107
- }}
108
- >
109
- {!own && (
110
- <Text style={textStyles.meta(theme)}>
111
- {m.admin_name ?? 'Support'}
112
- </Text>
113
- )}
114
- <View
115
- style={{
116
- borderRadius: theme.radii.card,
117
- paddingHorizontal: 12,
118
- paddingVertical: 8,
119
- backgroundColor: own
120
- ? theme.colors.accentMuted
121
- : theme.colors.surface,
122
- borderWidth: own ? 0 : 1,
123
- borderColor: theme.colors.border,
124
- }}
125
- >
126
- <Text style={textStyles.body(theme)}>{m.body}</Text>
127
- </View>
128
- <Text style={textStyles.meta(theme)}>
129
- {relativeTime(m.created_at)}
130
- </Text>
131
- </View>
132
- );
133
- })}
134
-
135
- {resolved && thread.resolved_at && (
136
- <View style={{ flexDirection: 'row', alignItems: 'center', gap: 10, marginVertical: 6 }}>
137
- <View style={{ flex: 1, height: 1, backgroundColor: theme.colors.border }} />
138
- <Text style={textStyles.meta(theme)}>
139
- Resolved by {thread.resolved_by_name ?? 'support'} ·{' '}
140
- {formatDate(thread.resolved_at)}
141
- </Text>
142
- <View style={{ flex: 1, height: 1, backgroundColor: theme.colors.border }} />
290
+ {rows.map((row) => (
291
+ <View key={row.message.id}>
292
+ {row.day !== null && <Separator label={row.day} theme={theme} />}
293
+ {row.startsUnread && (
294
+ <Separator
295
+ label={strings.t('thread.new')}
296
+ theme={theme}
297
+ tone="accent"
298
+ />
299
+ )}
300
+ <MessageBubble row={row} theme={theme} strings={strings} />
301
+ </View>
302
+ ))}
303
+
304
+ {resolved && thread.resolved_at !== null && (
305
+ <View style={{ marginTop: 16 }}>
306
+ <Separator
307
+ label={strings.t('thread.resolvedAt', {
308
+ date: formatDate(thread.resolved_at, strings),
309
+ })}
310
+ theme={theme}
311
+ />
143
312
  </View>
144
313
  )}
145
314
  </ScrollView>
@@ -151,74 +320,102 @@ export function ThreadScreen({
151
320
  borderTopColor: theme.colors.border,
152
321
  paddingHorizontal: 16,
153
322
  paddingTop: 10,
323
+ paddingBottom: 4,
154
324
  }}
155
325
  >
156
326
  {resolved ? (
157
- <Text
158
- style={[
159
- textStyles.meta(theme),
160
- { textAlign: 'center', paddingVertical: 8 },
161
- ]}
327
+ <View
328
+ style={{
329
+ alignItems: 'center',
330
+ paddingVertical: 10,
331
+ borderRadius: theme.radii.control,
332
+ backgroundColor: theme.colors.surface,
333
+ }}
162
334
  >
163
- This thread is resolved and read-only
164
- </Text>
335
+ <Text style={textStyles.secondary(theme)}>
336
+ {strings.t('thread.readOnly')}
337
+ </Text>
338
+ </View>
165
339
  ) : (
166
340
  <>
167
- <TextInput
168
- value={draft}
169
- onChangeText={(v) => {
170
- setDraft(v);
171
- setFailedMessage(null);
172
- }}
173
- multiline
174
- placeholder="Reply"
175
- placeholderTextColor={theme.colors.textTertiary}
176
- style={{
177
- minHeight: 60,
178
- maxHeight: 120,
179
- borderWidth: 1,
180
- borderColor: theme.colors.border,
181
- borderRadius: theme.radii.control,
182
- backgroundColor: theme.colors.surface,
183
- color: theme.colors.textPrimary,
184
- fontSize: 14,
185
- lineHeight: 20,
186
- padding: 10,
187
- textAlignVertical: 'top',
188
- }}
189
- />
190
- <View
191
- style={{
192
- flexDirection: 'row',
193
- alignItems: 'center',
194
- gap: 12,
195
- paddingVertical: 8,
196
- }}
197
- >
198
- <Text
199
- style={[
200
- textStyles.meta(theme),
201
- draft.length > USER_MESSAGE_MAX && {
202
- color: theme.colors.statusDanger,
203
- },
204
- ]}
341
+ <View style={{ flexDirection: 'row', alignItems: 'flex-end', gap: 10 }}>
342
+ <TextInput
343
+ value={draft}
344
+ onChangeText={(v) => {
345
+ setDraft(v);
346
+ setFailedMessage(null);
347
+ }}
348
+ multiline
349
+ editable={!sending}
350
+ placeholder={strings.t('thread.placeholder')}
351
+ placeholderTextColor={theme.colors.textTertiary}
352
+ style={{
353
+ flex: 1,
354
+ minHeight: 44,
355
+ maxHeight: 120,
356
+ borderWidth: 1,
357
+ borderColor: theme.colors.border,
358
+ borderRadius: theme.radii.cardLg,
359
+ backgroundColor: theme.colors.surface,
360
+ color: theme.colors.textPrimary,
361
+ fontSize: 15,
362
+ lineHeight: 20,
363
+ paddingHorizontal: 14,
364
+ paddingTop: 12,
365
+ paddingBottom: 12,
366
+ textAlignVertical: 'top',
367
+ }}
368
+ />
369
+ <IconButton
370
+ onPress={handleSend}
371
+ theme={theme}
372
+ tone="accent"
373
+ size={44}
374
+ disabled={!valid || sending}
375
+ accessibilityLabel={strings.t(
376
+ sending ? 'thread.sendingA11y' : 'thread.sendA11y',
377
+ )}
205
378
  >
206
- {draft.length}/{USER_MESSAGE_MAX}
207
- </Text>
208
- {failedMessage !== null && (
209
- <Text style={{ fontSize: 11, color: theme.colors.statusDanger }}>
210
- {failedMessage}
211
- </Text>
212
- )}
213
- <View style={{ marginLeft: 'auto' }}>
214
- <PrimaryButton
215
- label={sending ? 'Sending…' : 'Send'}
216
- onPress={handleSend}
217
- disabled={!valid || sending}
218
- theme={theme}
219
- />
220
- </View>
379
+ <ArrowUpIcon color={theme.colors.accentForeground} size={19} />
380
+ </IconButton>
221
381
  </View>
382
+
383
+ {(tooShort ||
384
+ draft.length >= COUNTER_VISIBLE_FROM ||
385
+ failedMessage !== null) && (
386
+ <View
387
+ style={{
388
+ flexDirection: 'row',
389
+ alignItems: 'center',
390
+ gap: 10,
391
+ paddingTop: 8,
392
+ paddingHorizontal: 4,
393
+ }}
394
+ >
395
+ {failedMessage !== null ? (
396
+ <Text style={{ fontSize: 12, color: theme.colors.statusDanger }}>
397
+ {failedMessage}
398
+ </Text>
399
+ ) : tooShort ? (
400
+ <Text style={textStyles.meta(theme)}>
401
+ {strings.t('compose.minChars', { count: USER_MESSAGE_MIN })}
402
+ </Text>
403
+ ) : null}
404
+ {draft.length >= COUNTER_VISIBLE_FROM && (
405
+ <Text
406
+ style={[
407
+ textStyles.meta(theme),
408
+ { marginLeft: 'auto' },
409
+ draft.length > USER_MESSAGE_MAX && {
410
+ color: theme.colors.statusDanger,
411
+ },
412
+ ]}
413
+ >
414
+ {draft.length}/{USER_MESSAGE_MAX}
415
+ </Text>
416
+ )}
417
+ </View>
418
+ )}
222
419
  </>
223
420
  )}
224
421
  </View>
@@ -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';