tf-checkout-react-native 2.0.0-beta.17 → 2.0.0-beta.18

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tf-checkout-react-native",
3
- "version": "2.0.0-beta.17",
3
+ "version": "2.0.0-beta.18",
4
4
  "description": "TheTicketFairy ReactNative Checkout library",
5
5
  "homepage": "https://github.com/theticketfairy/tf-shared-libraries#readme",
6
6
  "author": "The Ticket Fairy",
@@ -103,7 +103,9 @@
103
103
  "react-dom": "19.1.0",
104
104
  "react-native": "0.81.4",
105
105
  "react-native-mmkv": "^3.3.1",
106
+ "react-native-render-html": "6.3.4",
106
107
  "react-native-webview": "13.15.0",
108
+ "react-test-renderer": "19.1.0",
107
109
  "release-it": "^19.0.4",
108
110
  "tf-checkout-shared": "workspace:*",
109
111
  "tf-seat-map-view": "9.4.7-beta.33",
@@ -113,6 +115,21 @@
113
115
  },
114
116
  "jest": {
115
117
  "preset": "react-native",
118
+ "transform": {
119
+ "^.+\\.(js|ts|tsx)$": [
120
+ "babel-jest",
121
+ {
122
+ "babelrc": false,
123
+ "configFile": false,
124
+ "presets": [
125
+ "module:@react-native/babel-preset"
126
+ ]
127
+ }
128
+ ]
129
+ },
130
+ "transformIgnorePatterns": [
131
+ "node_modules/(?!((jest-)?react-native|@react-native(-community)?|\\.pnpm)/)"
132
+ ],
116
133
  "modulePathIgnorePatterns": [
117
134
  "<rootDir>/example/node_modules",
118
135
  "<rootDir>/dist/"
@@ -160,6 +177,7 @@
160
177
  "react": ">=19.1",
161
178
  "react-native": ">=0.81",
162
179
  "react-native-mmkv": "^3.0.0",
180
+ "react-native-render-html": ">=6.3.4 <7",
163
181
  "react-native-webview": ">=13.15.0"
164
182
  },
165
183
  "//dependencies": "tf-checkout-shared is INLINED into this bundle (tsup noExternal), so its own runtime deps are ours too \u2014 axios, entities and htmlparser2 must stay declared here or tsup bundles them (axios drags Node's stream into a browser build) / the consumer never installs them.",
@@ -1,9 +1,10 @@
1
1
  import React from 'react'
2
2
  import { StyleSheet, Text, View } from 'react-native'
3
- import { hooks, htmlToPlainText, accommodationBookingDeadline } from 'tf-checkout-shared'
3
+ import { hooks, accommodationBookingDeadline } from 'tf-checkout-shared'
4
4
  import { useCheckoutContext, useTheme } from '../ui/CheckoutProvider'
5
5
  import { Field, Input } from '../ui/Field'
6
6
  import { QuantitySelect } from '../ui/QuantitySelect'
7
+ import { RichText } from '../ui/RichText'
7
8
  import { Button, Card, ErrorText, Loader, MutedText, SectionHeader } from '../ui/primitives'
8
9
 
9
10
  /** Native controls over the shared stay draft, quote and reservation lifecycle. */
@@ -26,7 +27,7 @@ export const Accommodation = ({ eventId }: { eventId: string }) => {
26
27
  return (
27
28
  <View key={id} style={styles.room}>
28
29
  <Text style={[text, styles.title]}>{room.property_name} · {room.name}</Text>
29
- {!!room.description && <MutedText>{htmlToPlainText(room.description)}</MutedText>}
30
+ {!!room.description && <RichText html={room.description} />}
30
31
  {accommodationBookingDeadline(room) && <MutedText>{accommodationBookingDeadline(room)}</MutedText>}
31
32
  <MutedText>{room.sold_out ? 'Sold out' : `${room.currency} ${room.min_price.toFixed(2)} per night${room.max_price > room.min_price ? ' and up' : ''}`}</MutedText>
32
33
  <Field label="Rooms">
@@ -15,7 +15,6 @@ import {
15
15
  addOnMaxQuantity,
16
16
  errorMessage,
17
17
  hooks,
18
- htmlToPlainText,
19
18
  type EventAddon,
20
19
  } from 'tf-checkout-shared'
21
20
 
@@ -26,6 +25,7 @@ import { useCheckoutContext, useTheme } from '../ui/CheckoutProvider'
26
25
  // `CustomFieldGroupFields` API: { group, values, onChange(name, value) }.
27
26
  import { CustomFieldGroupFields } from '../ui/CustomFields'
28
27
  import { QuantitySelect } from '../ui/QuantitySelect'
28
+ import { RichText } from '../ui/RichText'
29
29
  import type { ResolvedTheme } from '../ui/theme'
30
30
 
31
31
  const money = (currency: string, n: number): string =>
@@ -135,7 +135,6 @@ export const AddOns: FC<AddOnsProps> = ({
135
135
  {g.items.map((a) => {
136
136
  const n = qty[a.id] ?? 0
137
137
  const group = fieldGroups[a.id]
138
- const desc = a.description ? htmlToPlainText(a.description) : ''
139
138
  return (
140
139
  <View key={a.id} style={s.item}>
141
140
  <View style={s.row}>
@@ -150,7 +149,9 @@ export const AddOns: FC<AddOnsProps> = ({
150
149
  <Text style={s.priceNote}> incl. fees</Text>
151
150
  ) : null}
152
151
  </Text>
153
- {desc ? <Text style={s.desc}>{desc}</Text> : null}
152
+ {a.description ? (
153
+ <RichText html={a.description} textStyle={s.desc} />
154
+ ) : null}
154
155
  </View>
155
156
  <View style={s.quantity}>
156
157
  {soldOut(a) ? (
@@ -17,7 +17,6 @@ import { StyleSheet, Text, View } from 'react-native'
17
17
  import {
18
18
  bundleAvailabilityCopy,
19
19
  bundleContentsLine,
20
- htmlToPlainText,
21
20
  bundleQuantityRange,
22
21
  createTranslator,
23
22
  hooks,
@@ -33,6 +32,7 @@ import { useCheckoutContext, useTheme } from '../ui/CheckoutProvider'
33
32
  import { Field, Input } from '../ui/Field'
34
33
  import { Button, Card, ErrorText, Loader } from '../ui/primitives'
35
34
  import { QuantitySelect } from '../ui/QuantitySelect'
35
+ import { RichText } from '../ui/RichText'
36
36
  import type { ResolvedTheme } from '../ui/theme'
37
37
 
38
38
  const money = (currency: string, amount: number) => `${currency} ${amount.toFixed(2)}`
@@ -361,7 +361,6 @@ export const Bundles: FC<BundlesProps> = ({
361
361
  const range = bundleQuantityRange(bundle)
362
362
  const stock = bundleAvailabilityCopy(bundle)
363
363
  const contents = bundleContentsLine(bundle)
364
- const desc = htmlToPlainText(bundle.description ?? '')
365
364
  return (
366
365
  <View key={bundle.id} style={[s.item, { borderTopColor: theme.border }]}>
367
366
  <View style={s.info}>
@@ -377,7 +376,9 @@ export const Bundles: FC<BundlesProps> = ({
377
376
  })}
378
377
  </Text>
379
378
  )}
380
- {desc ? <Text style={s.muted}>{desc}</Text> : null}
379
+ {bundle.description ? (
380
+ <RichText html={bundle.description} textStyle={s.muted} />
381
+ ) : null}
381
382
  {stock && stock.key !== 'bundles.soldOut' ? (
382
383
  <Text style={[s.muted, s.stock]}>{t(stock.key, stock.values)}</Text>
383
384
  ) : null}
@@ -15,7 +15,7 @@ import { Accommodation } from './Accommodation'
15
15
  * payment plans. Existing explicit wallet rows keep their own policy gates.
16
16
  * - CheckoutProvider owns provider-page windows for Xendit, redirect methods,
17
17
  * Razorpay and PayPal; imperative hosts provide that port themselves.
18
- * - Conditions render as plain text (HTML stripped) — no DOM to inject markup.
18
+ * - Conditions use the shared native rich renderer, preserving links/formatting.
19
19
  */
20
20
  import React, { useEffect, useMemo, useState, type FC } from 'react'
21
21
  import { Platform, View } from 'react-native'
@@ -30,7 +30,6 @@ import {
30
30
  cartTicketCount,
31
31
  getConfig,
32
32
  errorMessage,
33
- htmlToPlainText,
34
33
  missingCheckoutDetails,
35
34
  paymentAcknowledgementsMet,
36
35
  paymentPlanTerms,
@@ -52,8 +51,13 @@ import {
52
51
  import { CardInput, claimCardField } from '../CardInput'
53
52
  import { nativePaymentSheetAvailable } from '../paymentSheet'
54
53
  import { rnConfirmer } from '../instance'
55
- import { useCheckoutContext, useCheckoutValidation } from '../ui/CheckoutProvider'
54
+ import {
55
+ useCheckoutContext,
56
+ useCheckoutValidation,
57
+ useTheme,
58
+ } from '../ui/CheckoutProvider'
56
59
  import { Checkbox } from '../ui/Field'
60
+ import { RichText } from '../ui/RichText'
57
61
  import { PaymentMethods, type PaymentMethodOption } from '../ui/PaymentMethods'
58
62
  import {
59
63
  BodyText,
@@ -85,10 +89,6 @@ const REDIRECT_LABELS: Record<string, string> = {
85
89
  const redirectLabel = (t: string) =>
86
90
  REDIRECT_LABELS[t] ?? t.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
87
91
 
88
- /** Condition copy as one line of plain text: native has no DOM to render
89
- * markup into, and the copy is a checkbox label. */
90
- const conditionText = (html: string) => htmlToPlainText(html).replace(/\s*\n\s*/g, ' ')
91
-
92
92
  export interface PaymentProps {
93
93
  onComplete?: (order: PlaceOrderResult) => void
94
94
  /** Optional event context (parity with web; native `OrderSummary` reads the
@@ -150,6 +150,7 @@ export const Payment: FC<PaymentProps> = ({
150
150
  }) => {
151
151
  const t = translate ?? createTranslator({ locale, messages })
152
152
  const checkout = useCheckoutContext()
153
+ const theme = useTheme()
153
154
  // This checkout's own card field: another checkout on the page has its own.
154
155
  const cardOwner = hooks.useTicketCardOwner()
155
156
  // A bundle purchase holds the payment SDK: Pay would do nothing now.
@@ -506,7 +507,17 @@ export const Payment: FC<PaymentProps> = ({
506
507
  checked={Boolean(checkout.acceptedConditions?.[c.id])}
507
508
  invalid={invalid}
508
509
  onChange={(next) => checkout.acceptCondition(c.id, next)}
509
- label={`${conditionText(c.content || c.name)}${c.isRequired ? ' *' : ''}`}
510
+ label={
511
+ <View style={{ flex: 1, minWidth: 0, flexDirection: 'row', gap: 4 }}>
512
+ <View style={{ flex: 1, minWidth: 0 }}>
513
+ <RichText
514
+ html={c.content || c.name}
515
+ textStyle={{ color: theme.text }}
516
+ />
517
+ </View>
518
+ {c.isRequired ? <BodyText>*</BodyText> : null}
519
+ </View>
520
+ }
510
521
  />
511
522
  </FrozenFields>
512
523
  {invalid ? (
@@ -25,11 +25,10 @@ import {
25
25
  changeTicketQuantity,
26
26
  errorMessage,
27
27
  hooks,
28
- htmlToPlainText,
29
28
  ticketQuantityRange,
29
+ waitingListProfilePrefill,
30
30
  type TicketSelection,
31
31
  type TicketType,
32
- type WaitingListEntry,
33
32
  createTranslator,
34
33
  } from 'tf-checkout-shared'
35
34
 
@@ -46,73 +45,13 @@ import { FrozenFields } from '../ui/Frozen'
46
45
  import { useCheckoutContext, useTheme } from '../ui/CheckoutProvider'
47
46
  import { Field, Input } from '../ui/Field'
48
47
  import { QuantitySelect } from '../ui/QuantitySelect'
48
+ import { WaitingListForm } from '../ui/WaitingListForm'
49
+ import { RichText } from '../ui/RichText'
49
50
  import { Select } from '../ui/Select'
50
51
  import type { ResolvedTheme } from '../ui/theme'
51
52
 
52
53
  const money = (sym: string, n: number): string => `${sym} ${Number(n).toFixed(2)}`
53
54
 
54
- /** Inline waiting-list sign-up for a sold-out type. */
55
- const WaitingListForm: FC<{
56
- ticket: TicketType
57
- onJoin: (entry: WaitingListEntry) => Promise<void>
58
- }> = ({ ticket, onJoin }) => {
59
- const [form, setForm] = useState({ firstName: '', lastName: '', email: '' })
60
- const [state, setState] = useState<'idle' | 'busy' | 'done' | 'error'>('idle')
61
- const [error, setError] = useState('')
62
- const ok = Boolean(form.firstName.trim() && form.lastName.trim() && form.email.trim())
63
-
64
- if (state === 'done')
65
- return (
66
- <MutedText>You’re on the waiting list — we’ll email you if a spot opens.</MutedText>
67
- )
68
-
69
- return (
70
- <View style={staticStyles.wlForm}>
71
- <Field>
72
- <Input
73
- placeholder="First name"
74
- value={form.firstName}
75
- onChangeText={(t) => setForm((f) => ({ ...f, firstName: t }))}
76
- />
77
- </Field>
78
- <Field>
79
- <Input
80
- placeholder="Last name"
81
- value={form.lastName}
82
- onChangeText={(t) => setForm((f) => ({ ...f, lastName: t }))}
83
- />
84
- </Field>
85
- <Field>
86
- <Input
87
- placeholder="Email"
88
- value={form.email}
89
- keyboardType="email-address"
90
- autoCapitalize="none"
91
- onChangeText={(t) => setForm((f) => ({ ...f, email: t }))}
92
- />
93
- </Field>
94
- <Button
95
- variant="ghost"
96
- loading={state === 'busy'}
97
- disabled={!ok || state === 'busy'}
98
- onPress={() => {
99
- setState('busy')
100
- setError('')
101
- onJoin({ ...form, ticketTypeId: ticket.id })
102
- .then(() => setState('done'))
103
- .catch((e) => {
104
- setState('error')
105
- setError(errorMessage(e))
106
- })
107
- }}
108
- >
109
- Join the waiting list
110
- </Button>
111
- {error ? <ErrorText>{error}</ErrorText> : null}
112
- </View>
113
- )
114
- }
115
-
116
55
  /** Host-owned visuals over the shared catalog and reservation flow. */
117
56
  export interface TicketRowRenderProps {
118
57
  ticket: TicketType
@@ -178,6 +117,13 @@ export const Tickets: FC<TicketsProps> = ({
178
117
  const theme = useTheme()
179
118
  const s = useTicketStyles(theme)
180
119
  const ticketsQuery = hooks.useTicketsWithMeta(eventId)
120
+ const waitingBuyer = hooks.useAuthSession()
121
+ const cachedProfile = hooks.useProfile({ enabled: false }).data
122
+ const waitingPrefill = waitingListProfilePrefill(
123
+ cachedProfile,
124
+ waitingBuyer.buyerId,
125
+ waitingBuyer.sessionScope
126
+ )
181
127
  // A bundle order is starting: wait, so the two flows never start at once.
182
128
  // The checkout start lock is held by a bundle start or pay (shown as a
183
129
  // wait), or by this checkout's own priming (shown as the busy button).
@@ -185,7 +131,6 @@ export const Tickets: FC<TicketsProps> = ({
185
131
  const bundleWait = hooks.useTicketGatewayWait(checkout.store)
186
132
  const bundleStarting = bundleWait !== null
187
133
  const promoMeta = ticketsQuery.data?.promo
188
- const showWaitingList = ticketsQuery.data?.showWaitingList ?? false
189
134
 
190
135
  const [qty, setQty] = useState<Record<string, number>>({})
191
136
  const [slotDate, setSlotDate] = useState('')
@@ -201,6 +146,11 @@ export const Tickets: FC<TicketsProps> = ({
201
146
  if (isTimeSlot && !slotDate && dates.length) setSlotDate(dates[0])
202
147
  }, [isTimeSlot, slotDate, dates])
203
148
  const slotQuery = hooks.useTimeSlotTickets(eventId, isTimeSlot ? slotDate : undefined)
149
+ const refetchCatalog = () =>
150
+ Promise.all([
151
+ ticketsQuery.refetch(),
152
+ ...(isTimeSlot && slotDate ? [slotQuery.refetch()] : []),
153
+ ])
204
154
 
205
155
  const tickets = useMemo(
206
156
  () => (isTimeSlot ? (slotQuery.data ?? []) : (ticketsQuery.data?.tickets ?? [])),
@@ -218,7 +168,6 @@ export const Tickets: FC<TicketsProps> = ({
218
168
  setPromoOpen(false)
219
169
  setError('')
220
170
  })
221
- const [wlOpen, setWlOpen] = useState<Record<string, boolean>>({})
222
171
  const [visibleDescription, setVisibleDescription] = useState<string | null>(null)
223
172
  const attributionEvent = useRef<string | null>(null)
224
173
 
@@ -243,7 +192,7 @@ export const Tickets: FC<TicketsProps> = ({
243
192
  .applyPromoCode(eventId, promo)
244
193
  .catch(() => undefined)
245
194
  .finally(() => {
246
- ticketsQuery.refetch().finally(() => setCodeState('idle'))
195
+ refetchCatalog().finally(() => setCodeState('idle'))
247
196
  })
248
197
  }
249
198
  // Checkout actions and query functions are stable; this effect is scoped to
@@ -264,7 +213,7 @@ export const Tickets: FC<TicketsProps> = ({
264
213
  .then((res) => {
265
214
  if (res.requiresCatalogRefresh) {
266
215
  setQty({})
267
- ticketsQuery.refetch()
216
+ refetchCatalog()
268
217
  }
269
218
  })
270
219
  .catch(() => undefined)
@@ -340,7 +289,7 @@ export const Tickets: FC<TicketsProps> = ({
340
289
  .applyPromoCode(eventId, code.trim())
341
290
  .catch(() => undefined)
342
291
  .finally(() => {
343
- ticketsQuery.refetch().finally(() => setCodeState('idle'))
292
+ refetchCatalog().finally(() => setCodeState('idle'))
344
293
  })
345
294
  }
346
295
 
@@ -354,8 +303,6 @@ export const Tickets: FC<TicketsProps> = ({
354
303
  const unavailable = t.soldOut || t.salesEnded || notStarted
355
304
  const original = originals.current[t.id]
356
305
  const discounted = original != null && original > t.price
357
- const canWaitlist =
358
- t.soldOut && (t.waitingListEnabled || showWaitingList) && !t.salesEnded
359
306
  const isFree = Number(t.price) === 0
360
307
  const sym = t.priceSymbol || t.currency
361
308
  const range = ticketQuantityRange(t)
@@ -457,26 +404,10 @@ export const Tickets: FC<TicketsProps> = ({
457
404
  )}
458
405
  </View>
459
406
  {t.descriptionRich && visibleDescription === t.id ? (
460
- <Text style={s.description}>{htmlToPlainText(t.descriptionRich)}</Text>
407
+ <RichText html={t.descriptionRich} textStyle={s.description} />
461
408
  ) : null}
462
409
  </>
463
410
  )}
464
- {canWaitlist ? (
465
- wlOpen[t.id] ? (
466
- <WaitingListForm
467
- ticket={t}
468
- onJoin={(entry) => checkout.joinWaitingList(eventId, entry)}
469
- />
470
- ) : (
471
- <Pressable
472
- accessibilityRole="button"
473
- style={s.textAction}
474
- onPress={() => setWlOpen((w) => ({ ...w, [t.id]: true }))}
475
- >
476
- <Text style={s.wlToggle}>Join the waiting list</Text>
477
- </Pressable>
478
- )
479
- ) : null}
480
411
  </View>
481
412
  </Fragment>
482
413
  )
@@ -639,6 +570,13 @@ export const Tickets: FC<TicketsProps> = ({
639
570
  </Button>
640
571
  ) : null}
641
572
 
573
+ <WaitingListForm
574
+ eventId={eventId}
575
+ definition={ticketsQuery.data?.waitingList}
576
+ prefill={waitingPrefill}
577
+ onJoin={(entry) => checkout.joinWaitingList(eventId, entry)}
578
+ />
579
+
642
580
  {error || checkout.error ? (
643
581
  <ErrorText>{error || checkout.error}</ErrorText>
644
582
  ) : null}
@@ -647,11 +585,6 @@ export const Tickets: FC<TicketsProps> = ({
647
585
  )
648
586
  }
649
587
 
650
- /** Theme-independent layout bits (used by the WaitingListForm subcomponent). */
651
- const staticStyles = StyleSheet.create({
652
- wlForm: { gap: 8 },
653
- })
654
-
655
588
  const useTicketStyles = (theme: ResolvedTheme) =>
656
589
  useMemo(
657
590
  () =>
@@ -775,12 +708,6 @@ const useTicketStyles = (theme: ResolvedTheme) =>
775
708
  lineHeight: theme.fontSize * 1.5,
776
709
  fontFamily: theme.fontFamily,
777
710
  },
778
- wlToggle: {
779
- color: theme.accent,
780
- fontSize: theme.fontSize - 1,
781
- fontWeight: '600',
782
- fontFamily: theme.fontFamily,
783
- },
784
711
  promo: { gap: 8 },
785
712
  promoToggle: {
786
713
  color: theme.accent,
@@ -20,7 +20,6 @@ import {
20
20
  emptyWaiverDraft,
21
21
  formatDateDigits,
22
22
  hooks,
23
- htmlToPlainText,
24
23
  holderBackfilledFrom,
25
24
  signatureMatchesHolder,
26
25
  waiverTicketFormKey,
@@ -38,6 +37,7 @@ import {
38
37
 
39
38
  import { useCheckoutContext, useTheme } from '../ui/CheckoutProvider'
40
39
  import { SignaturePad } from '../ui/SignaturePad'
40
+ import { RichText } from '../ui/RichText'
41
41
  import { Checkbox, Field, Input } from '../ui/Field'
42
42
  import { Button, Card, ErrorText, SectionHeader } from '../ui/primitives'
43
43
  import type { ResolvedTheme } from '../ui/theme'
@@ -116,7 +116,7 @@ export const WaiverSigning: FC<WaiverSigningProps> = ({
116
116
  )}
117
117
  </Text>
118
118
  <ScrollView style={[s.textBox, { borderColor: theme.border }]} nestedScrollEnabled>
119
- <Text style={s.body}>{htmlToPlainText(waiver.bodyHtml)}</Text>
119
+ <RichText html={waiver.bodyHtml} textStyle={s.body} selectable />
120
120
  </ScrollView>
121
121
 
122
122
  {tickets.map((ticket) => {
@@ -0,0 +1,187 @@
1
+ import React, { useMemo, useState } from 'react'
2
+ import { Alert, Linking, StyleSheet, Text, View } from 'react-native'
3
+ import RenderHTML, {
4
+ defaultSystemFonts,
5
+ HTMLContentModel,
6
+ HTMLElementModel,
7
+ IMGElement,
8
+ useIMGElementProps,
9
+ useSharedProps,
10
+ type CustomBlockRenderer,
11
+ type MixedStyleDeclaration,
12
+ type MixedStyleRecord,
13
+ } from 'react-native-render-html'
14
+
15
+ import { useTheme } from './CheckoutProvider'
16
+
17
+ const ignoredDomTags = [
18
+ 'script',
19
+ 'style',
20
+ 'template',
21
+ 'noscript',
22
+ 'iframe',
23
+ 'object',
24
+ 'embed',
25
+ 'form',
26
+ 'head',
27
+ ]
28
+ const allowedStyles: NonNullable<
29
+ React.ComponentProps<typeof RenderHTML>['allowedStyles']
30
+ > = ['fontWeight', 'fontStyle', 'textDecorationLine', 'textAlign', 'display']
31
+ const ignoreDomNode: NonNullable<
32
+ React.ComponentProps<typeof RenderHTML>['ignoreDomNode']
33
+ > = (node) => {
34
+ if (!('attribs' in node) || !node.attribs || typeof node.attribs !== 'object')
35
+ return false
36
+ return (
37
+ 'hidden' in node.attribs ||
38
+ ('style' in node.attribs &&
39
+ typeof node.attribs.style === 'string' &&
40
+ /(?:^|;)\s*(?:display\s*:\s*none|visibility\s*:\s*(?:hidden|collapse))\s*(?:!important\s*)?(?:;|$)/i.test(
41
+ node.attribs.style
42
+ ))
43
+ )
44
+ }
45
+ // Default models omit tables. Block models retain every caption/cell without
46
+ // a WebView or a second parser; complex spans remain readable, not pixel-identical.
47
+ const customHTMLElementModels = Object.fromEntries(
48
+ ['table', 'caption', 'thead', 'tbody', 'tfoot', 'tr', 'td', 'th'].map((tagName) => [
49
+ tagName,
50
+ HTMLElementModel.fromCustomModel({ tagName, contentModel: HTMLContentModel.block }),
51
+ ])
52
+ )
53
+
54
+ export interface RichTextProps {
55
+ html: string
56
+ textStyle?: MixedStyleDeclaration
57
+ tagsStyles?: MixedStyleRecord
58
+ onLinkPress?: (href: string) => void
59
+ selectable?: boolean
60
+ }
61
+
62
+ const SafeImage: CustomBlockRenderer = (props) => {
63
+ const image = useIMGElementProps(props)
64
+ const { defaultTextProps } = useSharedProps()
65
+ if (!/^https?:\/\//i.test(image.source.uri ?? ''))
66
+ return image.alt ? (
67
+ <Text {...defaultTextProps} style={props.tnode.styles.nativeTextFlow}>
68
+ {image.alt}
69
+ </Text>
70
+ ) : null
71
+ return <IMGElement {...image} />
72
+ }
73
+ const renderers = { img: SafeImage }
74
+
75
+ /** API HTML is parsed once, preserving escaped literal tags as visible text. */
76
+ export const RichText = ({
77
+ html,
78
+ textStyle,
79
+ tagsStyles: tagOverrides,
80
+ onLinkPress,
81
+ selectable = false,
82
+ }: RichTextProps) => {
83
+ const theme = useTheme()
84
+ const [width, setWidth] = useState(0)
85
+ const source = useMemo(() => ({ html }), [html])
86
+ const baseStyle = useMemo(
87
+ () => ({
88
+ color: theme.muted,
89
+ fontSize: theme.fontSize - 1,
90
+ lineHeight: theme.fontSize * 1.5,
91
+ fontFamily: theme.fontFamily,
92
+ ...textStyle,
93
+ }),
94
+ [theme.muted, theme.fontSize, theme.fontFamily, textStyle]
95
+ )
96
+ const systemFonts = useMemo(
97
+ () =>
98
+ [
99
+ ...new Set([...defaultSystemFonts, theme.fontFamily, textStyle?.fontFamily]),
100
+ ].filter((font): font is string => typeof font === 'string'),
101
+ [theme.fontFamily, textStyle?.fontFamily]
102
+ )
103
+ const tagsStyles = useMemo(() => {
104
+ const defaults: Record<string, MixedStyleDeclaration> = {
105
+ p: { marginTop: 0, marginBottom: 8 },
106
+ ul: { marginTop: 0, marginBottom: 8, paddingLeft: 22 },
107
+ ol: { marginTop: 0, marginBottom: 8, paddingLeft: 22 },
108
+ a: { color: theme.accent, textDecorationLine: 'underline' },
109
+ table: { marginBottom: 8 },
110
+ caption: { fontWeight: '600', marginBottom: 4 },
111
+ tr: { flexDirection: 'row', flexWrap: 'wrap' },
112
+ td: { flexGrow: 1, flexShrink: 1, flexBasis: 0, minWidth: 48, padding: 4 },
113
+ th: {
114
+ flexGrow: 1,
115
+ flexShrink: 1,
116
+ flexBasis: 0,
117
+ minWidth: 48,
118
+ padding: 4,
119
+ fontWeight: '600',
120
+ },
121
+ }
122
+ // Match the standard heading sizes to the body font with enough line height;
123
+ // inheriting the body's fixed height clips larger headings.
124
+ const headingBase =
125
+ typeof baseStyle.fontSize === 'number' ? baseStyle.fontSize : theme.fontSize - 1
126
+ for (const [heading, scale] of Object.entries({
127
+ h1: 2,
128
+ h2: 1.5,
129
+ h3: 1.17,
130
+ h4: 1,
131
+ h5: 0.83,
132
+ h6: 0.67,
133
+ })) {
134
+ const overrideSize = tagOverrides?.[heading]?.fontSize
135
+ const size = typeof overrideSize === 'number' ? overrideSize : headingBase * scale
136
+ defaults[heading] = { fontSize: size, lineHeight: size * 1.35 }
137
+ }
138
+ for (const [tag, style] of Object.entries(tagOverrides ?? {}))
139
+ defaults[tag] = { ...defaults[tag], ...style }
140
+ return defaults
141
+ }, [theme.accent, theme.fontSize, baseStyle.fontSize, tagOverrides])
142
+ const renderersProps = useMemo<
143
+ NonNullable<React.ComponentProps<typeof RenderHTML>['renderersProps']>
144
+ >(
145
+ () => ({
146
+ a: {
147
+ onPress: (event, href) => {
148
+ // Opening a nested terms link must never accept its parent checkbox.
149
+ event.stopPropagation?.()
150
+ if (!/^https?:\/\//i.test(href)) return
151
+ if (onLinkPress) onLinkPress(href)
152
+ else
153
+ void Linking.openURL(href).catch(() =>
154
+ Alert.alert('Couldn’t open link', 'Please try again.')
155
+ )
156
+ },
157
+ },
158
+ }),
159
+ [onLinkPress]
160
+ )
161
+ const defaultTextProps = useMemo(() => ({ selectable }), [selectable])
162
+ return (
163
+ <View
164
+ style={styles.container}
165
+ onLayout={(event) => setWidth(event.nativeEvent.layout.width)}
166
+ >
167
+ {width > 0 ? (
168
+ <RenderHTML
169
+ source={source}
170
+ contentWidth={width}
171
+ baseStyle={baseStyle}
172
+ systemFonts={systemFonts}
173
+ tagsStyles={tagsStyles}
174
+ allowedStyles={allowedStyles}
175
+ ignoredDomTags={ignoredDomTags}
176
+ ignoreDomNode={ignoreDomNode}
177
+ renderersProps={renderersProps}
178
+ customHTMLElementModels={customHTMLElementModels}
179
+ renderers={renderers}
180
+ defaultTextProps={defaultTextProps}
181
+ />
182
+ ) : null}
183
+ </View>
184
+ )
185
+ }
186
+
187
+ const styles = StyleSheet.create({ container: { alignSelf: 'stretch', minWidth: 0 } })