@appsupportkit/support-sdk 0.3.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 (208) hide show
  1. package/lib/commonjs/api.js +49 -15
  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 +123 -97
  8. package/lib/commonjs/components/SupportButton.js.map +1 -1
  9. package/lib/commonjs/components/icons.js +81 -110
  10. package/lib/commonjs/components/icons.js.map +1 -1
  11. package/lib/commonjs/components/primitives.js +0 -153
  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/i18n/strings/de.js +3 -0
  16. package/lib/commonjs/i18n/strings/de.js.map +1 -1
  17. package/lib/commonjs/i18n/strings/en.js +3 -0
  18. package/lib/commonjs/i18n/strings/en.js.map +1 -1
  19. package/lib/commonjs/i18n/strings/es.js +3 -0
  20. package/lib/commonjs/i18n/strings/es.js.map +1 -1
  21. package/lib/commonjs/i18n/strings/fr.js +3 -0
  22. package/lib/commonjs/i18n/strings/fr.js.map +1 -1
  23. package/lib/commonjs/i18n/strings/hi.js +3 -0
  24. package/lib/commonjs/i18n/strings/hi.js.map +1 -1
  25. package/lib/commonjs/i18n/strings/it.js +3 -0
  26. package/lib/commonjs/i18n/strings/it.js.map +1 -1
  27. package/lib/commonjs/i18n/strings/ja.js +3 -0
  28. package/lib/commonjs/i18n/strings/ja.js.map +1 -1
  29. package/lib/commonjs/i18n/strings/ko.js +3 -0
  30. package/lib/commonjs/i18n/strings/ko.js.map +1 -1
  31. package/lib/commonjs/i18n/strings/nl.js +3 -0
  32. package/lib/commonjs/i18n/strings/nl.js.map +1 -1
  33. package/lib/commonjs/i18n/strings/pl.js +3 -0
  34. package/lib/commonjs/i18n/strings/pl.js.map +1 -1
  35. package/lib/commonjs/i18n/strings/pt.js +3 -0
  36. package/lib/commonjs/i18n/strings/pt.js.map +1 -1
  37. package/lib/commonjs/i18n/strings/ru.js +3 -0
  38. package/lib/commonjs/i18n/strings/ru.js.map +1 -1
  39. package/lib/commonjs/i18n/strings/tr.js +3 -0
  40. package/lib/commonjs/i18n/strings/tr.js.map +1 -1
  41. package/lib/commonjs/i18n/strings/zh.js +3 -0
  42. package/lib/commonjs/i18n/strings/zh.js.map +1 -1
  43. package/lib/commonjs/index.js +7 -0
  44. package/lib/commonjs/index.js.map +1 -1
  45. package/lib/commonjs/push.js +78 -3
  46. package/lib/commonjs/push.js.map +1 -1
  47. package/lib/commonjs/review.js +54 -0
  48. package/lib/commonjs/review.js.map +1 -0
  49. package/lib/commonjs/theme.js +142 -23
  50. package/lib/commonjs/theme.js.map +1 -1
  51. package/lib/commonjs/types.js +21 -3
  52. package/lib/commonjs/types.js.map +1 -1
  53. package/lib/commonjs/useSupportThreads.js +22 -15
  54. package/lib/commonjs/useSupportThreads.js.map +1 -1
  55. package/lib/commonjs/utils.js +13 -33
  56. package/lib/commonjs/utils.js.map +1 -1
  57. package/lib/commonjs/version.js +13 -0
  58. package/lib/commonjs/version.js.map +1 -0
  59. package/lib/module/api.js +46 -13
  60. package/lib/module/api.js.map +1 -1
  61. package/lib/module/components/BottomSheet.js +1 -1
  62. package/lib/module/components/BottomSheet.js.map +1 -1
  63. package/lib/module/components/LauncherButton.js +5 -5
  64. package/lib/module/components/LauncherButton.js.map +1 -1
  65. package/lib/module/components/SupportButton.js +128 -102
  66. package/lib/module/components/SupportButton.js.map +1 -1
  67. package/lib/module/components/icons.js +80 -107
  68. package/lib/module/components/icons.js.map +1 -1
  69. package/lib/module/components/primitives.js +2 -146
  70. package/lib/module/components/primitives.js.map +1 -1
  71. package/lib/module/components/screens/ChatScreen.js +485 -0
  72. package/lib/module/components/screens/ChatScreen.js.map +1 -0
  73. package/lib/module/i18n/strings/de.js +3 -0
  74. package/lib/module/i18n/strings/de.js.map +1 -1
  75. package/lib/module/i18n/strings/en.js +3 -0
  76. package/lib/module/i18n/strings/en.js.map +1 -1
  77. package/lib/module/i18n/strings/es.js +3 -0
  78. package/lib/module/i18n/strings/es.js.map +1 -1
  79. package/lib/module/i18n/strings/fr.js +3 -0
  80. package/lib/module/i18n/strings/fr.js.map +1 -1
  81. package/lib/module/i18n/strings/hi.js +3 -0
  82. package/lib/module/i18n/strings/hi.js.map +1 -1
  83. package/lib/module/i18n/strings/it.js +3 -0
  84. package/lib/module/i18n/strings/it.js.map +1 -1
  85. package/lib/module/i18n/strings/ja.js +3 -0
  86. package/lib/module/i18n/strings/ja.js.map +1 -1
  87. package/lib/module/i18n/strings/ko.js +3 -0
  88. package/lib/module/i18n/strings/ko.js.map +1 -1
  89. package/lib/module/i18n/strings/nl.js +3 -0
  90. package/lib/module/i18n/strings/nl.js.map +1 -1
  91. package/lib/module/i18n/strings/pl.js +3 -0
  92. package/lib/module/i18n/strings/pl.js.map +1 -1
  93. package/lib/module/i18n/strings/pt.js +3 -0
  94. package/lib/module/i18n/strings/pt.js.map +1 -1
  95. package/lib/module/i18n/strings/ru.js +3 -0
  96. package/lib/module/i18n/strings/ru.js.map +1 -1
  97. package/lib/module/i18n/strings/tr.js +3 -0
  98. package/lib/module/i18n/strings/tr.js.map +1 -1
  99. package/lib/module/i18n/strings/zh.js +3 -0
  100. package/lib/module/i18n/strings/zh.js.map +1 -1
  101. package/lib/module/index.js +1 -0
  102. package/lib/module/index.js.map +1 -1
  103. package/lib/module/push.js +77 -3
  104. package/lib/module/push.js.map +1 -1
  105. package/lib/module/review.js +48 -0
  106. package/lib/module/review.js.map +1 -0
  107. package/lib/module/theme.js +141 -23
  108. package/lib/module/theme.js.map +1 -1
  109. package/lib/module/types.js +21 -3
  110. package/lib/module/types.js.map +1 -1
  111. package/lib/module/useSupportThreads.js +22 -15
  112. package/lib/module/useSupportThreads.js.map +1 -1
  113. package/lib/module/utils.js +12 -32
  114. package/lib/module/utils.js.map +1 -1
  115. package/lib/module/version.js +9 -0
  116. package/lib/module/version.js.map +1 -0
  117. package/lib/typescript/api.d.ts +33 -12
  118. package/lib/typescript/api.d.ts.map +1 -1
  119. package/lib/typescript/components/SupportButton.d.ts +2 -2
  120. package/lib/typescript/components/SupportButton.d.ts.map +1 -1
  121. package/lib/typescript/components/icons.d.ts +5 -14
  122. package/lib/typescript/components/icons.d.ts.map +1 -1
  123. package/lib/typescript/components/primitives.d.ts +0 -31
  124. package/lib/typescript/components/primitives.d.ts.map +1 -1
  125. package/lib/typescript/components/screens/ChatScreen.d.ts +21 -0
  126. package/lib/typescript/components/screens/ChatScreen.d.ts.map +1 -0
  127. package/lib/typescript/i18n/strings/de.d.ts.map +1 -1
  128. package/lib/typescript/i18n/strings/en.d.ts +3 -0
  129. package/lib/typescript/i18n/strings/en.d.ts.map +1 -1
  130. package/lib/typescript/i18n/strings/es.d.ts.map +1 -1
  131. package/lib/typescript/i18n/strings/fr.d.ts.map +1 -1
  132. package/lib/typescript/i18n/strings/hi.d.ts.map +1 -1
  133. package/lib/typescript/i18n/strings/it.d.ts.map +1 -1
  134. package/lib/typescript/i18n/strings/ja.d.ts.map +1 -1
  135. package/lib/typescript/i18n/strings/ko.d.ts.map +1 -1
  136. package/lib/typescript/i18n/strings/nl.d.ts.map +1 -1
  137. package/lib/typescript/i18n/strings/pl.d.ts.map +1 -1
  138. package/lib/typescript/i18n/strings/pt.d.ts.map +1 -1
  139. package/lib/typescript/i18n/strings/ru.d.ts.map +1 -1
  140. package/lib/typescript/i18n/strings/tr.d.ts.map +1 -1
  141. package/lib/typescript/i18n/strings/zh.d.ts.map +1 -1
  142. package/lib/typescript/index.d.ts +3 -2
  143. package/lib/typescript/index.d.ts.map +1 -1
  144. package/lib/typescript/push.d.ts +14 -2
  145. package/lib/typescript/push.d.ts.map +1 -1
  146. package/lib/typescript/review.d.ts +15 -0
  147. package/lib/typescript/review.d.ts.map +1 -0
  148. package/lib/typescript/theme.d.ts +23 -5
  149. package/lib/typescript/theme.d.ts.map +1 -1
  150. package/lib/typescript/types.d.ts +54 -11
  151. package/lib/typescript/types.d.ts.map +1 -1
  152. package/lib/typescript/useSupportThreads.d.ts +9 -3
  153. package/lib/typescript/useSupportThreads.d.ts.map +1 -1
  154. package/lib/typescript/utils.d.ts +3 -2
  155. package/lib/typescript/utils.d.ts.map +1 -1
  156. package/lib/typescript/version.d.ts +7 -0
  157. package/lib/typescript/version.d.ts.map +1 -0
  158. package/package.json +1 -1
  159. package/src/api.ts +59 -30
  160. package/src/components/BottomSheet.tsx +1 -1
  161. package/src/components/LauncherButton.tsx +4 -4
  162. package/src/components/SupportButton.tsx +140 -111
  163. package/src/components/icons.tsx +60 -101
  164. package/src/components/primitives.tsx +1 -163
  165. package/src/components/screens/ChatScreen.tsx +541 -0
  166. package/src/i18n/strings/de.ts +3 -0
  167. package/src/i18n/strings/en.ts +3 -0
  168. package/src/i18n/strings/es.ts +3 -0
  169. package/src/i18n/strings/fr.ts +3 -0
  170. package/src/i18n/strings/hi.ts +3 -0
  171. package/src/i18n/strings/it.ts +3 -0
  172. package/src/i18n/strings/ja.ts +3 -0
  173. package/src/i18n/strings/ko.ts +3 -0
  174. package/src/i18n/strings/nl.ts +3 -0
  175. package/src/i18n/strings/pl.ts +3 -0
  176. package/src/i18n/strings/pt.ts +3 -0
  177. package/src/i18n/strings/ru.ts +3 -0
  178. package/src/i18n/strings/tr.ts +3 -0
  179. package/src/i18n/strings/zh.ts +3 -0
  180. package/src/index.tsx +5 -0
  181. package/src/push.ts +100 -2
  182. package/src/review.ts +54 -0
  183. package/src/theme.ts +182 -28
  184. package/src/types.ts +53 -11
  185. package/src/useSupportThreads.ts +31 -18
  186. package/src/utils.ts +14 -21
  187. package/src/version.ts +6 -0
  188. package/lib/commonjs/components/screens/ComposeScreen.js +0 -219
  189. package/lib/commonjs/components/screens/ComposeScreen.js.map +0 -1
  190. package/lib/commonjs/components/screens/HomeScreen.js +0 -334
  191. package/lib/commonjs/components/screens/HomeScreen.js.map +0 -1
  192. package/lib/commonjs/components/screens/ThreadScreen.js +0 -339
  193. package/lib/commonjs/components/screens/ThreadScreen.js.map +0 -1
  194. package/lib/module/components/screens/ComposeScreen.js +0 -217
  195. package/lib/module/components/screens/ComposeScreen.js.map +0 -1
  196. package/lib/module/components/screens/HomeScreen.js +0 -330
  197. package/lib/module/components/screens/HomeScreen.js.map +0 -1
  198. package/lib/module/components/screens/ThreadScreen.js +0 -337
  199. package/lib/module/components/screens/ThreadScreen.js.map +0 -1
  200. package/lib/typescript/components/screens/ComposeScreen.d.ts +0 -12
  201. package/lib/typescript/components/screens/ComposeScreen.d.ts.map +0 -1
  202. package/lib/typescript/components/screens/HomeScreen.d.ts +0 -19
  203. package/lib/typescript/components/screens/HomeScreen.d.ts.map +0 -1
  204. package/lib/typescript/components/screens/ThreadScreen.d.ts +0 -12
  205. package/lib/typescript/components/screens/ThreadScreen.d.ts.map +0 -1
  206. package/src/components/screens/ComposeScreen.tsx +0 -229
  207. package/src/components/screens/HomeScreen.tsx +0 -346
  208. package/src/components/screens/ThreadScreen.tsx +0 -424
@@ -0,0 +1,541 @@
1
+ import { useEffect, useMemo, useRef, useState } from 'react';
2
+ import {
3
+ KeyboardAvoidingView,
4
+ Platform,
5
+ Pressable,
6
+ ScrollView,
7
+ Text,
8
+ TextInput,
9
+ View,
10
+ } from 'react-native';
11
+ import type { Translator } from '../../i18n';
12
+ import { LANGUAGE_LABELS, type Language } from '../../i18n/languages';
13
+ import type { SupportTheme } from '../../theme';
14
+ import type { Thread, ThreadMessage } from '../../types';
15
+ import { USER_MESSAGE_MAX, USER_MESSAGE_MIN } from '../../types';
16
+ import { dayLabel, formatDate, formatTime, isSameDay } from '../../utils';
17
+ import { ArrowUpIcon, CloseIcon, GlobeIcon } from '../icons';
18
+ import {
19
+ EmptyState,
20
+ ErrorState,
21
+ IconButton,
22
+ SkeletonBlock,
23
+ textStyles,
24
+ } from '../primitives';
25
+
26
+ /** Consecutive messages from one sender inside this window form a group. */
27
+ const GROUP_WINDOW_MS = 5 * 60 * 1000;
28
+ /** The counter only appears once the limit is actually in play. */
29
+ const COUNTER_VISIBLE_FROM = USER_MESSAGE_MAX - 100;
30
+
31
+ /**
32
+ * The chat is every conversation this user has had, in time order. The
33
+ * dashboard still works in conversations it can resolve, so a resolve shows
34
+ * here as a divider, and the next message simply starts the next one.
35
+ */
36
+ type Item =
37
+ | { kind: 'message'; key: string; at: number; message: ThreadMessage }
38
+ | { kind: 'resolved'; key: string; at: number; resolvedAt: string };
39
+
40
+ interface Row {
41
+ item: Item;
42
+ firstInGroup: boolean;
43
+ lastInGroup: boolean;
44
+ /** Date separator to draw above this row, if any. */
45
+ day: string | null;
46
+ /** "New" separator above the first message the user hasn't seen. */
47
+ startsUnread: boolean;
48
+ }
49
+
50
+ function buildItems(threads: Thread[]): Item[] {
51
+ const items: Item[] = [];
52
+ for (const thread of threads) {
53
+ for (const message of thread.messages) {
54
+ items.push({
55
+ kind: 'message',
56
+ key: message.id,
57
+ at: Date.parse(message.created_at),
58
+ message,
59
+ });
60
+ }
61
+ if (thread.status === 'resolved' && thread.resolved_at !== null) {
62
+ items.push({
63
+ kind: 'resolved',
64
+ key: `resolved-${thread.id}`,
65
+ at: Date.parse(thread.resolved_at),
66
+ resolvedAt: thread.resolved_at,
67
+ });
68
+ }
69
+ }
70
+ return items.sort((a, b) => a.at - b.at);
71
+ }
72
+
73
+ function buildRows(items: Item[], unreadCount: number, strings: Translator): Row[] {
74
+ // Unread messages are the newest support replies, so the divider goes
75
+ // above the Nth-from-last of them.
76
+ let remaining = unreadCount;
77
+ let firstUnreadKey: string | null = null;
78
+ for (let i = items.length - 1; i >= 0 && remaining > 0; i -= 1) {
79
+ const candidate = items[i];
80
+ if (candidate?.kind !== 'message' || candidate.message.sender !== 'admin') {
81
+ continue;
82
+ }
83
+ remaining -= 1;
84
+ firstUnreadKey = candidate.key;
85
+ }
86
+
87
+ // Grouped by sender alone: two replies from two different admins are two
88
+ // messages from "Support" on this side. A divider always breaks a group.
89
+ const joins = (a: Item | undefined, b: Item) =>
90
+ a !== undefined &&
91
+ a.kind === 'message' &&
92
+ b.kind === 'message' &&
93
+ a.message.sender === b.message.sender &&
94
+ Math.abs(b.at - a.at) < GROUP_WINDOW_MS &&
95
+ isSameDay(a.message.created_at, b.message.created_at) &&
96
+ b.key !== firstUnreadKey;
97
+
98
+ let lastDayAt: number | null = null;
99
+ return items.map((item, i) => {
100
+ const previous = items[i - 1];
101
+ const next = items[i + 1];
102
+ let day: string | null = null;
103
+ if (item.kind === 'message') {
104
+ const iso = item.message.created_at;
105
+ if (lastDayAt === null || !isSameDay(new Date(lastDayAt).toISOString(), iso)) {
106
+ day = dayLabel(iso, strings);
107
+ }
108
+ lastDayAt = item.at;
109
+ }
110
+ return {
111
+ item,
112
+ firstInGroup: day !== null || !joins(previous, item),
113
+ lastInGroup: next === undefined || !joins(item, next),
114
+ day,
115
+ startsUnread: item.key === firstUnreadKey,
116
+ };
117
+ });
118
+ }
119
+
120
+ function Separator({
121
+ label,
122
+ theme,
123
+ tone = 'muted',
124
+ }: {
125
+ label: string;
126
+ theme: SupportTheme;
127
+ tone?: 'muted' | 'accent';
128
+ }) {
129
+ const color =
130
+ tone === 'accent' ? theme.colors.accentText : theme.colors.textTertiary;
131
+ const line = tone === 'accent' ? theme.colors.accentMuted : theme.colors.border;
132
+ return (
133
+ <View
134
+ style={{
135
+ flexDirection: 'row',
136
+ alignItems: 'center',
137
+ gap: 10,
138
+ paddingVertical: 6,
139
+ }}
140
+ >
141
+ <View style={{ flex: 1, height: 1, backgroundColor: line }} />
142
+ <Text style={{ fontSize: 11, fontWeight: '600', letterSpacing: 0.3, color }}>
143
+ {label}
144
+ </Text>
145
+ <View style={{ flex: 1, height: 1, backgroundColor: line }} />
146
+ </View>
147
+ );
148
+ }
149
+
150
+ function MessageBubble({
151
+ message,
152
+ firstInGroup,
153
+ lastInGroup,
154
+ theme,
155
+ strings,
156
+ reviewLabel,
157
+ onOpenReview,
158
+ }: {
159
+ message: ThreadMessage;
160
+ firstInGroup: boolean;
161
+ lastInGroup: boolean;
162
+ theme: SupportTheme;
163
+ strings: Translator;
164
+ reviewLabel: string | null;
165
+ onOpenReview: (messageId: string) => void;
166
+ }) {
167
+ // A review request whose store isn't set up for this platform still
168
+ // reads as the reply it is; it just has no button.
169
+ const review = message.kind === 'review_request' && reviewLabel !== null;
170
+ const own = message.sender === 'user';
171
+ const round = theme.radii.bubble;
172
+ const tight = theme.radii.bubbleTail;
173
+
174
+ return (
175
+ <View
176
+ style={{
177
+ maxWidth: '86%',
178
+ alignSelf: own ? 'flex-end' : 'flex-start',
179
+ alignItems: own ? 'flex-end' : 'flex-start',
180
+ marginTop: firstInGroup ? 10 : 2,
181
+ gap: 4,
182
+ }}
183
+ >
184
+ {!own && firstInGroup && (
185
+ <Text style={[textStyles.meta(theme), { paddingHorizontal: 4 }]}>
186
+ {strings.t('thread.support')}
187
+ </Text>
188
+ )}
189
+ <View
190
+ style={{
191
+ paddingHorizontal: 14,
192
+ paddingVertical: 10,
193
+ backgroundColor: own ? theme.colors.accent : theme.colors.surface,
194
+ borderWidth: own ? 0 : 1,
195
+ borderColor: theme.colors.border,
196
+ borderTopLeftRadius: own || firstInGroup ? round : tight,
197
+ borderBottomLeftRadius: own || lastInGroup ? round : tight,
198
+ borderTopRightRadius: !own || firstInGroup ? round : tight,
199
+ borderBottomRightRadius: !own || lastInGroup ? round : tight,
200
+ }}
201
+ >
202
+ <Text
203
+ style={[
204
+ textStyles.body(theme),
205
+ own && { color: theme.colors.accentForeground },
206
+ ]}
207
+ >
208
+ {message.body}
209
+ </Text>
210
+ {review && (
211
+ <Pressable
212
+ onPress={() => onOpenReview(message.id)}
213
+ accessibilityRole="link"
214
+ accessibilityLabel={reviewLabel}
215
+ style={({ pressed }) => ({
216
+ marginTop: 10,
217
+ height: 40,
218
+ paddingHorizontal: 14,
219
+ flexDirection: 'row',
220
+ alignItems: 'center',
221
+ justifyContent: 'center',
222
+ gap: 6,
223
+ borderRadius: theme.radii.control,
224
+ backgroundColor: pressed
225
+ ? theme.colors.accentHover
226
+ : theme.colors.accent,
227
+ })}
228
+ >
229
+ <Text style={{ fontSize: 14, color: theme.colors.accentForeground }}>
230
+ ★
231
+ </Text>
232
+ <Text
233
+ style={{
234
+ fontSize: 14,
235
+ fontWeight: '600',
236
+ color: theme.colors.accentForeground,
237
+ }}
238
+ >
239
+ {reviewLabel}
240
+ </Text>
241
+ </Pressable>
242
+ )}
243
+ </View>
244
+ {lastInGroup && (
245
+ <Text style={[textStyles.meta(theme), { paddingHorizontal: 4 }]}>
246
+ {/* A reply whose translation is still in flight says so, rather
247
+ than showing English that changes under the reader. */}
248
+ {message.translation_pending
249
+ ? strings.t('thread.translating')
250
+ : formatTime(message.created_at, strings)}
251
+ </Text>
252
+ )}
253
+ </View>
254
+ );
255
+ }
256
+
257
+ export function ChatScreen({
258
+ theme,
259
+ strings,
260
+ language,
261
+ threads,
262
+ loading,
263
+ error,
264
+ onRetry,
265
+ onSend,
266
+ onChangeLanguage,
267
+ onClose,
268
+ reviewLabel,
269
+ onOpenReview,
270
+ }: {
271
+ theme: SupportTheme;
272
+ strings: Translator;
273
+ language: Language;
274
+ threads: Thread[] | null;
275
+ loading: boolean;
276
+ error: string | null;
277
+ onRetry: () => void;
278
+ /** Resolves null on success, or a user-facing error message. */
279
+ onSend: (message: string) => Promise<string | null>;
280
+ onChangeLanguage: () => void;
281
+ onClose: () => void;
282
+ /** The review button's label, or null when this platform's store isn't set up. */
283
+ reviewLabel: string | null;
284
+ onOpenReview: (messageId: string) => void;
285
+ }) {
286
+ const [draft, setDraft] = useState('');
287
+ const [sending, setSending] = useState(false);
288
+ const [failedMessage, setFailedMessage] = useState<string | null>(null);
289
+ const scrollRef = useRef<ScrollView>(null);
290
+
291
+ // Captured once threads first arrive: opening the chat marks everything
292
+ // read within a tick, but the "New" divider should stay put while the
293
+ // user reads.
294
+ const [unreadOnOpen, setUnreadOnOpen] = useState<number | null>(null);
295
+ useEffect(() => {
296
+ if (unreadOnOpen === null && threads !== null) {
297
+ setUnreadOnOpen(threads.reduce((sum, t) => sum + t.unread_count, 0));
298
+ }
299
+ }, [threads, unreadOnOpen]);
300
+
301
+ const items = useMemo(() => buildItems(threads ?? []), [threads]);
302
+ const rows = useMemo(
303
+ () => buildRows(items, unreadOnOpen ?? 0, strings),
304
+ [items, unreadOnOpen, strings],
305
+ );
306
+
307
+ const trimmed = draft.trim();
308
+ const valid =
309
+ trimmed.length >= USER_MESSAGE_MIN && trimmed.length <= USER_MESSAGE_MAX;
310
+
311
+ useEffect(() => {
312
+ scrollRef.current?.scrollToEnd({ animated: false });
313
+ }, [items.length]);
314
+
315
+ async function handleSend() {
316
+ if (!valid || sending) return;
317
+ setSending(true);
318
+ setFailedMessage(null);
319
+ const errorMessage = await onSend(trimmed);
320
+ setSending(false);
321
+ if (errorMessage === null) setDraft('');
322
+ else setFailedMessage(errorMessage);
323
+ }
324
+
325
+ return (
326
+ <KeyboardAvoidingView
327
+ style={{ flex: 1 }}
328
+ behavior={Platform.OS === 'ios' ? 'padding' : undefined}
329
+ >
330
+ {/* Header */}
331
+ <View
332
+ style={{
333
+ flexDirection: 'row',
334
+ alignItems: 'center',
335
+ gap: 8,
336
+ paddingHorizontal: 16,
337
+ paddingTop: 4,
338
+ paddingBottom: 12,
339
+ borderBottomWidth: 1,
340
+ borderBottomColor: theme.colors.border,
341
+ }}
342
+ >
343
+ <Text style={[textStyles.title(theme), { flex: 1 }]}>
344
+ {strings.t('home.title')}
345
+ </Text>
346
+ {/* Language is the sheet's only preference, so it lives here rather
347
+ than behind a settings screen. */}
348
+ <Pressable
349
+ onPress={onChangeLanguage}
350
+ accessibilityRole="button"
351
+ accessibilityLabel={strings.t('lang.change')}
352
+ hitSlop={6}
353
+ style={({ pressed }) => ({
354
+ flexDirection: 'row',
355
+ alignItems: 'center',
356
+ gap: 6,
357
+ height: 32,
358
+ paddingHorizontal: 10,
359
+ borderRadius: theme.radii.pill,
360
+ borderWidth: 1,
361
+ borderColor: theme.colors.border,
362
+ backgroundColor: pressed
363
+ ? theme.colors.surfaceActive
364
+ : theme.colors.surface,
365
+ })}
366
+ >
367
+ <GlobeIcon color={theme.colors.textSecondary} size={14} />
368
+ <Text style={[textStyles.meta(theme), { color: theme.colors.textSecondary }]}>
369
+ {LANGUAGE_LABELS[language]}
370
+ </Text>
371
+ </Pressable>
372
+ <IconButton
373
+ onPress={onClose}
374
+ theme={theme}
375
+ accessibilityLabel={strings.t('home.close')}
376
+ size={32}
377
+ >
378
+ <CloseIcon color={theme.colors.textSecondary} size={12} />
379
+ </IconButton>
380
+ </View>
381
+
382
+ {/* Messages */}
383
+ {error !== null && threads === null ? (
384
+ // Only take over the screen when there's nothing cached to show —
385
+ // a failed background poll shouldn't hide an already-loaded chat.
386
+ <View style={{ flex: 1 }}>
387
+ <ErrorState
388
+ message={error}
389
+ onRetry={onRetry}
390
+ theme={theme}
391
+ strings={strings}
392
+ />
393
+ </View>
394
+ ) : loading && threads === null ? (
395
+ <View style={{ flex: 1, paddingHorizontal: 16, paddingTop: 20, gap: 10 }}>
396
+ <SkeletonBlock width="62%" height={44} theme={theme} radius={theme.radii.bubble} />
397
+ <View style={{ alignItems: 'flex-end' }}>
398
+ <SkeletonBlock width="48%" height={44} theme={theme} radius={theme.radii.bubble} />
399
+ </View>
400
+ <SkeletonBlock width="70%" height={60} theme={theme} radius={theme.radii.bubble} />
401
+ </View>
402
+ ) : items.length === 0 ? (
403
+ <View style={{ flex: 1, justifyContent: 'center' }}>
404
+ <EmptyState
405
+ message={strings.t('home.subtitle')}
406
+ hint={strings.t('home.emptyHint')}
407
+ theme={theme}
408
+ />
409
+ </View>
410
+ ) : (
411
+ <ScrollView
412
+ ref={scrollRef}
413
+ style={{ flex: 1 }}
414
+ contentContainerStyle={{ paddingHorizontal: 16, paddingVertical: 12 }}
415
+ keyboardShouldPersistTaps="handled"
416
+ onContentSizeChange={() =>
417
+ scrollRef.current?.scrollToEnd({ animated: false })
418
+ }
419
+ >
420
+ {rows.map((row) => (
421
+ <View key={row.item.key}>
422
+ {row.day !== null && <Separator label={row.day} theme={theme} />}
423
+ {row.startsUnread && (
424
+ <Separator label={strings.t('thread.new')} theme={theme} tone="accent" />
425
+ )}
426
+ {row.item.kind === 'message' ? (
427
+ <MessageBubble
428
+ message={row.item.message}
429
+ firstInGroup={row.firstInGroup}
430
+ lastInGroup={row.lastInGroup}
431
+ theme={theme}
432
+ strings={strings}
433
+ reviewLabel={reviewLabel}
434
+ onOpenReview={onOpenReview}
435
+ />
436
+ ) : (
437
+ <View style={{ marginTop: 14, marginBottom: 4 }}>
438
+ <Separator
439
+ label={strings.t('thread.resolvedAt', {
440
+ date: formatDate(row.item.resolvedAt, strings),
441
+ })}
442
+ theme={theme}
443
+ />
444
+ </View>
445
+ )}
446
+ </View>
447
+ ))}
448
+ </ScrollView>
449
+ )}
450
+
451
+ {/* Composer — always open: after a resolve, the next message starts
452
+ the next conversation. */}
453
+ <View
454
+ style={{
455
+ borderTopWidth: 1,
456
+ borderTopColor: theme.colors.border,
457
+ paddingHorizontal: 16,
458
+ paddingTop: 10,
459
+ paddingBottom: 4,
460
+ }}
461
+ >
462
+ <View style={{ flexDirection: 'row', alignItems: 'flex-end', gap: 10 }}>
463
+ <TextInput
464
+ value={draft}
465
+ onChangeText={(v) => {
466
+ setDraft(v);
467
+ setFailedMessage(null);
468
+ }}
469
+ multiline
470
+ editable={!sending}
471
+ placeholder={strings.t('chat.placeholder')}
472
+ placeholderTextColor={theme.colors.textTertiary}
473
+ // Matches the sheet even when the theme is pinned against the
474
+ // device's own setting.
475
+ keyboardAppearance={theme.name}
476
+ selectionColor={theme.colors.accent}
477
+ style={{
478
+ flex: 1,
479
+ minHeight: 44,
480
+ maxHeight: 120,
481
+ borderWidth: 1,
482
+ borderColor: theme.colors.border,
483
+ borderRadius: theme.radii.cardLg,
484
+ backgroundColor: theme.colors.surface,
485
+ color: theme.colors.textPrimary,
486
+ fontSize: 15,
487
+ lineHeight: 20,
488
+ paddingHorizontal: 14,
489
+ paddingTop: 12,
490
+ paddingBottom: 12,
491
+ textAlignVertical: 'top',
492
+ }}
493
+ />
494
+ <IconButton
495
+ onPress={handleSend}
496
+ theme={theme}
497
+ tone="accent"
498
+ size={44}
499
+ disabled={!valid || sending}
500
+ accessibilityLabel={strings.t(
501
+ sending ? 'thread.sendingA11y' : 'thread.sendA11y',
502
+ )}
503
+ >
504
+ <ArrowUpIcon color={theme.colors.accentForeground} size={19} />
505
+ </IconButton>
506
+ </View>
507
+
508
+ {(draft.length >= COUNTER_VISIBLE_FROM || failedMessage !== null) && (
509
+ <View
510
+ style={{
511
+ flexDirection: 'row',
512
+ alignItems: 'center',
513
+ gap: 10,
514
+ paddingTop: 8,
515
+ paddingHorizontal: 4,
516
+ }}
517
+ >
518
+ {failedMessage !== null && (
519
+ <Text style={{ fontSize: 12, color: theme.colors.statusDanger }}>
520
+ {failedMessage}
521
+ </Text>
522
+ )}
523
+ {draft.length >= COUNTER_VISIBLE_FROM && (
524
+ <Text
525
+ style={[
526
+ textStyles.meta(theme),
527
+ { marginLeft: 'auto' },
528
+ draft.length > USER_MESSAGE_MAX && {
529
+ color: theme.colors.statusDanger,
530
+ },
531
+ ]}
532
+ >
533
+ {draft.length}/{USER_MESSAGE_MAX}
534
+ </Text>
535
+ )}
536
+ </View>
537
+ )}
538
+ </View>
539
+ </KeyboardAvoidingView>
540
+ );
541
+ }
@@ -44,6 +44,9 @@ export const de: StringTable = {
44
44
  'thread.resolvedAt': 'Erledigt · {date}',
45
45
  'thread.readOnly': 'Dieses Gespräch ist erledigt und schreibgeschützt',
46
46
  'thread.placeholder': 'Antwort schreiben…',
47
+ 'chat.placeholder': 'Nachricht schreiben…',
48
+ 'review.appStore': 'Im App Store bewerten',
49
+ 'review.playStore': 'Bei Google Play bewerten',
47
50
  'thread.sendA11y': 'Antwort senden',
48
51
  'thread.sendingA11y': 'Wird gesendet',
49
52
  'thread.translating': 'Wird übersetzt…',
@@ -55,6 +55,9 @@ export const en = {
55
55
  'thread.resolvedAt': 'Resolved · {date}',
56
56
  'thread.readOnly': 'This conversation is resolved and read-only',
57
57
  'thread.placeholder': 'Write a reply…',
58
+ 'chat.placeholder': 'Write a message…',
59
+ 'review.appStore': 'Rate on the App Store',
60
+ 'review.playStore': 'Rate on Google Play',
58
61
  'thread.sendA11y': 'Send reply',
59
62
  'thread.sendingA11y': 'Sending',
60
63
  'thread.translating': 'Translating…',
@@ -43,6 +43,9 @@ export const es: StringTable = {
43
43
  'thread.resolvedAt': 'Resuelta · {date}',
44
44
  'thread.readOnly': 'Esta conversación está resuelta y es de solo lectura',
45
45
  'thread.placeholder': 'Escribe una respuesta…',
46
+ 'chat.placeholder': 'Escribe un mensaje…',
47
+ 'review.appStore': 'Valorar en la App Store',
48
+ 'review.playStore': 'Valorar en Google Play',
46
49
  'thread.sendA11y': 'Enviar respuesta',
47
50
  'thread.sendingA11y': 'Enviando',
48
51
  'thread.translating': 'Traduciendo…',
@@ -44,6 +44,9 @@ export const fr: StringTable = {
44
44
  'thread.resolvedAt': 'Résolue · {date}',
45
45
  'thread.readOnly': 'Cette conversation est résolue et en lecture seule',
46
46
  'thread.placeholder': 'Écrivez une réponse…',
47
+ 'chat.placeholder': 'Écrivez un message…',
48
+ 'review.appStore': "Noter sur l'App Store",
49
+ 'review.playStore': 'Noter sur Google Play',
47
50
  'thread.sendA11y': 'Envoyer la réponse',
48
51
  'thread.sendingA11y': 'Envoi en cours',
49
52
  'thread.translating': 'Traduction…',
@@ -43,6 +43,9 @@ export const hi: StringTable = {
43
43
  'thread.resolvedAt': 'हल हो चुकी · {date}',
44
44
  'thread.readOnly': 'यह बातचीत हल हो चुकी है और केवल पढ़ने के लिए है',
45
45
  'thread.placeholder': 'जवाब लिखें…',
46
+ 'chat.placeholder': 'संदेश लिखें…',
47
+ 'review.appStore': 'App Store पर रेटिंग दें',
48
+ 'review.playStore': 'Google Play पर रेटिंग दें',
46
49
  'thread.sendA11y': 'जवाब भेजें',
47
50
  'thread.sendingA11y': 'भेजा जा रहा है',
48
51
  'thread.translating': 'अनुवाद हो रहा है…',
@@ -43,6 +43,9 @@ export const it: StringTable = {
43
43
  'thread.resolvedAt': 'Risolta · {date}',
44
44
  'thread.readOnly': 'Questa conversazione è risolta e in sola lettura',
45
45
  'thread.placeholder': 'Scrivi una risposta…',
46
+ 'chat.placeholder': 'Scrivi un messaggio…',
47
+ 'review.appStore': "Valuta sull'App Store",
48
+ 'review.playStore': 'Valuta su Google Play',
46
49
  'thread.sendA11y': 'Invia risposta',
47
50
  'thread.sendingA11y': 'Invio in corso',
48
51
  'thread.translating': 'Traduzione…',
@@ -43,6 +43,9 @@ export const ja: StringTable = {
43
43
  'thread.resolvedAt': '解決済み · {date}',
44
44
  'thread.readOnly': 'この会話は解決済みで、読み取り専用です',
45
45
  'thread.placeholder': '返信を入力…',
46
+ 'chat.placeholder': 'メッセージを入力…',
47
+ 'review.appStore': 'App Storeで評価する',
48
+ 'review.playStore': 'Google Playで評価する',
46
49
  'thread.sendA11y': '返信を送信',
47
50
  'thread.sendingA11y': '送信中',
48
51
  'thread.translating': '翻訳中…',
@@ -42,6 +42,9 @@ export const ko: StringTable = {
42
42
  'thread.resolvedAt': '해결됨 · {date}',
43
43
  'thread.readOnly': '이 대화는 해결되어 읽기 전용입니다',
44
44
  'thread.placeholder': '답장을 입력하세요…',
45
+ 'chat.placeholder': '메시지를 입력하세요…',
46
+ 'review.appStore': 'App Store에서 평가하기',
47
+ 'review.playStore': 'Google Play에서 평가하기',
45
48
  'thread.sendA11y': '답장 보내기',
46
49
  'thread.sendingA11y': '보내는 중',
47
50
  'thread.translating': '번역 중…',
@@ -43,6 +43,9 @@ export const nl: StringTable = {
43
43
  'thread.resolvedAt': 'Afgehandeld · {date}',
44
44
  'thread.readOnly': 'Dit gesprek is afgehandeld en alleen-lezen',
45
45
  'thread.placeholder': 'Schrijf een antwoord…',
46
+ 'chat.placeholder': 'Schrijf een bericht…',
47
+ 'review.appStore': 'Beoordelen in de App Store',
48
+ 'review.playStore': 'Beoordelen in Google Play',
46
49
  'thread.sendA11y': 'Antwoord verzenden',
47
50
  'thread.sendingA11y': 'Bezig met verzenden',
48
51
  'thread.translating': 'Vertalen…',
@@ -43,6 +43,9 @@ export const pl: StringTable = {
43
43
  'thread.resolvedAt': 'Rozwiązane · {date}',
44
44
  'thread.readOnly': 'Ta rozmowa jest rozwiązana i tylko do odczytu',
45
45
  'thread.placeholder': 'Napisz odpowiedź…',
46
+ 'chat.placeholder': 'Napisz wiadomość…',
47
+ 'review.appStore': 'Oceń w App Store',
48
+ 'review.playStore': 'Oceń w Google Play',
46
49
  'thread.sendA11y': 'Wyślij odpowiedź',
47
50
  'thread.sendingA11y': 'Wysyłanie',
48
51
  'thread.translating': 'Tłumaczenie…',
@@ -43,6 +43,9 @@ export const pt: StringTable = {
43
43
  'thread.resolvedAt': 'Resolvida · {date}',
44
44
  'thread.readOnly': 'Esta conversa está resolvida e é somente leitura',
45
45
  'thread.placeholder': 'Escreva uma resposta…',
46
+ 'chat.placeholder': 'Escreva uma mensagem…',
47
+ 'review.appStore': 'Avaliar na App Store',
48
+ 'review.playStore': 'Avaliar no Google Play',
46
49
  'thread.sendA11y': 'Enviar resposta',
47
50
  'thread.sendingA11y': 'Enviando',
48
51
  'thread.translating': 'Traduzindo…',
@@ -43,6 +43,9 @@ export const ru: StringTable = {
43
43
  'thread.resolvedAt': 'Решено · {date}',
44
44
  'thread.readOnly': 'Этот разговор решён и доступен только для чтения',
45
45
  'thread.placeholder': 'Напишите ответ…',
46
+ 'chat.placeholder': 'Напишите сообщение…',
47
+ 'review.appStore': 'Оценить в App Store',
48
+ 'review.playStore': 'Оценить в Google Play',
46
49
  'thread.sendA11y': 'Отправить ответ',
47
50
  'thread.sendingA11y': 'Отправка',
48
51
  'thread.translating': 'Перевод…',
@@ -43,6 +43,9 @@ export const tr: StringTable = {
43
43
  'thread.resolvedAt': 'Çözüldü · {date}',
44
44
  'thread.readOnly': 'Bu görüşme çözüldü ve salt okunur',
45
45
  'thread.placeholder': 'Bir yanıt yazın…',
46
+ 'chat.placeholder': 'Bir mesaj yazın…',
47
+ 'review.appStore': "App Store'da değerlendir",
48
+ 'review.playStore': "Google Play'de değerlendir",
46
49
  'thread.sendA11y': 'Yanıtı gönder',
47
50
  'thread.sendingA11y': 'Gönderiliyor',
48
51
  'thread.translating': 'Çevriliyor…',