@appsupportkit/support-sdk 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (242) hide show
  1. package/lib/commonjs/api.js +61 -12
  2. package/lib/commonjs/api.js.map +1 -1
  3. package/lib/commonjs/components/BottomSheet.js +1 -1
  4. package/lib/commonjs/components/BottomSheet.js.map +1 -1
  5. package/lib/commonjs/components/LauncherButton.js +4 -4
  6. package/lib/commonjs/components/LauncherButton.js.map +1 -1
  7. package/lib/commonjs/components/SupportButton.js +192 -66
  8. package/lib/commonjs/components/SupportButton.js.map +1 -1
  9. package/lib/commonjs/components/icons.js +117 -110
  10. package/lib/commonjs/components/icons.js.map +1 -1
  11. package/lib/commonjs/components/primitives.js +3 -154
  12. package/lib/commonjs/components/primitives.js.map +1 -1
  13. package/lib/commonjs/components/screens/ChatScreen.js +487 -0
  14. package/lib/commonjs/components/screens/ChatScreen.js.map +1 -0
  15. package/lib/commonjs/components/screens/LanguageScreen.js +150 -0
  16. package/lib/commonjs/components/screens/LanguageScreen.js.map +1 -0
  17. package/lib/commonjs/i18n/index.js +101 -0
  18. package/lib/commonjs/i18n/index.js.map +1 -0
  19. package/lib/commonjs/i18n/languages.js +95 -0
  20. package/lib/commonjs/i18n/languages.js.map +1 -0
  21. package/lib/commonjs/i18n/strings/de.js +75 -0
  22. package/lib/commonjs/i18n/strings/de.js.map +1 -0
  23. package/lib/commonjs/i18n/strings/en.js +91 -0
  24. package/lib/commonjs/i18n/strings/en.js.map +1 -0
  25. package/lib/commonjs/i18n/strings/es.js +75 -0
  26. package/lib/commonjs/i18n/strings/es.js.map +1 -0
  27. package/lib/commonjs/i18n/strings/fr.js +75 -0
  28. package/lib/commonjs/i18n/strings/fr.js.map +1 -0
  29. package/lib/commonjs/i18n/strings/hi.js +75 -0
  30. package/lib/commonjs/i18n/strings/hi.js.map +1 -0
  31. package/lib/commonjs/i18n/strings/it.js +75 -0
  32. package/lib/commonjs/i18n/strings/it.js.map +1 -0
  33. package/lib/commonjs/i18n/strings/ja.js +75 -0
  34. package/lib/commonjs/i18n/strings/ja.js.map +1 -0
  35. package/lib/commonjs/i18n/strings/ko.js +75 -0
  36. package/lib/commonjs/i18n/strings/ko.js.map +1 -0
  37. package/lib/commonjs/i18n/strings/nl.js +75 -0
  38. package/lib/commonjs/i18n/strings/nl.js.map +1 -0
  39. package/lib/commonjs/i18n/strings/pl.js +75 -0
  40. package/lib/commonjs/i18n/strings/pl.js.map +1 -0
  41. package/lib/commonjs/i18n/strings/pt.js +75 -0
  42. package/lib/commonjs/i18n/strings/pt.js.map +1 -0
  43. package/lib/commonjs/i18n/strings/ru.js +75 -0
  44. package/lib/commonjs/i18n/strings/ru.js.map +1 -0
  45. package/lib/commonjs/i18n/strings/tr.js +75 -0
  46. package/lib/commonjs/i18n/strings/tr.js.map +1 -0
  47. package/lib/commonjs/i18n/strings/zh.js +75 -0
  48. package/lib/commonjs/i18n/strings/zh.js.map +1 -0
  49. package/lib/commonjs/index.js +38 -0
  50. package/lib/commonjs/index.js.map +1 -1
  51. package/lib/commonjs/push.js +78 -3
  52. package/lib/commonjs/push.js.map +1 -1
  53. package/lib/commonjs/review.js +54 -0
  54. package/lib/commonjs/review.js.map +1 -0
  55. package/lib/commonjs/theme.js +142 -23
  56. package/lib/commonjs/theme.js.map +1 -1
  57. package/lib/commonjs/types.js +32 -3
  58. package/lib/commonjs/types.js.map +1 -1
  59. package/lib/commonjs/useSupportThreads.js +41 -24
  60. package/lib/commonjs/useSupportThreads.js.map +1 -1
  61. package/lib/commonjs/utils.js +25 -30
  62. package/lib/commonjs/utils.js.map +1 -1
  63. package/lib/commonjs/version.js +13 -0
  64. package/lib/commonjs/version.js.map +1 -0
  65. package/lib/module/api.js +57 -10
  66. package/lib/module/api.js.map +1 -1
  67. package/lib/module/components/BottomSheet.js +1 -1
  68. package/lib/module/components/BottomSheet.js.map +1 -1
  69. package/lib/module/components/LauncherButton.js +5 -5
  70. package/lib/module/components/LauncherButton.js.map +1 -1
  71. package/lib/module/components/SupportButton.js +199 -73
  72. package/lib/module/components/SupportButton.js.map +1 -1
  73. package/lib/module/components/icons.js +115 -107
  74. package/lib/module/components/icons.js.map +1 -1
  75. package/lib/module/components/primitives.js +5 -147
  76. package/lib/module/components/primitives.js.map +1 -1
  77. package/lib/module/components/screens/ChatScreen.js +485 -0
  78. package/lib/module/components/screens/ChatScreen.js.map +1 -0
  79. package/lib/module/components/screens/LanguageScreen.js +147 -0
  80. package/lib/module/components/screens/LanguageScreen.js.map +1 -0
  81. package/lib/module/i18n/index.js +56 -0
  82. package/lib/module/i18n/index.js.map +1 -0
  83. package/lib/module/i18n/languages.js +88 -0
  84. package/lib/module/i18n/languages.js.map +1 -0
  85. package/lib/module/i18n/strings/de.js +71 -0
  86. package/lib/module/i18n/strings/de.js.map +1 -0
  87. package/lib/module/i18n/strings/en.js +87 -0
  88. package/lib/module/i18n/strings/en.js.map +1 -0
  89. package/lib/module/i18n/strings/es.js +71 -0
  90. package/lib/module/i18n/strings/es.js.map +1 -0
  91. package/lib/module/i18n/strings/fr.js +71 -0
  92. package/lib/module/i18n/strings/fr.js.map +1 -0
  93. package/lib/module/i18n/strings/hi.js +71 -0
  94. package/lib/module/i18n/strings/hi.js.map +1 -0
  95. package/lib/module/i18n/strings/it.js +71 -0
  96. package/lib/module/i18n/strings/it.js.map +1 -0
  97. package/lib/module/i18n/strings/ja.js +71 -0
  98. package/lib/module/i18n/strings/ja.js.map +1 -0
  99. package/lib/module/i18n/strings/ko.js +71 -0
  100. package/lib/module/i18n/strings/ko.js.map +1 -0
  101. package/lib/module/i18n/strings/nl.js +71 -0
  102. package/lib/module/i18n/strings/nl.js.map +1 -0
  103. package/lib/module/i18n/strings/pl.js +71 -0
  104. package/lib/module/i18n/strings/pl.js.map +1 -0
  105. package/lib/module/i18n/strings/pt.js +71 -0
  106. package/lib/module/i18n/strings/pt.js.map +1 -0
  107. package/lib/module/i18n/strings/ru.js +71 -0
  108. package/lib/module/i18n/strings/ru.js.map +1 -0
  109. package/lib/module/i18n/strings/tr.js +71 -0
  110. package/lib/module/i18n/strings/tr.js.map +1 -0
  111. package/lib/module/i18n/strings/zh.js +71 -0
  112. package/lib/module/i18n/strings/zh.js.map +1 -0
  113. package/lib/module/index.js +2 -0
  114. package/lib/module/index.js.map +1 -1
  115. package/lib/module/push.js +77 -3
  116. package/lib/module/push.js.map +1 -1
  117. package/lib/module/review.js +48 -0
  118. package/lib/module/review.js.map +1 -0
  119. package/lib/module/theme.js +141 -23
  120. package/lib/module/theme.js.map +1 -1
  121. package/lib/module/types.js +32 -3
  122. package/lib/module/types.js.map +1 -1
  123. package/lib/module/useSupportThreads.js +41 -24
  124. package/lib/module/useSupportThreads.js.map +1 -1
  125. package/lib/module/utils.js +24 -29
  126. package/lib/module/utils.js.map +1 -1
  127. package/lib/module/version.js +9 -0
  128. package/lib/module/version.js.map +1 -0
  129. package/lib/typescript/api.d.ts +40 -10
  130. package/lib/typescript/api.d.ts.map +1 -1
  131. package/lib/typescript/components/SupportButton.d.ts +2 -2
  132. package/lib/typescript/components/SupportButton.d.ts.map +1 -1
  133. package/lib/typescript/components/icons.d.ts +10 -14
  134. package/lib/typescript/components/icons.d.ts.map +1 -1
  135. package/lib/typescript/components/primitives.d.ts +3 -30
  136. package/lib/typescript/components/primitives.d.ts.map +1 -1
  137. package/lib/typescript/components/screens/ChatScreen.d.ts +21 -0
  138. package/lib/typescript/components/screens/ChatScreen.d.ts.map +1 -0
  139. package/lib/typescript/components/screens/LanguageScreen.d.ts +30 -0
  140. package/lib/typescript/components/screens/LanguageScreen.d.ts.map +1 -0
  141. package/lib/typescript/i18n/index.d.ts +15 -0
  142. package/lib/typescript/i18n/index.d.ts.map +1 -0
  143. package/lib/typescript/i18n/languages.d.ts +36 -0
  144. package/lib/typescript/i18n/languages.d.ts.map +1 -0
  145. package/lib/typescript/i18n/strings/de.d.ts +3 -0
  146. package/lib/typescript/i18n/strings/de.d.ts.map +1 -0
  147. package/lib/typescript/i18n/strings/en.d.ts +86 -0
  148. package/lib/typescript/i18n/strings/en.d.ts.map +1 -0
  149. package/lib/typescript/i18n/strings/es.d.ts +3 -0
  150. package/lib/typescript/i18n/strings/es.d.ts.map +1 -0
  151. package/lib/typescript/i18n/strings/fr.d.ts +3 -0
  152. package/lib/typescript/i18n/strings/fr.d.ts.map +1 -0
  153. package/lib/typescript/i18n/strings/hi.d.ts +3 -0
  154. package/lib/typescript/i18n/strings/hi.d.ts.map +1 -0
  155. package/lib/typescript/i18n/strings/it.d.ts +3 -0
  156. package/lib/typescript/i18n/strings/it.d.ts.map +1 -0
  157. package/lib/typescript/i18n/strings/ja.d.ts +3 -0
  158. package/lib/typescript/i18n/strings/ja.d.ts.map +1 -0
  159. package/lib/typescript/i18n/strings/ko.d.ts +3 -0
  160. package/lib/typescript/i18n/strings/ko.d.ts.map +1 -0
  161. package/lib/typescript/i18n/strings/nl.d.ts +3 -0
  162. package/lib/typescript/i18n/strings/nl.d.ts.map +1 -0
  163. package/lib/typescript/i18n/strings/pl.d.ts +3 -0
  164. package/lib/typescript/i18n/strings/pl.d.ts.map +1 -0
  165. package/lib/typescript/i18n/strings/pt.d.ts +3 -0
  166. package/lib/typescript/i18n/strings/pt.d.ts.map +1 -0
  167. package/lib/typescript/i18n/strings/ru.d.ts +3 -0
  168. package/lib/typescript/i18n/strings/ru.d.ts.map +1 -0
  169. package/lib/typescript/i18n/strings/tr.d.ts +3 -0
  170. package/lib/typescript/i18n/strings/tr.d.ts.map +1 -0
  171. package/lib/typescript/i18n/strings/zh.d.ts +3 -0
  172. package/lib/typescript/i18n/strings/zh.d.ts.map +1 -0
  173. package/lib/typescript/index.d.ts +4 -2
  174. package/lib/typescript/index.d.ts.map +1 -1
  175. package/lib/typescript/push.d.ts +14 -2
  176. package/lib/typescript/push.d.ts.map +1 -1
  177. package/lib/typescript/review.d.ts +15 -0
  178. package/lib/typescript/review.d.ts.map +1 -0
  179. package/lib/typescript/theme.d.ts +23 -5
  180. package/lib/typescript/theme.d.ts.map +1 -1
  181. package/lib/typescript/types.d.ts +86 -13
  182. package/lib/typescript/types.d.ts.map +1 -1
  183. package/lib/typescript/useSupportThreads.d.ts +20 -4
  184. package/lib/typescript/useSupportThreads.d.ts.map +1 -1
  185. package/lib/typescript/utils.d.ts +8 -6
  186. package/lib/typescript/utils.d.ts.map +1 -1
  187. package/lib/typescript/version.d.ts +7 -0
  188. package/lib/typescript/version.d.ts.map +1 -0
  189. package/package.json +2 -2
  190. package/src/api.ts +75 -23
  191. package/src/components/BottomSheet.tsx +1 -1
  192. package/src/components/LauncherButton.tsx +4 -4
  193. package/src/components/SupportButton.tsx +244 -86
  194. package/src/components/icons.tsx +96 -101
  195. package/src/components/primitives.tsx +9 -161
  196. package/src/components/screens/ChatScreen.tsx +541 -0
  197. package/src/components/screens/LanguageScreen.tsx +172 -0
  198. package/src/i18n/index.ts +81 -0
  199. package/src/i18n/languages.ts +106 -0
  200. package/src/i18n/strings/de.ts +81 -0
  201. package/src/i18n/strings/en.ts +97 -0
  202. package/src/i18n/strings/es.ts +80 -0
  203. package/src/i18n/strings/fr.ts +81 -0
  204. package/src/i18n/strings/hi.ts +80 -0
  205. package/src/i18n/strings/it.ts +80 -0
  206. package/src/i18n/strings/ja.ts +80 -0
  207. package/src/i18n/strings/ko.ts +79 -0
  208. package/src/i18n/strings/nl.ts +80 -0
  209. package/src/i18n/strings/pl.ts +80 -0
  210. package/src/i18n/strings/pt.ts +80 -0
  211. package/src/i18n/strings/ru.ts +80 -0
  212. package/src/i18n/strings/tr.ts +80 -0
  213. package/src/i18n/strings/zh.ts +78 -0
  214. package/src/index.tsx +13 -0
  215. package/src/push.ts +100 -2
  216. package/src/review.ts +54 -0
  217. package/src/theme.ts +182 -28
  218. package/src/types.ts +85 -13
  219. package/src/useSupportThreads.ts +61 -28
  220. package/src/utils.ts +27 -29
  221. package/src/version.ts +6 -0
  222. package/lib/commonjs/components/screens/ComposeScreen.js +0 -228
  223. package/lib/commonjs/components/screens/ComposeScreen.js.map +0 -1
  224. package/lib/commonjs/components/screens/HomeScreen.js +0 -286
  225. package/lib/commonjs/components/screens/HomeScreen.js.map +0 -1
  226. package/lib/commonjs/components/screens/ThreadScreen.js +0 -327
  227. package/lib/commonjs/components/screens/ThreadScreen.js.map +0 -1
  228. package/lib/module/components/screens/ComposeScreen.js +0 -224
  229. package/lib/module/components/screens/ComposeScreen.js.map +0 -1
  230. package/lib/module/components/screens/HomeScreen.js +0 -282
  231. package/lib/module/components/screens/HomeScreen.js.map +0 -1
  232. package/lib/module/components/screens/ThreadScreen.js +0 -325
  233. package/lib/module/components/screens/ThreadScreen.js.map +0 -1
  234. package/lib/typescript/components/screens/ComposeScreen.d.ts +0 -10
  235. package/lib/typescript/components/screens/ComposeScreen.d.ts.map +0 -1
  236. package/lib/typescript/components/screens/HomeScreen.d.ts +0 -14
  237. package/lib/typescript/components/screens/HomeScreen.d.ts.map +0 -1
  238. package/lib/typescript/components/screens/ThreadScreen.d.ts +0 -10
  239. package/lib/typescript/components/screens/ThreadScreen.d.ts.map +0 -1
  240. package/src/components/screens/ComposeScreen.tsx +0 -233
  241. package/src/components/screens/HomeScreen.tsx +0 -287
  242. package/src/components/screens/ThreadScreen.tsx +0 -397
@@ -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
+ }
@@ -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
+ }