tf-checkout-react-native 2.0.0-beta.0 → 2.0.0-beta.6

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 (60) hide show
  1. package/README.md +92 -8
  2. package/dist/index.d.mts +7410 -290
  3. package/dist/index.d.ts +7410 -290
  4. package/dist/index.js +23539 -5909
  5. package/dist/index.js.map +1 -1
  6. package/dist/index.mjs +23561 -5857
  7. package/dist/index.mjs.map +1 -1
  8. package/package.json +22 -10
  9. package/scripts/build-seat-map.mjs +31 -0
  10. package/scripts/seat-map-entry.jsx +22 -0
  11. package/src/CardInput.tsx +64 -3
  12. package/src/EmbeddedCheckout.tsx +129 -36
  13. package/src/auth/AuthModal.tsx +90 -6
  14. package/src/auth/EmailCodeForm.tsx +91 -0
  15. package/src/auth/RegisterForm.tsx +91 -5
  16. package/src/auth/TwoFactorForm.tsx +74 -0
  17. package/src/auth/index.ts +4 -0
  18. package/src/configure.ts +8 -1
  19. package/src/confirmer.ts +112 -20
  20. package/src/generated/seatMapWidget.ts +3 -0
  21. package/src/index.tsx +1 -1
  22. package/src/sections/Accommodation.tsx +57 -0
  23. package/src/sections/AccountRewards.tsx +108 -0
  24. package/src/sections/AddOns.tsx +99 -148
  25. package/src/sections/BillingInfo.tsx +63 -64
  26. package/src/sections/Bundles.tsx +467 -0
  27. package/src/sections/EventDiscussionView.tsx +637 -0
  28. package/src/sections/Passes.tsx +177 -0
  29. package/src/sections/Payment.tsx +169 -52
  30. package/src/sections/PostReaction.tsx +122 -0
  31. package/src/sections/Rewards.tsx +320 -0
  32. package/src/sections/SeatMap.tsx +225 -0
  33. package/src/sections/SeatsIoMap.tsx +110 -0
  34. package/src/sections/Streaming.tsx +278 -0
  35. package/src/sections/TicketChange.tsx +379 -0
  36. package/src/sections/TicketGift.tsx +206 -0
  37. package/src/sections/TicketRefund.tsx +104 -0
  38. package/src/sections/Tickets.tsx +289 -208
  39. package/src/sections/Trips.tsx +87 -0
  40. package/src/sections/WaiverSigning.tsx +429 -0
  41. package/src/sections/billing/PhoneIdentity.tsx +169 -0
  42. package/src/sections/discussion/CommentImages.tsx +366 -0
  43. package/src/sections/discussion/SwipeReply.tsx +88 -0
  44. package/src/sections/index.ts +26 -0
  45. package/src/sections/rewards/RewardsLadder.tsx +167 -0
  46. package/src/sections/rewards/RewardsTree.tsx +117 -0
  47. package/src/storage.ts +8 -9
  48. package/src/ui/CheckoutProvider.tsx +25 -13
  49. package/src/ui/Field.tsx +21 -3
  50. package/src/ui/Frozen.tsx +22 -0
  51. package/src/ui/InstallmentPicker.tsx +15 -18
  52. package/src/ui/PaymentMethods.tsx +6 -3
  53. package/src/ui/ProviderPages.tsx +134 -0
  54. package/src/ui/QuantitySelect.tsx +26 -2
  55. package/src/ui/Select.tsx +4 -1
  56. package/src/ui/SignaturePad.tsx +218 -0
  57. package/src/ui/index.ts +1 -0
  58. package/src/ui/primitives.tsx +13 -2
  59. package/src/ui/useAccountT.ts +16 -0
  60. package/src/useCheckout.ts +3 -1
@@ -0,0 +1,637 @@
1
+ import React, { useRef, useState } from 'react'
2
+ import {
3
+ ActivityIndicator,
4
+ Alert,
5
+ FlatList,
6
+ Image,
7
+ KeyboardAvoidingView,
8
+ Linking,
9
+ Modal,
10
+ Platform,
11
+ Pressable,
12
+ StyleSheet,
13
+ Text,
14
+ TextInput,
15
+ View,
16
+ } from 'react-native'
17
+ import type {
18
+ EventComment,
19
+ EventCommentReportReason,
20
+ EventDiscussion,
21
+ } from 'tf-checkout-shared'
22
+ import { EVENT_COMMENT_IMAGE_MAX_COUNT } from 'tf-checkout-shared'
23
+ import { useTheme } from '../ui/CheckoutProvider'
24
+ import { CommentImages, CommentImageDrafts } from './discussion/CommentImages'
25
+ import { SwipeReply } from './discussion/SwipeReply'
26
+ import { useAccountT } from '../ui/useAccountT'
27
+
28
+ export interface EventDiscussionViewProps {
29
+ eventId: string
30
+ discussion: EventDiscussion
31
+ onLogin?: () => void
32
+ /** Platform picker only; common hook owns upload, validation and publication. */
33
+ pickImage?: () => Promise<string | null>
34
+ pickImages?: (remaining: number) => Promise<string[] | null>
35
+ previewKey?: string
36
+ locale?: string
37
+ renderReplyIcon?: (color: string) => React.ReactNode
38
+ /** Optional host artwork; the SDK owns the accessible close button and behavior. */
39
+ renderImageCloseIcon?: (color: string) => React.ReactNode
40
+ onModerationFilter?: (filter: 'pending' | 'reported' | undefined) => void
41
+ renderReactionIcon?: (
42
+ reaction: 'up' | 'down',
43
+ selected: boolean,
44
+ color: string
45
+ ) => React.ReactNode
46
+ }
47
+
48
+ /** Native presentation of the common discussion state. No account, moderation
49
+ * or mutation eligibility is inferred here: controls use the API's capabilities. */
50
+ const EventDiscussionBody = ({
51
+ eventId,
52
+ discussion: d,
53
+ onLogin,
54
+ pickImage,
55
+ pickImages,
56
+ previewKey,
57
+ locale,
58
+ renderReplyIcon,
59
+ renderImageCloseIcon,
60
+ onModerationFilter,
61
+ renderReactionIcon,
62
+ }: EventDiscussionViewProps) => {
63
+ const theme = useTheme()
64
+ const t = useAccountT(locale)
65
+ const list = useRef<FlatList<EventComment>>(null)
66
+ const firstContent = useRef(true)
67
+ const [actions, setActions] = useState<EventComment | null>(null)
68
+ const [reporting, setReporting] = useState<EventComment | null>(null)
69
+ const [moderating, setModerating] = useState<{
70
+ comment: EventComment
71
+ status: 'published' | 'hidden'
72
+ } | null>(null)
73
+ const [reason, setReason] = useState('')
74
+ const [blocksVisible, setBlocksVisible] = useState(false)
75
+ const [pickerError, setPickerError] = useState('')
76
+ const [imageOpen, setImageOpen] = useState(false)
77
+ const [pickerBusy, setPickerBusy] = useState(false)
78
+ const picking = useRef(false)
79
+ const text = { color: theme.text, fontFamily: theme.fontFamily }
80
+ const muted = { color: theme.muted, fontFamily: theme.fontFamily }
81
+ const closeActions = (work: (comment: EventComment) => void) => {
82
+ if (actions) work(actions)
83
+ setActions(null)
84
+ }
85
+ const chooseImage = async () => {
86
+ if (
87
+ (!pickImages && !pickImage) ||
88
+ d.isSubmitting ||
89
+ picking.current ||
90
+ d.imagesBase64.length >= EVENT_COMMENT_IMAGE_MAX_COUNT
91
+ )
92
+ return
93
+ picking.current = true
94
+ setPickerBusy(true)
95
+ setPickerError('')
96
+ try {
97
+ const values = pickImages
98
+ ? await pickImages(EVENT_COMMENT_IMAGE_MAX_COUNT - d.imagesBase64.length)
99
+ : [await pickImage?.()].filter((value): value is string => Boolean(value))
100
+ if (values?.length) d.appendImagesBase64(values)
101
+ } catch (error) {
102
+ setPickerError(
103
+ error instanceof Error
104
+ ? error.message
105
+ : 'Your image couldn’t be opened. Try again.'
106
+ )
107
+ } finally {
108
+ picking.current = false
109
+ setPickerBusy(false)
110
+ }
111
+ }
112
+ const send = async () => {
113
+ if (await d.submit()) list.current?.scrollToEnd({ animated: true })
114
+ }
115
+ const item = ({ item: comment }: { item: EventComment }) => {
116
+ const own = comment.canEdit || comment.canDelete
117
+ return (
118
+ <SwipeReply
119
+ enabled={d.canReplyTo(comment) && !d.isSubmitting && !imageOpen}
120
+ onReply={() => d.replyTo(comment)}
121
+ renderIcon={renderReplyIcon}
122
+ hint={t('Swipe left to reply')}
123
+ >
124
+ <View style={[s.row, own && s.ownRow]}>
125
+ {!own && (
126
+ <View style={[s.avatar, { backgroundColor: theme.surfaceMuted }]}>
127
+ {comment.author.avatarUrl ? (
128
+ <Image source={{ uri: comment.author.avatarUrl }} style={s.avatar} />
129
+ ) : (
130
+ <Text style={text}>{comment.author.name.slice(0, 1).toUpperCase()}</Text>
131
+ )}
132
+ </View>
133
+ )}
134
+ <View
135
+ style={[
136
+ s.bubble,
137
+ {
138
+ backgroundColor: theme.surface,
139
+ borderColor: own ? theme.accent : theme.border,
140
+ },
141
+ own && s.ownBubble,
142
+ ]}
143
+ >
144
+ <View style={s.authorRow}>
145
+ <Text style={[muted, s.author]}>
146
+ {comment.author.name}
147
+ {own ? ' (you)' : ''}
148
+ </Text>
149
+ <Pressable
150
+ accessibilityRole="button"
151
+ accessibilityLabel={`Actions for comment by ${comment.author.name}`}
152
+ hitSlop={10}
153
+ onPress={() => setActions(comment)}
154
+ >
155
+ <Text style={muted}>•••</Text>
156
+ </Pressable>
157
+ </View>
158
+ {comment.replyTo && (
159
+ <Pressable
160
+ accessibilityRole="button"
161
+ onPress={() => d.replyTo(comment)}
162
+ style={[s.reply, { borderLeftColor: theme.accent }]}
163
+ >
164
+ <Text style={[muted, s.author]}>{comment.replyTo.authorName}</Text>
165
+ <Text style={muted} numberOfLines={2}>
166
+ {comment.replyTo.body}
167
+ </Text>
168
+ </Pressable>
169
+ )}
170
+ <CommentImages
171
+ key={`${comment.id}:${comment.revision}`}
172
+ eventId={eventId}
173
+ comment={comment}
174
+ previewKey={previewKey}
175
+ locale={locale}
176
+ onPreviewChange={setImageOpen}
177
+ renderCloseIcon={renderImageCloseIcon}
178
+ />
179
+ <Text selectable style={[text, s.body]}>
180
+ {comment.status === 'deleted' ? 'Comment removed' : comment.body}
181
+ </Text>
182
+ {comment.status === 'pending' && (
183
+ <Text accessibilityLiveRegion="polite" style={[muted, s.pending]}>
184
+ Awaiting review
185
+ </Text>
186
+ )}
187
+ {comment.status === 'hidden' && (
188
+ <Text style={[muted, s.pending]}>Hidden</Text>
189
+ )}
190
+ <View style={s.meta}>
191
+ <Text style={[muted, s.date]}>
192
+ {new Date(comment.createdAt).toLocaleString()}
193
+ {comment.editedAt ? ' · Edited' : ''}
194
+ </Text>
195
+ {(['up', 'down'] as const).map((reaction) => (
196
+ <Pressable
197
+ key={reaction}
198
+ accessibilityRole="button"
199
+ accessibilityLabel={
200
+ reaction === 'up' ? 'Like comment' : 'Dislike comment'
201
+ }
202
+ accessibilityState={{
203
+ selected: comment.reactions.mine === reaction,
204
+ disabled: d.isSubmitting,
205
+ }}
206
+ disabled={d.isSubmitting}
207
+ onPress={() =>
208
+ void d.react(
209
+ comment,
210
+ comment.reactions.mine === reaction ? null : reaction
211
+ )
212
+ }
213
+ style={s.reaction}
214
+ >
215
+ <View style={s.reactionInner}>
216
+ {renderReactionIcon ? (
217
+ renderReactionIcon(
218
+ reaction,
219
+ comment.reactions.mine === reaction,
220
+ comment.reactions.mine === reaction ? theme.accent : theme.muted
221
+ )
222
+ ) : (
223
+ <Text>{reaction === 'up' ? '👍' : '👎'}</Text>
224
+ )}
225
+ <Text
226
+ style={{
227
+ color:
228
+ comment.reactions.mine === reaction
229
+ ? theme.accent
230
+ : theme.muted,
231
+ }}
232
+ >
233
+ {comment.reactions[reaction] || ''}
234
+ </Text>
235
+ </View>
236
+ </Pressable>
237
+ ))}
238
+ </View>
239
+ {comment.replyCount > 0 && (
240
+ <Pressable
241
+ accessibilityRole="button"
242
+ disabled={!d.canReplyTo(comment)}
243
+ onPress={() => d.replyTo(comment)}
244
+ >
245
+ <Text style={[muted, s.pending]}>
246
+ {comment.replyCount} {comment.replyCount === 1 ? 'reply' : 'replies'} ·
247
+ Reply
248
+ </Text>
249
+ </Pressable>
250
+ )}
251
+ </View>
252
+ </View>
253
+ </SwipeReply>
254
+ )
255
+ }
256
+ const actionButton = (label: string, onPress: () => void) => (
257
+ <Pressable key={label} accessibilityRole="button" onPress={onPress} style={s.action}>
258
+ <Text style={text}>{t(label)}</Text>
259
+ </Pressable>
260
+ )
261
+ const sheet = (visible: boolean, close: () => void, children: React.ReactNode) => (
262
+ <Modal transparent animationType="slide" visible={visible} onRequestClose={close}>
263
+ <View style={s.overlay}>
264
+ <Pressable
265
+ accessibilityLabel="Close menu"
266
+ style={StyleSheet.absoluteFill}
267
+ onPress={close}
268
+ />
269
+ <View
270
+ style={[s.sheet, { backgroundColor: theme.surface, borderColor: theme.border }]}
271
+ >
272
+ {children}
273
+ {actionButton('Close', close)}
274
+ </View>
275
+ </View>
276
+ </Modal>
277
+ )
278
+ return (
279
+ <KeyboardAvoidingView
280
+ style={s.container}
281
+ behavior={Platform.OS === 'ios' ? 'padding' : undefined}
282
+ >
283
+ {onModerationFilter && d.canModerate && (
284
+ <View style={s.filters}>
285
+ {actionButton('Comments', () => onModerationFilter(undefined))}
286
+ {actionButton('Pending', () => onModerationFilter('pending'))}
287
+ {actionButton('Reported', () => onModerationFilter('reported'))}
288
+ </View>
289
+ )}
290
+ {!!d.blockedAuthors.length && (
291
+ <Pressable
292
+ accessibilityRole="button"
293
+ onPress={() => setBlocksVisible(true)}
294
+ style={s.blocked}
295
+ >
296
+ <Text style={muted}>Blocked people</Text>
297
+ </Pressable>
298
+ )}
299
+ {d.isLoading ? (
300
+ <ActivityIndicator style={s.loading} color={theme.accent} />
301
+ ) : (
302
+ <FlatList
303
+ ref={list}
304
+ data={d.comments}
305
+ keyExtractor={(comment) => comment.id}
306
+ renderItem={item}
307
+ contentContainerStyle={s.messages}
308
+ keyboardShouldPersistTaps="handled"
309
+ maintainVisibleContentPosition={{ minIndexForVisible: 0 }}
310
+ onContentSizeChange={() => {
311
+ if (firstContent.current && d.comments.length) {
312
+ firstContent.current = false
313
+ list.current?.scrollToEnd({ animated: false })
314
+ }
315
+ }}
316
+ ListHeaderComponent={
317
+ d.hasMore ? actionButton('Earlier comments', d.loadMore) : null
318
+ }
319
+ ListEmptyComponent={
320
+ <Text style={[muted, s.empty]}>Be the first to leave a comment.</Text>
321
+ }
322
+ refreshing={d.isLoading}
323
+ onRefresh={() => void d.refresh()}
324
+ />
325
+ )}
326
+ {(d.error || pickerError) && (
327
+ <View style={s.notice}>
328
+ <Text accessibilityRole="alert" style={{ color: theme.error }}>
329
+ {t(d.error || pickerError)}
330
+ </Text>
331
+ <Pressable accessibilityRole="button" onPress={() => void d.refresh()}>
332
+ <Text style={muted}>Try again</Text>
333
+ </Pressable>
334
+ </View>
335
+ )}
336
+ {!!d.message && (
337
+ <Text accessibilityLiveRegion="polite" style={[muted, s.notice]}>
338
+ {t(d.message)}
339
+ </Text>
340
+ )}
341
+ {(d.replyingTo || d.editing) && (
342
+ <View style={[s.draftContext, { borderColor: theme.border }]}>
343
+ <Text style={muted} numberOfLines={1}>
344
+ {d.editing
345
+ ? 'Edit your comment'
346
+ : t('Reply to {{name}}', { name: d.replyingTo?.author.name ?? '' })}
347
+ </Text>
348
+ <Pressable accessibilityRole="button" onPress={d.cancelDraft}>
349
+ <Text style={muted}>Cancel</Text>
350
+ </Pressable>
351
+ </View>
352
+ )}
353
+ <CommentImageDrafts
354
+ images={d.imagesBase64}
355
+ locale={locale}
356
+ disabled={d.isSubmitting}
357
+ onRemove={d.removeImage}
358
+ />
359
+ {d.canPost ? (
360
+ <View
361
+ style={[
362
+ s.composer,
363
+ { borderColor: theme.border, backgroundColor: theme.surface },
364
+ ]}
365
+ >
366
+ {(pickImages || pickImage) && !d.editing && (
367
+ <Pressable
368
+ accessibilityRole="button"
369
+ accessibilityLabel={t('Attach image')}
370
+ disabled={
371
+ d.isSubmitting ||
372
+ pickerBusy ||
373
+ d.imagesBase64.length >= EVENT_COMMENT_IMAGE_MAX_COUNT
374
+ }
375
+ onPress={() => void chooseImage()}
376
+ style={s.attach}
377
+ >
378
+ <Text style={[muted, s.plus]}>⊕</Text>
379
+ </Pressable>
380
+ )}
381
+ <TextInput
382
+ accessibilityLabel="Your comment"
383
+ placeholder="Enter your comment…"
384
+ placeholderTextColor={theme.muted}
385
+ multiline
386
+ maxLength={4000}
387
+ value={d.body}
388
+ onChangeText={d.setBody}
389
+ editable={!d.isSubmitting}
390
+ style={[text, s.input]}
391
+ />
392
+ <Pressable
393
+ accessibilityRole="button"
394
+ accessibilityLabel={d.editing ? 'Save comment' : 'Post comment'}
395
+ disabled={
396
+ d.isSubmitting ||
397
+ pickerBusy ||
398
+ (!d.body.trim() && !d.imagesBase64.length && !d.editing?.images.length)
399
+ }
400
+ onPress={() => void send()}
401
+ style={s.send}
402
+ >
403
+ {d.isSubmitting ? (
404
+ <ActivityIndicator color={theme.accent} />
405
+ ) : (
406
+ <Text style={{ color: theme.accent }}>{d.editing ? 'Save' : 'Send'}</Text>
407
+ )}
408
+ </Pressable>
409
+ </View>
410
+ ) : (
411
+ <Pressable
412
+ accessibilityRole="button"
413
+ onPress={onLogin}
414
+ style={[
415
+ s.composer,
416
+ { borderColor: theme.border, backgroundColor: theme.surface },
417
+ ]}
418
+ >
419
+ <Text style={text}>Sign in to leave a comment</Text>
420
+ </Pressable>
421
+ )}
422
+ <Pressable
423
+ accessibilityRole="link"
424
+ style={s.support}
425
+ onPress={() => {
426
+ if (/^https:\/\//.test(d.supportUrl))
427
+ void Linking.openURL(d.supportUrl).catch(() => undefined)
428
+ }}
429
+ >
430
+ <Text style={[muted, s.date]}>Need help? Contact support</Text>
431
+ </Pressable>
432
+ {sheet(
433
+ Boolean(actions),
434
+ () => setActions(null),
435
+ actions && (
436
+ <>
437
+ {d.canReplyTo(actions) &&
438
+ actionButton('Reply', () => closeActions((comment) => d.replyTo(comment)))}
439
+ {actions.canEdit &&
440
+ actionButton('Edit', () => closeActions((comment) => d.edit(comment)))}
441
+ {actions.canDelete &&
442
+ actionButton('Delete', () =>
443
+ closeActions((comment) =>
444
+ Alert.alert(
445
+ 'Delete comment?',
446
+ 'Your comment will be removed from this conversation.',
447
+ [
448
+ { text: 'Cancel', style: 'cancel' },
449
+ {
450
+ text: 'Delete',
451
+ style: 'destructive',
452
+ onPress: () => void d.remove(comment),
453
+ },
454
+ ]
455
+ )
456
+ )
457
+ )}
458
+ {d.canPost &&
459
+ actionButton('Report comment', () => closeActions(setReporting))}
460
+ {d.canPost &&
461
+ !actions.canEdit &&
462
+ actionButton('Block this person', () =>
463
+ closeActions((comment) =>
464
+ Alert.alert(
465
+ `Block ${comment.author.name}?`,
466
+ 'You won’t see their comments. You can unblock them from Blocked people.',
467
+ [
468
+ { text: 'Cancel', style: 'cancel' },
469
+ {
470
+ text: 'Block',
471
+ onPress: () => void d.block(comment.author.id, true),
472
+ },
473
+ ]
474
+ )
475
+ )
476
+ )}
477
+ {actions.canModerate &&
478
+ actionButton(
479
+ actions.status === 'published' ? 'Hide comment' : 'Approve comment',
480
+ () =>
481
+ closeActions((comment) => {
482
+ setReason('')
483
+ setModerating({
484
+ comment,
485
+ status: comment.status === 'published' ? 'hidden' : 'published',
486
+ })
487
+ })
488
+ )}
489
+ </>
490
+ )
491
+ )}
492
+ {sheet(
493
+ Boolean(reporting),
494
+ () => setReporting(null),
495
+ <>
496
+ <Text style={text}>Report comment</Text>
497
+ {(
498
+ [
499
+ ['spam', 'Spam'],
500
+ ['abuse', 'Harassment or abuse'],
501
+ ['unsafe', 'Unsafe content'],
502
+ ['other', 'Other'],
503
+ ] as Array<[EventCommentReportReason, string]>
504
+ ).map(([value, label]) =>
505
+ actionButton(label, () => {
506
+ if (reporting) void d.report(reporting, value)
507
+ setReporting(null)
508
+ })
509
+ )}
510
+ </>
511
+ )}
512
+ {sheet(
513
+ Boolean(moderating),
514
+ () => setModerating(null),
515
+ <>
516
+ <Text style={text}>
517
+ {moderating?.status === 'published' ? 'Approve comment' : 'Hide comment'}
518
+ </Text>
519
+ <TextInput
520
+ accessibilityLabel="Moderation reason"
521
+ placeholder="Reason"
522
+ placeholderTextColor={theme.muted}
523
+ value={reason}
524
+ onChangeText={setReason}
525
+ style={[text, s.reason]}
526
+ />
527
+ {actionButton('Confirm', () => {
528
+ if (moderating && reason.trim()) {
529
+ void d.moderate(moderating.comment, moderating.status, reason.trim())
530
+ setModerating(null)
531
+ }
532
+ })}
533
+ </>
534
+ )}
535
+ {sheet(
536
+ blocksVisible,
537
+ () => setBlocksVisible(false),
538
+ <>
539
+ <Text style={text}>Blocked people</Text>
540
+ <FlatList
541
+ data={d.blockedAuthors}
542
+ keyExtractor={(person) => person.id}
543
+ renderItem={({ item: person }) =>
544
+ actionButton(`Unblock ${person.name}`, () => void d.block(person.id, false))
545
+ }
546
+ />
547
+ </>
548
+ )}
549
+ </KeyboardAvoidingView>
550
+ )
551
+ }
552
+
553
+ /** Retire local dialogs and full-screen images along with their shared subject. */
554
+ export const EventDiscussionView = (props: EventDiscussionViewProps) => (
555
+ <EventDiscussionBody key={props.discussion.scopeKey} {...props} />
556
+ )
557
+
558
+ const s = StyleSheet.create({
559
+ container: { flex: 1 },
560
+ messages: { padding: 16, paddingBottom: 12 },
561
+ row: { flexDirection: 'row', alignItems: 'flex-end', marginBottom: 16, gap: 12 },
562
+ ownRow: { paddingRight: 44 },
563
+ avatar: {
564
+ width: 32,
565
+ height: 32,
566
+ borderRadius: 16,
567
+ alignItems: 'center',
568
+ justifyContent: 'center',
569
+ },
570
+ bubble: {
571
+ flex: 1,
572
+ borderWidth: 1,
573
+ borderRadius: 14,
574
+ borderBottomLeftRadius: 0,
575
+ padding: 15,
576
+ },
577
+ ownBubble: { borderBottomLeftRadius: 14, borderBottomRightRadius: 0 },
578
+ authorRow: {
579
+ flexDirection: 'row',
580
+ alignItems: 'center',
581
+ justifyContent: 'space-between',
582
+ marginBottom: 6,
583
+ gap: 8,
584
+ },
585
+ author: { fontSize: 12, flexShrink: 1 },
586
+ body: { fontSize: 14, lineHeight: 22 },
587
+ meta: { flexDirection: 'row', alignItems: 'center', marginTop: 10, gap: 10 },
588
+ date: { fontSize: 11, flexShrink: 1 },
589
+ reaction: { minHeight: 28, justifyContent: 'center' },
590
+ reactionInner: { flexDirection: 'row', alignItems: 'center', gap: 4 },
591
+ pending: { fontSize: 12, marginTop: 8 },
592
+ reply: { borderLeftWidth: 2, paddingLeft: 8, marginBottom: 8 },
593
+ composer: {
594
+ flexDirection: 'row',
595
+ alignItems: 'center',
596
+ borderWidth: 1,
597
+ borderRadius: 32,
598
+ minHeight: 64,
599
+ marginHorizontal: 16,
600
+ paddingHorizontal: 14,
601
+ paddingVertical: 10,
602
+ },
603
+ input: { flex: 1, maxHeight: 140, fontSize: 14, paddingVertical: 8 },
604
+ attach: { paddingRight: 10, paddingVertical: 6 },
605
+ plus: { fontSize: 24 },
606
+ send: { paddingLeft: 10, paddingVertical: 10 },
607
+ support: { padding: 8, alignItems: 'center' },
608
+ draftContext: {
609
+ marginHorizontal: 20,
610
+ padding: 10,
611
+ borderTopWidth: 1,
612
+ flexDirection: 'row',
613
+ justifyContent: 'space-between',
614
+ },
615
+ notice: { marginHorizontal: 20, marginVertical: 8 },
616
+ empty: { textAlign: 'center', paddingVertical: 32 },
617
+ loading: { margin: 32 },
618
+ overlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.6)', justifyContent: 'flex-end' },
619
+ sheet: {
620
+ borderTopLeftRadius: 24,
621
+ borderTopRightRadius: 24,
622
+ borderWidth: 1,
623
+ padding: 20,
624
+ paddingBottom: 32,
625
+ maxHeight: '80%',
626
+ },
627
+ action: { paddingVertical: 14, paddingHorizontal: 8 },
628
+ filters: { flexDirection: 'row', justifyContent: 'space-around' },
629
+ blocked: { alignItems: 'flex-end', paddingHorizontal: 20 },
630
+ reason: {
631
+ borderWidth: 1,
632
+ borderColor: '#777',
633
+ borderRadius: 8,
634
+ padding: 12,
635
+ marginTop: 16,
636
+ },
637
+ })