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

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