@repzo/widget-react-native 1.1.0 → 1.2.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 (105) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +50 -2
  3. package/lib/commonjs/api/client.js +25 -0
  4. package/lib/commonjs/api/client.js.map +1 -1
  5. package/lib/commonjs/i18n.js +14 -0
  6. package/lib/commonjs/i18n.js.map +1 -1
  7. package/lib/commonjs/index.js.map +1 -1
  8. package/lib/commonjs/locales.js +13 -0
  9. package/lib/commonjs/locales.js.map +1 -1
  10. package/lib/commonjs/push/push-notifications.js +9 -7
  11. package/lib/commonjs/push/push-notifications.js.map +1 -1
  12. package/lib/commonjs/state/widget-store.js +451 -7
  13. package/lib/commonjs/state/widget-store.js.map +1 -1
  14. package/lib/commonjs/types.js +15 -0
  15. package/lib/commonjs/types.js.map +1 -1
  16. package/lib/commonjs/ui/WidgetProvider.js +64 -2
  17. package/lib/commonjs/ui/WidgetProvider.js.map +1 -1
  18. package/lib/commonjs/ui/components/Post.js +1241 -0
  19. package/lib/commonjs/ui/components/Post.js.map +1 -0
  20. package/lib/commonjs/ui/utils/html.js +1 -0
  21. package/lib/commonjs/ui/utils/html.js.map +1 -1
  22. package/lib/commonjs/ui/utils/post-html.js +276 -0
  23. package/lib/commonjs/ui/utils/post-html.js.map +1 -0
  24. package/lib/commonjs/ui/utils/safe-url.js +16 -0
  25. package/lib/commonjs/ui/utils/safe-url.js.map +1 -1
  26. package/lib/module/api/client.js +25 -0
  27. package/lib/module/api/client.js.map +1 -1
  28. package/lib/module/i18n.js +14 -0
  29. package/lib/module/i18n.js.map +1 -1
  30. package/lib/module/index.js.map +1 -1
  31. package/lib/module/locales.js +13 -0
  32. package/lib/module/locales.js.map +1 -1
  33. package/lib/module/push/push-notifications.js +9 -7
  34. package/lib/module/push/push-notifications.js.map +1 -1
  35. package/lib/module/state/widget-store.js +450 -6
  36. package/lib/module/state/widget-store.js.map +1 -1
  37. package/lib/module/types.js +15 -0
  38. package/lib/module/types.js.map +1 -1
  39. package/lib/module/ui/WidgetProvider.js +65 -3
  40. package/lib/module/ui/WidgetProvider.js.map +1 -1
  41. package/lib/module/ui/components/Post.js +1236 -0
  42. package/lib/module/ui/components/Post.js.map +1 -0
  43. package/lib/module/ui/utils/html.js +1 -1
  44. package/lib/module/ui/utils/html.js.map +1 -1
  45. package/lib/module/ui/utils/post-html.js +271 -0
  46. package/lib/module/ui/utils/post-html.js.map +1 -0
  47. package/lib/module/ui/utils/safe-url.js +15 -0
  48. package/lib/module/ui/utils/safe-url.js.map +1 -1
  49. package/lib/typescript/commonjs/api/client.d.ts +19 -1
  50. package/lib/typescript/commonjs/api/client.d.ts.map +1 -1
  51. package/lib/typescript/commonjs/i18n.d.ts +13 -0
  52. package/lib/typescript/commonjs/i18n.d.ts.map +1 -1
  53. package/lib/typescript/commonjs/index.d.ts +1 -1
  54. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  55. package/lib/typescript/commonjs/locales.d.ts.map +1 -1
  56. package/lib/typescript/commonjs/push/push-notifications.d.ts +6 -2
  57. package/lib/typescript/commonjs/push/push-notifications.d.ts.map +1 -1
  58. package/lib/typescript/commonjs/state/widget-store.d.ts +84 -1
  59. package/lib/typescript/commonjs/state/widget-store.d.ts.map +1 -1
  60. package/lib/typescript/commonjs/types.d.ts +77 -0
  61. package/lib/typescript/commonjs/types.d.ts.map +1 -1
  62. package/lib/typescript/commonjs/ui/WidgetProvider.d.ts.map +1 -1
  63. package/lib/typescript/commonjs/ui/components/Post.d.ts +69 -0
  64. package/lib/typescript/commonjs/ui/components/Post.d.ts.map +1 -0
  65. package/lib/typescript/commonjs/ui/utils/html.d.ts +1 -0
  66. package/lib/typescript/commonjs/ui/utils/html.d.ts.map +1 -1
  67. package/lib/typescript/commonjs/ui/utils/post-html.d.ts +54 -0
  68. package/lib/typescript/commonjs/ui/utils/post-html.d.ts.map +1 -0
  69. package/lib/typescript/commonjs/ui/utils/safe-url.d.ts +6 -0
  70. package/lib/typescript/commonjs/ui/utils/safe-url.d.ts.map +1 -1
  71. package/lib/typescript/module/api/client.d.ts +19 -1
  72. package/lib/typescript/module/api/client.d.ts.map +1 -1
  73. package/lib/typescript/module/i18n.d.ts +13 -0
  74. package/lib/typescript/module/i18n.d.ts.map +1 -1
  75. package/lib/typescript/module/index.d.ts +1 -1
  76. package/lib/typescript/module/index.d.ts.map +1 -1
  77. package/lib/typescript/module/locales.d.ts.map +1 -1
  78. package/lib/typescript/module/push/push-notifications.d.ts +6 -2
  79. package/lib/typescript/module/push/push-notifications.d.ts.map +1 -1
  80. package/lib/typescript/module/state/widget-store.d.ts +84 -1
  81. package/lib/typescript/module/state/widget-store.d.ts.map +1 -1
  82. package/lib/typescript/module/types.d.ts +77 -0
  83. package/lib/typescript/module/types.d.ts.map +1 -1
  84. package/lib/typescript/module/ui/WidgetProvider.d.ts.map +1 -1
  85. package/lib/typescript/module/ui/components/Post.d.ts +69 -0
  86. package/lib/typescript/module/ui/components/Post.d.ts.map +1 -0
  87. package/lib/typescript/module/ui/utils/html.d.ts +1 -0
  88. package/lib/typescript/module/ui/utils/html.d.ts.map +1 -1
  89. package/lib/typescript/module/ui/utils/post-html.d.ts +54 -0
  90. package/lib/typescript/module/ui/utils/post-html.d.ts.map +1 -0
  91. package/lib/typescript/module/ui/utils/safe-url.d.ts +6 -0
  92. package/lib/typescript/module/ui/utils/safe-url.d.ts.map +1 -1
  93. package/package.json +1 -1
  94. package/src/api/client.ts +27 -0
  95. package/src/i18n.ts +14 -0
  96. package/src/index.tsx +4 -0
  97. package/src/locales.ts +13 -0
  98. package/src/push/push-notifications.ts +10 -7
  99. package/src/state/widget-store.ts +429 -6
  100. package/src/types.ts +77 -0
  101. package/src/ui/WidgetProvider.tsx +78 -2
  102. package/src/ui/components/Post.tsx +943 -0
  103. package/src/ui/utils/html.ts +1 -1
  104. package/src/ui/utils/post-html.ts +256 -0
  105. package/src/ui/utils/safe-url.ts +15 -0
@@ -0,0 +1,943 @@
1
+ /**
2
+ * A Send post over the host app — the mobile side of Send → Post.
3
+ *
4
+ * Sent as a badge or a snippet, it waits at the bottom of the screen (a pill
5
+ * or a card); tapping opens it in full. Sent as "full" it opens right away. A
6
+ * small post opens as a sheet over the app, a large one as a full screen.
7
+ * The full post has the body (paragraphs, lists, code, a button, social
8
+ * links), what the visitor already replied, and a reply box or reactions.
9
+ * Replies stay under the post and go to the inbox, where the team answers.
10
+ * A reaction answers it: the emoji pops, it says thanks, and goes.
11
+ *
12
+ * "Not now" — the ⌄ button, swiping the top down, tapping outside a sheet, or
13
+ * Android's back — minimizes it back to its pill (or snippet card), where it
14
+ * stays, on later launches too. × closes it for good (the next post loads).
15
+ */
16
+
17
+ import { useEffect, useMemo, useRef, useState } from 'react';
18
+ import {
19
+ AccessibilityInfo,
20
+ Animated,
21
+ Modal,
22
+ PanResponder,
23
+ Platform,
24
+ Pressable,
25
+ ScrollView,
26
+ StyleSheet,
27
+ Text,
28
+ TextInput,
29
+ View,
30
+ useWindowDimensions,
31
+ } from 'react-native';
32
+
33
+ import { formatLabel } from '../../i18n';
34
+ import { useKeyboardOverlap } from '../hooks/use-keyboard-overlap';
35
+ import { useReduceMotion } from '../hooks/use-reduce-motion';
36
+ import type { PostInteractionKind, PostReply, WidgetPost } from '../../types';
37
+ import { contrastingTextColor, fontSize, palette, radius, shadows, spacing, withAlpha } from '../styles/theme';
38
+ import { openExternalUrl } from '../utils/safe-url';
39
+ import { parsePostHtml, type PostAlign, type PostBlock, type PostSocialNetwork, type PostTextRun } from '../utils/post-html';
40
+ import { START_ALIGN } from '../utils/rtl';
41
+ import { Avatar } from './Avatar';
42
+ import { ArrowUpIcon, ChevronIcon, CloseIcon } from './icons';
43
+
44
+ export interface PostLabels {
45
+ from: string;
46
+ newMessage: string;
47
+ openHint: string;
48
+ close: string;
49
+ /** The ⌄ button: back to its pill for later. */
50
+ minimize: string;
51
+ replyPlaceholder: string;
52
+ send: string;
53
+ sent: string;
54
+ replyFailed: string;
55
+ reactions: string;
56
+ /** Shown after a reaction, just before the post goes. */
57
+ thanks: string;
58
+ /** The conversation under the post (screen readers). */
59
+ conversation: string;
60
+ /** Opens the post's conversation in the messenger. */
61
+ continueChat: string;
62
+ }
63
+
64
+ type PostAction = Exclude<PostInteractionKind, 'view' | 'open' | 'minimize'>;
65
+
66
+ interface PostOverlayProps {
67
+ post: WidgetPost;
68
+ expanded: boolean;
69
+ /** The Messenger's color: buttons, the visitor's replies, the send button. */
70
+ color: string;
71
+ insets: { top: number; bottom: number };
72
+ labels: PostLabels;
73
+ onShown: (campaignId: string) => void;
74
+ onExpand: (campaignId: string) => void;
75
+ /** "Not now": ⌄, a swipe down, tapping outside a sheet, Android back. */
76
+ onMinimize: (campaignId: string) => void;
77
+ /**
78
+ * Back to its pill or card for now, without telling the server: after a
79
+ * link (the app may open a screen of its own), or when iOS couldn't put the
80
+ * post on screen (another modal was up).
81
+ */
82
+ onCollapse: (campaignId: string) => void;
83
+ /** "Continue in Messenger" under the post's conversation. */
84
+ onOpenConversation: (campaignId: string, conversationId: string) => void;
85
+ /** Resolves with the saved reply for "reply"; rejects when it wasn't saved. */
86
+ onInteract: (campaignId: string, kind: PostAction, value?: string) => Promise<PostReply | undefined>;
87
+ }
88
+
89
+ export function PostOverlay(props: PostOverlayProps) {
90
+ const { post, expanded, onShown } = props;
91
+ // Reported once per post per session (the store dedupes).
92
+ useEffect(() => {
93
+ onShown(post.id);
94
+ }, [onShown, post.id]);
95
+ if (expanded) return <PostFull {...props} />;
96
+ // A snippet waits as its card; a badge — or a full post put away for later — as the pill.
97
+ return post.sentAs === 'snippet' ? <PostCard {...props} /> : <PostBadge {...props} />;
98
+ }
99
+
100
+ function senderLine(post: WidgetPost, labels: PostLabels) {
101
+ if (post.sender && post.workspaceName) {
102
+ return formatLabel(labels.from, { name: post.sender.name, workspace: post.workspaceName });
103
+ }
104
+ return post.sender?.name || post.workspaceName || labels.newMessage;
105
+ }
106
+
107
+ /**
108
+ * Slides a card in from below and tells screen-reader users it arrived — the
109
+ * way the in-app notification card does. Holds still under Reduce Motion.
110
+ */
111
+ function useArrival(accessibilityLabel: string) {
112
+ const reduceMotion = useReduceMotion();
113
+ // Read as "hold still" until the setting is known (see useReduceMotion).
114
+ const animate = reduceMotion === false;
115
+ const entry = useRef(new Animated.Value(animate ? 0 : 1)).current;
116
+
117
+ // Runs per mount: a new post, or the card coming back after the messenger.
118
+ useEffect(() => {
119
+ if (animate) {
120
+ Animated.spring(entry, { toValue: 1, useNativeDriver: true, speed: 16, bounciness: 4 }).start();
121
+ }
122
+ AccessibilityInfo.announceForAccessibility(accessibilityLabel);
123
+ // eslint-disable-next-line react-hooks/exhaustive-deps
124
+ }, []);
125
+
126
+ return {
127
+ opacity: entry,
128
+ transform: [{ translateY: entry.interpolate({ inputRange: [0, 1], outputRange: [spacing.lg, 0] }) }],
129
+ };
130
+ }
131
+
132
+ function CloseButton({ label, onPress, color = palette.textMuted }: { label: string; onPress: () => void; color?: string }) {
133
+ return (
134
+ <Pressable
135
+ onPress={onPress}
136
+ accessibilityRole="button"
137
+ accessibilityLabel={label}
138
+ hitSlop={10}
139
+ style={({ pressed }) => [styles.close, pressed && styles.pressed]}>
140
+ <CloseIcon size={14} color={color} />
141
+ </Pressable>
142
+ );
143
+ }
144
+
145
+ function MinimizeButton({ label, onPress }: { label: string; onPress: () => void }) {
146
+ return (
147
+ <Pressable
148
+ onPress={onPress}
149
+ accessibilityRole="button"
150
+ accessibilityLabel={label}
151
+ hitSlop={10}
152
+ style={({ pressed }) => [styles.close, pressed && styles.pressed]}>
153
+ <ChevronIcon size={16} direction="down" color={palette.textMuted} />
154
+ </Pressable>
155
+ );
156
+ }
157
+
158
+ /** How far the top must be dragged down to minimize — or a quick flick. */
159
+ const MINIMIZE_DISTANCE = 120;
160
+ const MINIMIZE_VELOCITY = 0.8;
161
+
162
+ /**
163
+ * Dragging the post's top edge down minimizes it, the way a sheet slides away.
164
+ * Pure JS (PanResponder), like the in-app notification's swipe.
165
+ */
166
+ function useSwipeToMinimize(onMinimize: () => void) {
167
+ const { height } = useWindowDimensions();
168
+ const reduceMotion = useReduceMotion();
169
+ const drag = useRef(new Animated.Value(0)).current;
170
+ // Read through a ref: the provider re-renders on every store change, and a
171
+ // PanResponder rebuilt mid-drag would restart the gesture.
172
+ const minimizeRef = useRef(onMinimize);
173
+ minimizeRef.current = onMinimize;
174
+ const handlers = useMemo(() => {
175
+ const settle = () => Animated.spring(drag, { toValue: 0, useNativeDriver: true, bounciness: 4 }).start();
176
+ return PanResponder.create({
177
+ onMoveShouldSetPanResponder: (_, gesture) => gesture.dy > 6 && gesture.dy > Math.abs(gesture.dx) * 1.5,
178
+ onPanResponderMove: (_, gesture) => drag.setValue(Math.max(0, gesture.dy)),
179
+ onPanResponderRelease: (_, gesture) => {
180
+ if (gesture.dy < MINIMIZE_DISTANCE && gesture.vy < MINIMIZE_VELOCITY) {
181
+ settle();
182
+ return;
183
+ }
184
+ Animated.timing(drag, { toValue: height, duration: reduceMotion === false ? 200 : 0, useNativeDriver: true }).start(() =>
185
+ minimizeRef.current()
186
+ );
187
+ },
188
+ onPanResponderTerminate: settle,
189
+ });
190
+ }, [drag, height, reduceMotion]);
191
+ return {
192
+ panHandlers: handlers.panHandlers,
193
+ // Never above where it started.
194
+ translateY: drag.interpolate({ inputRange: [0, 1], outputRange: [0, 1], extrapolateLeft: 'clamp' }),
195
+ // The dimmed backdrop lightens as the sheet goes.
196
+ backdropOpacity: drag.interpolate({ inputRange: [0, 400], outputRange: [1, 0], extrapolate: 'clamp' }),
197
+ };
198
+ }
199
+
200
+ /** Snippet: a card at the bottom with who it's from and the first words. */
201
+ function PostCard({ post, color, insets, labels, onExpand, onInteract }: PostOverlayProps) {
202
+ const accessibilityLabel = [senderLine(post, labels), post.snippet].filter(Boolean).join(': ');
203
+ const arrival = useArrival(accessibilityLabel);
204
+ return (
205
+ <View pointerEvents="box-none" style={[styles.bottomWrap, { bottom: insets.bottom + spacing.md }]}>
206
+ <Animated.View style={[styles.card, arrival]}>
207
+ <Pressable
208
+ onPress={() => onExpand(post.id)}
209
+ accessibilityRole="button"
210
+ accessibilityLabel={accessibilityLabel}
211
+ accessibilityHint={labels.openHint}
212
+ style={({ pressed }) => [styles.cardBody, pressed && styles.pressed]}>
213
+ <View style={styles.cardHead}>
214
+ <Avatar avatar={post.sender?.avatarUrl} name={post.sender?.name ?? post.workspaceName} color={color} size={28} />
215
+ <Text numberOfLines={1} style={styles.senderText}>
216
+ {senderLine(post, labels)}
217
+ </Text>
218
+ </View>
219
+ <Text numberOfLines={3} style={styles.snippet}>
220
+ {post.snippet || labels.newMessage}
221
+ </Text>
222
+ </Pressable>
223
+ <View style={styles.cardClose}>
224
+ <CloseButton label={labels.close} onPress={() => void onInteract(post.id, 'dismiss')} />
225
+ </View>
226
+ </Animated.View>
227
+ </View>
228
+ );
229
+ }
230
+
231
+ /** Badge: a small pill — the sender and a dot — that opens the post. */
232
+ function PostBadge({ post, color, insets, labels, onExpand, onInteract }: PostOverlayProps) {
233
+ const accessibilityLabel = [post.sender?.name || labels.newMessage, post.snippet].filter(Boolean).join(': ');
234
+ const arrival = useArrival(accessibilityLabel);
235
+ return (
236
+ <Animated.View pointerEvents="box-none" style={[styles.badgeWrap, { bottom: insets.bottom + spacing.md }, arrival]}>
237
+ <Pressable
238
+ onPress={() => onExpand(post.id)}
239
+ accessibilityRole="button"
240
+ accessibilityLabel={accessibilityLabel}
241
+ accessibilityHint={labels.openHint}
242
+ style={({ pressed }) => [styles.pill, pressed && styles.pressed]}>
243
+ <Avatar avatar={post.sender?.avatarUrl} name={post.sender?.name ?? post.workspaceName} color={color} size={28} />
244
+ <Text numberOfLines={1} style={styles.pillText}>
245
+ {post.sender?.name ?? labels.newMessage}
246
+ </Text>
247
+ <View style={styles.dot} />
248
+ </Pressable>
249
+ <View style={styles.pillClose}>
250
+ <CloseButton label={labels.close} onPress={() => void onInteract(post.id, 'dismiss')} />
251
+ </View>
252
+ </Animated.View>
253
+ );
254
+ }
255
+
256
+ /** The post in full: a sheet (small) or a full screen (large). */
257
+ /** How long iOS gets to present the post before it's treated as blocked (another modal was up). */
258
+ const PRESENT_TIMEOUT_MS = 2500;
259
+
260
+ function PostFull({ post, color, insets, labels, onMinimize, onCollapse, onOpenConversation, onInteract }: PostOverlayProps) {
261
+ const large = post.format === 'large';
262
+ // KeyboardAvoidingView misplaces the composer inside a modal (see the hook);
263
+ // this is the same lift the messenger's panel uses.
264
+ const keyboard = useKeyboardOverlap(true, insets.bottom);
265
+ const dismiss = () => void onInteract(post.id, 'dismiss');
266
+ const minimize = () => onMinimize(post.id);
267
+ const swipe = useSwipeToMinimize(minimize);
268
+ const click = (href: string) => {
269
+ void onInteract(post.id, 'click', href);
270
+ void openExternalUrl(href);
271
+ // A link the app routes in-app would otherwise open behind the post.
272
+ onCollapse(post.id);
273
+ };
274
+
275
+ // iOS presents one modal at a time: if the app had its own up (a Modal, an
276
+ // alert, a modal screen), this one never shows and onShow never fires. Fall
277
+ // back to the pill instead of leaving an invisible post.
278
+ const shown = useRef(false);
279
+ const collapseRef = useRef(onCollapse);
280
+ collapseRef.current = onCollapse;
281
+ useEffect(() => {
282
+ if (Platform.OS !== 'ios') return undefined;
283
+ const timer = setTimeout(() => {
284
+ if (!shown.current) collapseRef.current(post.id);
285
+ }, PRESENT_TIMEOUT_MS);
286
+ return () => clearTimeout(timer);
287
+ }, [post.id]);
288
+
289
+ const content = (
290
+ <>
291
+ {/* The top edge is the drag handle: pull it down to minimize. */}
292
+ <View {...swipe.panHandlers}>
293
+ <View style={styles.grabber} />
294
+ <View style={styles.fullHead}>
295
+ <Avatar avatar={post.sender?.avatarUrl} name={post.sender?.name ?? post.workspaceName} color={color} size={32} />
296
+ <Text numberOfLines={1} style={[styles.senderText, styles.fullSender]}>
297
+ {senderLine(post, labels)}
298
+ </Text>
299
+ <MinimizeButton label={labels.minimize} onPress={minimize} />
300
+ <CloseButton label={labels.close} onPress={dismiss} />
301
+ </View>
302
+ </View>
303
+ {/* Full screen: the body takes the room, so the reply box or reactions sit at the bottom. */}
304
+ <ScrollView
305
+ style={large ? styles.flex : styles.fullScroll}
306
+ contentContainerStyle={styles.fullBody}
307
+ keyboardShouldPersistTaps="handled">
308
+ <PostBody html={post.bodyHtml} color={color} onLink={click} />
309
+ {post.thread.length > 0 ? (
310
+ <View style={styles.replies} accessibilityLabel={labels.conversation}>
311
+ {post.thread.map((message, index) => {
312
+ const team = message.from === 'team';
313
+ const previous = post.thread[index - 1];
314
+ // The teammate's name over the first of their answers in a row.
315
+ const showName =
316
+ team && !!message.senderName && !(previous?.from === 'team' && previous.senderName === message.senderName);
317
+ return (
318
+ <View key={`${message.at}-${index}`} style={team ? styles.answerWrap : styles.replyWrap}>
319
+ {showName ? <Text style={styles.replyMeta}>{message.senderName}</Text> : null}
320
+ <View style={team ? styles.answer : [styles.reply, { backgroundColor: color }]}>
321
+ <Text style={[styles.replyText, { color: team ? palette.text : contrastingTextColor(color) }]}>
322
+ {message.text}
323
+ </Text>
324
+ </View>
325
+ {team ? null : <Text style={styles.replyMeta}>{labels.sent}</Text>}
326
+ </View>
327
+ );
328
+ })}
329
+ {post.conversationId ? (
330
+ <Pressable
331
+ onPress={() => onOpenConversation(post.id, post.conversationId as string)}
332
+ accessibilityRole="button"
333
+ accessibilityLabel={labels.continueChat}
334
+ hitSlop={8}
335
+ style={({ pressed }) => [styles.continue, pressed && styles.pressed]}>
336
+ <Text style={[styles.continueText, { color: contrastingTextColor(color) === palette.ink ? palette.text : color }]}>
337
+ {labels.continueChat}
338
+ </Text>
339
+ </Pressable>
340
+ ) : null}
341
+ </View>
342
+ ) : null}
343
+ </ScrollView>
344
+ <PostFooter post={post} color={color} labels={labels} onInteract={onInteract} />
345
+ </>
346
+ );
347
+
348
+ return (
349
+ <Modal
350
+ visible
351
+ // Transparent either way, so swiping a large post down shows the app under it.
352
+ transparent
353
+ animationType={large ? 'slide' : 'fade'}
354
+ presentationStyle="overFullScreen"
355
+ statusBarTranslucent
356
+ onShow={() => {
357
+ shown.current = true;
358
+ }}
359
+ // Back is "not now", not ×.
360
+ onRequestClose={minimize}>
361
+ {/* The keyboard padding sits inside the safe-area padding, so the two add up to the keyboard's height. */}
362
+ {large ? (
363
+ <Animated.View
364
+ accessibilityViewIsModal
365
+ // VoiceOver's two-finger scrub: "not now".
366
+ onAccessibilityEscape={minimize}
367
+ style={[
368
+ styles.flex,
369
+ styles.page,
370
+ { paddingTop: insets.top, paddingBottom: insets.bottom, transform: [{ translateY: swipe.translateY }] },
371
+ ]}>
372
+ <View onLayout={keyboard.onLayout} style={[styles.flex, { paddingBottom: keyboard.overlap }]}>
373
+ {content}
374
+ </View>
375
+ </Animated.View>
376
+ ) : (
377
+ <View style={styles.flex}>
378
+ <Animated.View style={[StyleSheet.absoluteFill, styles.backdropTint, { opacity: swipe.backdropOpacity }]} />
379
+ <View style={[styles.backdrop, { paddingTop: insets.top + spacing.xl, paddingBottom: insets.bottom + spacing.md }]}>
380
+ {/* Tapping outside the sheet is "not now", as on the website's large post. */}
381
+ <Pressable
382
+ style={StyleSheet.absoluteFill}
383
+ onPress={minimize}
384
+ accessibilityRole="button"
385
+ accessibilityLabel={labels.minimize}
386
+ />
387
+ <View onLayout={keyboard.onLayout} pointerEvents="box-none" style={[styles.sheetArea, { paddingBottom: keyboard.overlap }]}>
388
+ <Animated.View
389
+ accessibilityViewIsModal
390
+ onAccessibilityEscape={minimize}
391
+ style={[styles.sheet, { transform: [{ translateY: swipe.translateY }] }]}>
392
+ {content}
393
+ </Animated.View>
394
+ </View>
395
+ </View>
396
+ </View>
397
+ )}
398
+ </Modal>
399
+ );
400
+ }
401
+
402
+ function PostFooter({
403
+ post,
404
+ color,
405
+ labels,
406
+ onInteract,
407
+ }: Pick<PostOverlayProps, 'post' | 'color' | 'labels' | 'onInteract'>) {
408
+ const [text, setText] = useState('');
409
+ const [sending, setSending] = useState(false);
410
+ const [failed, setFailed] = useState(false);
411
+ const [focused, setFocused] = useState(false);
412
+
413
+ if (post.replyType === 'reaction' && post.reactions.length > 0) {
414
+ return <PostReactions post={post} color={color} labels={labels} onInteract={onInteract} />;
415
+ }
416
+ if (post.replyType !== 'text') return null;
417
+
418
+ const value = text.trim();
419
+ const canSend = !!value && !sending;
420
+ const send = async () => {
421
+ if (!canSend) return;
422
+ setSending(true);
423
+ setFailed(false);
424
+ try {
425
+ await onInteract(post.id, 'reply', value);
426
+ setText('');
427
+ } catch {
428
+ setFailed(true);
429
+ } finally {
430
+ setSending(false);
431
+ }
432
+ };
433
+
434
+ return (
435
+ <View style={styles.footer}>
436
+ {/* Same composer as the messenger's: Return adds a line, the arrow sends. */}
437
+ <View style={[styles.composer, focused && styles.composerFocused]}>
438
+ <TextInput
439
+ value={text}
440
+ onChangeText={setText}
441
+ onFocus={() => setFocused(true)}
442
+ onBlur={() => setFocused(false)}
443
+ placeholder={labels.replyPlaceholder}
444
+ placeholderTextColor={palette.textSubtle}
445
+ accessibilityLabel={labels.replyPlaceholder}
446
+ maxLength={2000}
447
+ multiline
448
+ editable={!sending}
449
+ style={styles.input}
450
+ />
451
+ <Pressable
452
+ onPress={send}
453
+ disabled={!canSend}
454
+ accessibilityRole="button"
455
+ accessibilityLabel={labels.send}
456
+ accessibilityState={{ disabled: !canSend }}
457
+ // 36pt circle + 4pt on every side = a 44pt target.
458
+ hitSlop={4}
459
+ style={({ pressed }) => [
460
+ styles.send,
461
+ canSend ? { backgroundColor: color } : styles.sendDisabled,
462
+ pressed && canSend && styles.pressed,
463
+ ]}>
464
+ <ArrowUpIcon size={18} color={canSend ? contrastingTextColor(color) : palette.textSubtle} />
465
+ </Pressable>
466
+ </View>
467
+ {failed ? (
468
+ <Text accessibilityRole="alert" style={styles.error}>
469
+ {labels.replyFailed}
470
+ </Text>
471
+ ) : null}
472
+ </View>
473
+ );
474
+ }
475
+
476
+ /**
477
+ * The reaction row. A tap pops the emoji and throws a few copies of it (the
478
+ * shared reaction burst), then thanks them; the store takes the post away a
479
+ * moment later. Another tap in that moment changes their pick.
480
+ */
481
+ function PostReactions({
482
+ post,
483
+ color,
484
+ labels,
485
+ onInteract,
486
+ }: Pick<PostOverlayProps, 'post' | 'color' | 'labels' | 'onInteract'>) {
487
+ const [thanked, setThanked] = useState(false);
488
+ const react = (emoji: string) => {
489
+ if (!thanked) {
490
+ setThanked(true);
491
+ AccessibilityInfo.announceForAccessibility(labels.thanks);
492
+ }
493
+ void onInteract(post.id, 'reaction', emoji);
494
+ };
495
+ return (
496
+ <View style={styles.footer}>
497
+ <View style={styles.reactions} accessibilityRole="radiogroup" accessibilityLabel={labels.reactions}>
498
+ {post.reactions.map((emoji) => (
499
+ <ReactionChip
500
+ key={emoji}
501
+ emoji={emoji}
502
+ color={color}
503
+ picked={post.reacted === emoji}
504
+ dimmed={!!post.reacted && post.reacted !== emoji}
505
+ onPress={() => react(emoji)}
506
+ />
507
+ ))}
508
+ </View>
509
+ {thanked && post.reacted ? (
510
+ <Text accessibilityLiveRegion="polite" style={styles.thanks}>
511
+ {labels.thanks}
512
+ </Text>
513
+ ) : null}
514
+ </View>
515
+ );
516
+ }
517
+
518
+ /** Where the burst's copies fly: a fan above the chip. */
519
+ const SPARKS = [-70, -40, -12, 12, 40, 70].map((degrees, index) => {
520
+ const angle = ((degrees - 90) * Math.PI) / 180;
521
+ const distance = index % 2 ? 54 : 44;
522
+ return { x: Math.cos(angle) * distance, y: Math.sin(angle) * distance };
523
+ });
524
+
525
+ function ReactionChip({
526
+ emoji,
527
+ color,
528
+ picked,
529
+ dimmed,
530
+ onPress,
531
+ }: {
532
+ emoji: string;
533
+ color: string;
534
+ picked: boolean;
535
+ dimmed: boolean;
536
+ onPress: () => void;
537
+ }) {
538
+ const reduceMotion = useReduceMotion();
539
+ const scale = useRef(new Animated.Value(1)).current;
540
+ const spark = useRef(new Animated.Value(0)).current;
541
+ const [bursting, setBursting] = useState(false);
542
+
543
+ const press = () => {
544
+ onPress();
545
+ if (reduceMotion !== false) return;
546
+ scale.setValue(1);
547
+ Animated.sequence([
548
+ Animated.timing(scale, { toValue: 1.35, duration: 120, useNativeDriver: true }),
549
+ Animated.spring(scale, { toValue: 1, useNativeDriver: true, speed: 14, bounciness: 12 }),
550
+ ]).start();
551
+ spark.setValue(0);
552
+ setBursting(true);
553
+ Animated.timing(spark, { toValue: 1, duration: 650, useNativeDriver: true }).start(({ finished }) => {
554
+ if (finished) setBursting(false);
555
+ });
556
+ };
557
+
558
+ return (
559
+ <Pressable
560
+ onPress={press}
561
+ accessibilityRole="radio"
562
+ accessibilityState={{ checked: picked }}
563
+ accessibilityLabel={emoji}
564
+ style={({ pressed }) => [
565
+ styles.reaction,
566
+ picked && { borderColor: color, backgroundColor: withAlpha(color, 0.12) },
567
+ dimmed ? styles.reactionDim : null,
568
+ pressed && styles.pressed,
569
+ ]}>
570
+ <Animated.Text style={[styles.reactionEmoji, { transform: [{ scale }] }]}>{emoji}</Animated.Text>
571
+ {bursting
572
+ ? SPARKS.map((to, index) => (
573
+ <Animated.Text
574
+ key={index}
575
+ pointerEvents="none"
576
+ importantForAccessibility="no-hide-descendants"
577
+ accessibilityElementsHidden
578
+ style={[
579
+ styles.spark,
580
+ {
581
+ opacity: spark.interpolate({ inputRange: [0, 0.6, 1], outputRange: [1, 0.9, 0] }),
582
+ transform: [
583
+ { translateX: spark.interpolate({ inputRange: [0, 1], outputRange: [0, to.x] }) },
584
+ { translateY: spark.interpolate({ inputRange: [0, 1], outputRange: [0, to.y] }) },
585
+ { scale: spark.interpolate({ inputRange: [0, 1], outputRange: [0.6, 1.1] }) },
586
+ ],
587
+ },
588
+ ]}>
589
+ {emoji}
590
+ </Animated.Text>
591
+ ))
592
+ : null}
593
+ </Pressable>
594
+ );
595
+ }
596
+
597
+ // ─── Body ──────────────────────────────────────────────────────────────────
598
+
599
+ const ALIGN_ITEMS: Record<PostAlign, 'flex-start' | 'center' | 'flex-end'> = {
600
+ left: 'flex-start',
601
+ center: 'center',
602
+ right: 'flex-end',
603
+ };
604
+
605
+ /** Brand marks drawn as a colored circle and letters — the SDK ships no SVG renderer. */
606
+ const SOCIAL: Record<PostSocialNetwork, { label: string; glyph: string; color: string }> = {
607
+ facebook: { label: 'Facebook', glyph: 'f', color: '#1877F2' },
608
+ // A plain X: the math-letter 𝕏 is missing from some Android fonts.
609
+ x: { label: 'X', glyph: 'X', color: '#000000' },
610
+ linkedin: { label: 'LinkedIn', glyph: 'in', color: '#0A66C2' },
611
+ instagram: { label: 'Instagram', glyph: 'IG', color: '#E4405F' },
612
+ youtube: { label: 'YouTube', glyph: '▶', color: '#FF0000' },
613
+ };
614
+
615
+ function Runs({ runs, color, onLink }: { runs: PostTextRun[]; color: string; onLink: (href: string) => void }) {
616
+ return (
617
+ <>
618
+ {runs.map((run, index) => (
619
+ <Text
620
+ key={index}
621
+ onPress={run.href ? () => onLink(run.href as string) : undefined}
622
+ accessibilityRole={run.href ? 'link' : undefined}
623
+ style={[
624
+ run.bold && styles.bold,
625
+ run.italic && styles.italic,
626
+ run.code && styles.inlineCode,
627
+ run.href ? [styles.link, { color: contrastingTextColor(color) === palette.ink ? palette.text : color }] : null,
628
+ ]}>
629
+ {run.text}
630
+ </Text>
631
+ ))}
632
+ </>
633
+ );
634
+ }
635
+
636
+ /** "1." / "•" per item, numbering each nested list on its own. */
637
+ function listMarkers(levels: Array<{ depth: number; ordered: boolean }>): string[] {
638
+ const counts: number[] = [];
639
+ return levels.map(({ depth, ordered }) => {
640
+ counts.length = depth + 1;
641
+ counts[depth] = (counts[depth] ?? 0) + 1;
642
+ if (ordered) return `${counts[depth]}.`;
643
+ return depth === 0 ? '•' : '◦';
644
+ });
645
+ }
646
+
647
+ export function PostBody({ html, color, onLink }: { html: string; color: string; onLink: (href: string) => void }) {
648
+ // The provider re-renders on every store change (typing, new messages).
649
+ const blocks: PostBlock[] = useMemo(() => parsePostHtml(html), [html]);
650
+ return (
651
+ <View style={styles.body}>
652
+ {blocks.map((block, index) => {
653
+ if (block.type === 'paragraph') {
654
+ return (
655
+ <Text key={index} style={styles.paragraph}>
656
+ <Runs runs={block.runs} color={color} onLink={onLink} />
657
+ </Text>
658
+ );
659
+ }
660
+ if (block.type === 'list') {
661
+ const markers = listMarkers(block.levels);
662
+ return (
663
+ <View key={index} style={styles.list}>
664
+ {block.items.map((item, itemIndex) => (
665
+ <View
666
+ key={itemIndex}
667
+ style={[styles.listItem, { paddingStart: (block.levels[itemIndex]?.depth ?? 0) * spacing.lg }]}>
668
+ <Text style={styles.marker}>{markers[itemIndex]}</Text>
669
+ <Text style={[styles.paragraph, styles.listText]}>
670
+ <Runs runs={item} color={color} onLink={onLink} />
671
+ </Text>
672
+ </View>
673
+ ))}
674
+ </View>
675
+ );
676
+ }
677
+ if (block.type === 'code') {
678
+ return (
679
+ <View key={index} style={styles.codeBlock}>
680
+ <Text style={styles.codeText}>{block.text}</Text>
681
+ </View>
682
+ );
683
+ }
684
+ if (block.type === 'button') {
685
+ return (
686
+ <View key={index} style={{ alignItems: ALIGN_ITEMS[block.align] }}>
687
+ <Pressable
688
+ onPress={() => onLink(block.href)}
689
+ accessibilityRole="link"
690
+ style={({ pressed }) => [styles.button, { backgroundColor: color }, pressed && styles.pressed]}>
691
+ <Text style={[styles.buttonText, { color: contrastingTextColor(color) }]}>{block.label}</Text>
692
+ </Pressable>
693
+ </View>
694
+ );
695
+ }
696
+ return (
697
+ <View key={index} style={[styles.social, { justifyContent: ALIGN_ITEMS[block.align] }]}>
698
+ {block.links.map((link) => {
699
+ const mark = SOCIAL[link.network];
700
+ return (
701
+ <Pressable
702
+ key={`${link.network}-${link.href}`}
703
+ onPress={() => onLink(link.href)}
704
+ accessibilityRole="link"
705
+ accessibilityLabel={mark.label}
706
+ hitSlop={6}
707
+ style={({ pressed }) => [styles.socialMark, { backgroundColor: mark.color }, pressed && styles.pressed]}>
708
+ <Text style={styles.socialGlyph}>{mark.glyph}</Text>
709
+ </Pressable>
710
+ );
711
+ })}
712
+ </View>
713
+ );
714
+ })}
715
+ </View>
716
+ );
717
+ }
718
+
719
+ const CLOSE_SIZE = 32;
720
+ const LINE_HEIGHT = 1.45;
721
+
722
+ const styles = StyleSheet.create({
723
+ flex: { flex: 1 },
724
+ pressed: { opacity: 0.85 },
725
+ close: {
726
+ width: CLOSE_SIZE,
727
+ height: CLOSE_SIZE,
728
+ borderRadius: radius.sm,
729
+ alignItems: 'center',
730
+ justifyContent: 'center',
731
+ },
732
+ bottomWrap: {
733
+ position: 'absolute',
734
+ left: 0,
735
+ right: 0,
736
+ paddingHorizontal: spacing.lg,
737
+ alignItems: 'center',
738
+ zIndex: 1000,
739
+ elevation: 1000,
740
+ },
741
+ card: {
742
+ width: '100%',
743
+ maxWidth: 480,
744
+ borderRadius: radius.xl,
745
+ backgroundColor: palette.white,
746
+ borderWidth: StyleSheet.hairlineWidth,
747
+ borderColor: palette.border,
748
+ ...shadows.floating,
749
+ },
750
+ cardBody: {
751
+ paddingVertical: spacing.md,
752
+ paddingStart: spacing.lg,
753
+ // Room for the × in the corner.
754
+ paddingEnd: CLOSE_SIZE + spacing.lg,
755
+ gap: spacing.sm,
756
+ borderRadius: radius.xl,
757
+ },
758
+ cardHead: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
759
+ cardClose: { position: 'absolute', top: spacing.sm, end: spacing.sm },
760
+ senderText: {
761
+ flexShrink: 1,
762
+ color: palette.textMuted,
763
+ fontSize: fontSize.meta,
764
+ fontWeight: '600',
765
+ textAlign: START_ALIGN,
766
+ },
767
+ snippet: { color: palette.text, fontSize: fontSize.body, lineHeight: fontSize.body * LINE_HEIGHT, textAlign: START_ALIGN },
768
+ badgeWrap: {
769
+ position: 'absolute',
770
+ end: spacing.lg,
771
+ flexDirection: 'row',
772
+ alignItems: 'center',
773
+ gap: spacing.xs,
774
+ zIndex: 1000,
775
+ elevation: 1000,
776
+ },
777
+ pill: {
778
+ flexDirection: 'row',
779
+ alignItems: 'center',
780
+ gap: spacing.sm,
781
+ minHeight: 44,
782
+ paddingVertical: spacing.xs,
783
+ paddingStart: spacing.xs,
784
+ paddingEnd: spacing.md,
785
+ borderRadius: radius.pill,
786
+ backgroundColor: palette.white,
787
+ borderWidth: StyleSheet.hairlineWidth,
788
+ borderColor: palette.border,
789
+ ...shadows.floating,
790
+ },
791
+ pillText: { maxWidth: 180, color: palette.text, fontSize: fontSize.meta, fontWeight: '600' },
792
+ dot: { width: 8, height: 8, borderRadius: 4, backgroundColor: palette.danger },
793
+ pillClose: {
794
+ borderRadius: radius.pill,
795
+ backgroundColor: palette.white,
796
+ borderWidth: StyleSheet.hairlineWidth,
797
+ borderColor: palette.border,
798
+ ...shadows.card,
799
+ },
800
+ backdrop: { flex: 1, paddingHorizontal: spacing.md },
801
+ backdropTint: { backgroundColor: 'rgba(15, 23, 42, 0.45)' },
802
+ sheetArea: { flex: 1, justifyContent: 'flex-end' },
803
+ sheet: {
804
+ maxHeight: '100%',
805
+ borderRadius: radius.xl,
806
+ backgroundColor: palette.white,
807
+ overflow: 'hidden',
808
+ ...shadows.floating,
809
+ },
810
+ page: { backgroundColor: palette.white },
811
+ grabber: {
812
+ alignSelf: 'center',
813
+ width: 36,
814
+ height: 5,
815
+ borderRadius: 3,
816
+ marginTop: spacing.sm,
817
+ backgroundColor: palette.border,
818
+ },
819
+ fullHead: {
820
+ flexDirection: 'row',
821
+ alignItems: 'center',
822
+ gap: spacing.sm,
823
+ paddingStart: spacing.lg,
824
+ paddingEnd: spacing.sm,
825
+ paddingTop: spacing.xs,
826
+ paddingBottom: spacing.md,
827
+ },
828
+ fullSender: { flex: 1 },
829
+ fullScroll: { flexGrow: 0, flexShrink: 1 },
830
+ fullBody: { paddingHorizontal: spacing.lg, paddingBottom: spacing.lg, gap: spacing.lg },
831
+ body: { gap: spacing.md },
832
+ paragraph: { color: palette.text, fontSize: fontSize.body, lineHeight: fontSize.body * LINE_HEIGHT, textAlign: START_ALIGN },
833
+ bold: { fontWeight: '700' },
834
+ italic: { fontStyle: 'italic' },
835
+ inlineCode: {
836
+ fontFamily: Platform.select({ ios: 'Menlo', default: 'monospace' }),
837
+ fontSize: fontSize.meta,
838
+ backgroundColor: palette.surfaceAlt,
839
+ },
840
+ link: { textDecorationLine: 'underline', fontWeight: '600' },
841
+ list: { gap: spacing.xs },
842
+ listItem: { flexDirection: 'row', gap: spacing.sm },
843
+ marker: { minWidth: spacing.lg, color: palette.textMuted, fontSize: fontSize.body, lineHeight: fontSize.body * LINE_HEIGHT },
844
+ listText: { flex: 1 },
845
+ codeBlock: { borderRadius: radius.md, backgroundColor: palette.surfaceAlt, padding: spacing.md },
846
+ codeText: {
847
+ color: palette.text,
848
+ fontFamily: Platform.select({ ios: 'Menlo', default: 'monospace' }),
849
+ fontSize: fontSize.meta,
850
+ lineHeight: fontSize.meta * LINE_HEIGHT,
851
+ },
852
+ button: {
853
+ minHeight: 44,
854
+ paddingHorizontal: spacing.lg,
855
+ borderRadius: radius.md,
856
+ alignItems: 'center',
857
+ justifyContent: 'center',
858
+ },
859
+ buttonText: { fontSize: fontSize.body, fontWeight: '700' },
860
+ social: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing.sm },
861
+ socialMark: { width: 36, height: 36, borderRadius: 18, alignItems: 'center', justifyContent: 'center' },
862
+ socialGlyph: { color: palette.white, fontSize: fontSize.meta, fontWeight: '800' },
863
+ replies: { gap: spacing.sm },
864
+ replyWrap: { alignItems: 'flex-end', gap: spacing.xs / 2 },
865
+ answerWrap: { alignItems: 'flex-start', gap: spacing.xs / 2 },
866
+ answer: {
867
+ maxWidth: '85%',
868
+ paddingHorizontal: spacing.md,
869
+ paddingVertical: spacing.sm,
870
+ borderRadius: radius.xl,
871
+ borderBottomStartRadius: spacing.xs,
872
+ backgroundColor: palette.surfaceAlt,
873
+ },
874
+ continue: { alignSelf: 'center', paddingVertical: spacing.xs, paddingHorizontal: spacing.sm, marginTop: spacing.xs },
875
+ continueText: { fontSize: fontSize.meta, fontWeight: '600' },
876
+ reply: {
877
+ maxWidth: '85%',
878
+ paddingHorizontal: spacing.md,
879
+ paddingVertical: spacing.sm,
880
+ borderRadius: radius.xl,
881
+ borderBottomEndRadius: spacing.xs,
882
+ },
883
+ replyText: { fontSize: fontSize.body, lineHeight: fontSize.body * LINE_HEIGHT },
884
+ replyMeta: { color: palette.textSubtle, fontSize: fontSize.caption },
885
+ footer: {
886
+ borderTopWidth: StyleSheet.hairlineWidth,
887
+ borderTopColor: palette.border,
888
+ backgroundColor: palette.white,
889
+ paddingHorizontal: spacing.md,
890
+ paddingVertical: spacing.sm,
891
+ gap: spacing.xs,
892
+ },
893
+ // Matches the messenger's composer (ChatView.styles.ts).
894
+ composer: {
895
+ flexDirection: 'row',
896
+ alignItems: 'flex-end',
897
+ gap: spacing.sm,
898
+ minHeight: 48,
899
+ backgroundColor: palette.surfaceAlt,
900
+ borderRadius: 24,
901
+ borderWidth: 1,
902
+ borderColor: palette.border,
903
+ paddingStart: spacing.lg,
904
+ paddingEnd: spacing.xs + 2,
905
+ paddingVertical: spacing.xs + 2,
906
+ },
907
+ composerFocused: { backgroundColor: palette.white },
908
+ input: {
909
+ flex: 1,
910
+ maxHeight: 120,
911
+ padding: 0,
912
+ paddingTop: 6,
913
+ paddingBottom: 6,
914
+ color: palette.ink,
915
+ fontSize: fontSize.input,
916
+ lineHeight: fontSize.input * 1.4,
917
+ textAlign: START_ALIGN,
918
+ },
919
+ send: { width: 36, height: 36, borderRadius: 18, alignItems: 'center', justifyContent: 'center' },
920
+ sendDisabled: { backgroundColor: palette.surfaceAlt },
921
+ error: { color: palette.danger, fontSize: fontSize.caption },
922
+ reactions: { flexDirection: 'row', justifyContent: 'center', gap: spacing.sm, paddingVertical: spacing.xs },
923
+ reaction: {
924
+ width: 48,
925
+ height: 48,
926
+ borderRadius: 24,
927
+ borderWidth: 1,
928
+ borderColor: palette.border,
929
+ backgroundColor: palette.white,
930
+ alignItems: 'center',
931
+ justifyContent: 'center',
932
+ },
933
+ reactionDim: { opacity: 0.5 },
934
+ reactionEmoji: { fontSize: 24 },
935
+ spark: { position: 'absolute', fontSize: 16 },
936
+ thanks: {
937
+ textAlign: 'center',
938
+ color: palette.text,
939
+ fontSize: fontSize.meta,
940
+ fontWeight: '600',
941
+ paddingBottom: spacing.xs,
942
+ },
943
+ });