diff --git a/__tests__/termsParser.test.ts b/__tests__/termsParser.test.ts
new file mode 100644
index 0000000..3cd034a
--- /dev/null
+++ b/__tests__/termsParser.test.ts
@@ -0,0 +1,147 @@
+/**
+ * Tests for the Terms/Privacy HTML parser used by WelcomeScreen.
+ *
+ * Two layers:
+ * 1. Deterministic unit tests over a fixed fixture — lock the parser's
+ * behaviour (block segmentation, inline formatting, entity decoding).
+ * 2. A live guard test that fetches https://minibits.cash/terms and asserts
+ * structural invariants. This is what catches an *unexpected* change to
+ * the live Terms markup (e.g. a new element type the parser can't handle,
+ * which would otherwise leak raw HTML into the app). Requires network.
+ */
+
+import { htmlToBlocks, extractArticle, Block } from '../src/utils/htmlToBlocks'
+
+const TERMS_URL = 'https://minibits.cash/terms'
+
+const SAMPLE = `
+Terms and Conditions
+Bitango & friends said "hello" 'again'
+
+Part 0 — General Terms
+0.1 About
+Read the Privacy Policy and the fine print .
+
+First item
+Second item
+
+
+IMPORTANT
+Read carefully.
+
+ `
+
+const textOf = (block: Block): string =>
+ block.type === 'hr' ? '' : block.segments.map(s => s.text).join('')
+
+describe('htmlToBlocks (fixture)', () => {
+ const blocks = htmlToBlocks(SAMPLE)
+
+ test('extractArticle returns only the article inner HTML', () => {
+ const inner = extractArticle(SAMPLE)
+ expect(inner).toContain('
Terms and Conditions ')
+ expect(inner).not.toContain('')
+ expect(inner).not.toContain('')
+ })
+
+ test('produces the expected block sequence', () => {
+ expect(blocks.map(b => b.type)).toEqual([
+ 'h1', 'p', 'hr', 'h2', 'h3', 'p', 'li', 'li', 'quote', 'quote',
+ ])
+ })
+
+ test('decodes HTML entities in text', () => {
+ expect(textOf(blocks[1])).toBe('Bitango & friends said "hello" \'again\'')
+ })
+
+ test('marks segments as bold', () => {
+ const strong = blocks[1].type !== 'hr' && blocks[1].segments.find(s => s.text === 'Bitango')
+ expect(strong && strong.bold).toBe(true)
+ })
+
+ test('captures link href and marks segments as italic', () => {
+ const paragraph = blocks[5]
+ if (paragraph.type === 'hr') throw new Error('unexpected hr')
+ const link = paragraph.segments.find(s => s.href)
+ expect(link?.href).toBe('https://minibits.cash/privacy')
+ expect(link?.text).toBe('Privacy Policy')
+ const italic = paragraph.segments.find(s => s.text === 'fine print')
+ expect(italic?.italic).toBe(true)
+ })
+
+ test('splits into individual blocks', () => {
+ expect(textOf(blocks[6])).toBe('First item')
+ expect(textOf(blocks[7])).toBe('Second item')
+ })
+
+ test('expands paragraphs into quote blocks', () => {
+ expect(blocks[8].type).toBe('quote')
+ expect(textOf(blocks[8])).toBe('IMPORTANT')
+ expect(textOf(blocks[9])).toBe('Read carefully.')
+ })
+
+ test('never leaks raw HTML tags into rendered text', () => {
+ for (const block of blocks) {
+ expect(textOf(block)).not.toMatch(/<[a-zA-Z/][^>]*>/)
+ }
+ })
+
+ test('parses an already-extracted fragment (no wrapper)', () => {
+ const fragment = htmlToBlocks('Hello
World ')
+ expect(fragment.map(b => b.type)).toEqual(['p', 'h3'])
+ })
+})
+
+describe('htmlToBlocks (live minibits.cash/terms)', () => {
+ // Fetching the live page is what catches an unexpected change to the Terms
+ // markup. Generous timeout for CI networks.
+ jest.setTimeout(30000)
+
+ let blocks: Block[] = []
+ let fetchError: Error | undefined
+
+ beforeAll(async () => {
+ try {
+ const controller = new AbortController()
+ const timeout = setTimeout(() => controller.abort(), 25000)
+ const response = await fetch(TERMS_URL, { signal: controller.signal })
+ const html = await response.text()
+ clearTimeout(timeout)
+ expect(html).toContain(' {
+ // If this fails the Terms page is unreachable or moved — surface it.
+ expect(fetchError).toBeUndefined()
+ })
+
+ test('parses into a substantial, well-formed document', () => {
+ expect(blocks.length).toBeGreaterThan(40)
+ })
+
+ test('contains the expected structural block types', () => {
+ const present = new Set(blocks.map(b => b.type))
+ for (const type of ['h2', 'h3', 'p', 'li', 'quote', 'hr']) {
+ expect(present.has(type as Block['type'])).toBe(true)
+ }
+ })
+
+ test('no unparsed HTML tags leak into rendered text', () => {
+ // The strongest guard: a new/changed element the parser does not handle
+ // would surface as literal "" inside a text segment.
+ const leaking = blocks.filter(b => /<[a-zA-Z/][^>]*>/.test(textOf(b)))
+ expect(leaking.map(textOf)).toEqual([])
+ })
+
+ test('still links to the Privacy Policy', () => {
+ const hrefs = blocks
+ .flatMap(b => (b.type === 'hr' ? [] : b.segments))
+ .map(s => s.href)
+ .filter(Boolean)
+ expect(hrefs.some(h => h!.includes('minibits.cash/privacy'))).toBe(true)
+ })
+})
diff --git a/src/i18n_messages/en.json b/src/i18n_messages/en.json
index 68d62d5..e2397e0 100644
--- a/src/i18n_messages/en.json
+++ b/src/i18n_messages/en.json
@@ -734,27 +734,18 @@
"warning": "Warning",
"welcomeScreen_creatingKeys": "Creating wallet keys...",
"welcomeScreen_creatingProfile": "Creating wallet profile...",
- "welcomeScreen_generatingWalletSeedStatusMessage": "Creating wallet seed, this takes a while...",
"welcomeScreen_lastPageConfirmButton": "Got it, take me to the wallet",
- "welcomeScreen_lastPageRecoverLostWalletButton": "Recover lost wallet",
"welcomeScreen_offlineWarning": "Please make sure you are online to set up the new wallet.",
- "welcomeScreen_page1_bullet1": "Minibits follows the Cashu protocol, where mints back ecash with Bitcoin.",
- "welcomeScreen_page1_bullet2": "Ecash is issued or converted back to Bitcoin instantly via Lightning payments.",
- "welcomeScreen_page1_bullet3": "Ecash tokens are stored on-device; mints do not maintain ledgers or wallet balances.",
- "welcomeScreen_page1_final": "No Watson, there's no blockchain.",
- "welcomeScreen_page1_heading": "Welcome",
- "welcomeScreen_page1_intro": "Minibits is an ecash and Lightning wallet focused on ease of use and security. Ecash is a digitally signed token issued by custodians known as mints.",
- "welcomeScreen_page2_bullet1": "Minibits provides a free Lightning address that also allows instant ecash transfers over Nostr.",
- "welcomeScreen_page2_bullet2": "First-class support for sending and receiving zaps on Nostr, including Nostr Wallet Connect.",
- "welcomeScreen_page2_bullet3": "Realtime encrypted push notifications and true Tap to pay experience using NFC.",
- "welcomeScreen_page2_final": "Minibits is free, open-source software. Find us on GitHub for our roadmap and contributions.",
- "welcomeScreen_page2_heading": "Why Minibits?",
- "welcomeScreen_page2_intro": "Minibits aims to explore how ecash can help people use Bitcoin by offering frictionless onboarding, low fees, ease of use, and strong privacy.",
- "welcomeScreen_page3_bullet1": "Mints are custodial by design. Run your own or use them only for research and testing.",
- "welcomeScreen_page3_bullet2": "Ecash is stored on your device. If lost, ecash is lost unless you safely back up your seed phrase.",
- "welcomeScreen_page3_bullet3": "Minibits offers its own mint for testing with small amounts. It operates on a best-effort basis with no guarantees.",
- "welcomeScreen_page3_final": "Now, let's move some ecash!",
- "welcomeScreen_page3_go": "Let's go!",
- "welcomeScreen_page3_heading": "Don't forget",
- "welcomeScreen_page3_intro": "Both the Cashu protocol and the Minibits wallet are experimental. By using them, you accept known and unknown risks."
+ "welcomeScreen_hero_instant": "Instant.",
+ "welcomeScreen_hero_private": "Private.",
+ "welcomeScreen_hero_ecash": "Ecash.",
+ "welcomeScreen_hero_intro": "Minibits is a Bitcoin Lightning and ecash wallet that delivers instant, low-cost, and private value transfers — even when the payer is offline.",
+ "welcomeScreen_terms_title": "Terms & Conditions",
+ "welcomeScreen_terms_loading": "Loading Terms…",
+ "welcomeScreen_terms_error": "Couldn't load the Terms. Please check your connection and try again.",
+ "welcomeScreen_terms_retry": "Retry",
+ "welcomeScreen_terms_agreePrefix": "I have read and agree to the",
+ "welcomeScreen_terms_agreeTerms": "Terms",
+ "welcomeScreen_terms_agreeConjunction": "and",
+ "welcomeScreen_terms_agreePrivacy": "Privacy Policy"
}
diff --git a/src/i18n_messages/es.json b/src/i18n_messages/es.json
index abb43a1..b52e0a2 100644
--- a/src/i18n_messages/es.json
+++ b/src/i18n_messages/es.json
@@ -733,27 +733,18 @@
"warning": "Advertencia",
"welcomeScreen_creatingKeys": "Creando claves de billetera...",
"welcomeScreen_creatingProfile": "Creando perfil de billetera...",
- "welcomeScreen_generatingWalletSeedStatusMessage": "Creando la semilla de la billetera, esto lleva un tiempo...",
"welcomeScreen_lastPageConfirmButton": "Entendido, llévame a la billetera.",
- "welcomeScreen_lastPageRecoverLostWalletButton": "Recuperar billetera perdida",
"welcomeScreen_offlineWarning": "Asegúrese de estar en línea para configurar la nueva billetera.",
- "welcomeScreen_page1_bullet1": "Minibits sigue el protocolo Cashu, donde las monedas respaldan el efectivo con Bitcoin.",
- "welcomeScreen_page1_bullet2": "Ecash se emite o se convierte nuevamente a Bitcoin instantáneamente a través de pagos Lightning.",
- "welcomeScreen_page1_bullet3": "Los tokens de Ecash se almacenan en el dispositivo; las mints no mantienen registros ni saldos de billetera.",
- "welcomeScreen_page1_final": "Sin Watson, no hay blockchain.",
- "welcomeScreen_page1_heading": "Bienvenido",
- "welcomeScreen_page1_intro": "Minibits es una billetera de ecash y Lightning centrada en la facilidad de uso y la seguridad. Ecash es un token firmado digitalmente emitido por custodios conocidos como mints.",
- "welcomeScreen_page2_bullet1": "Minibits proporciona una dirección Lightning gratuita que también permite transferencias de dinero en efectivo instantáneas a través de Nostr.",
- "welcomeScreen_page2_bullet2": "Soporte de primera clase para enviar y recibir zaps en Nostr, incluido Nostr Wallet Connect.",
- "welcomeScreen_page2_bullet3": "Notificaciones push encriptadas en tiempo real y NFC soporte.",
- "welcomeScreen_page2_final": "Minibits es un software gratuito de código abierto. Encuéntranos en GitHub para consultar nuestra hoja de ruta y nuestras contribuciones.",
- "welcomeScreen_page2_heading": "¿Por qué Minibits?",
- "welcomeScreen_page2_intro": "Minibits tiene como objetivo explorar cómo el ecash puede ayudar a las personas a usar Bitcoin al ofrecer una incorporación sin fricciones, tarifas bajas, facilidad de uso y una sólida privacidad.",
- "welcomeScreen_page3_bullet1": "Las mints son de custodia por diseño. Administre las suyas o úselas solo para investigación y pruebas.",
- "welcomeScreen_page3_bullet2": "El ecash se almacena en tu dispositivo. Si lo pierdes, se perderá a menos que hagas una copia de seguridad de tu frase inicial.",
- "welcomeScreen_page3_bullet3": "Minibits ofrece su propia menta para realizar pruebas con pequeñas cantidades. Opera con el máximo esfuerzo, sin garantías.",
- "welcomeScreen_page3_final": "¡Ahora, movamos algo de dinero!",
- "welcomeScreen_page3_go": "¡Vamos!",
- "welcomeScreen_page3_heading": "No lo olvides",
- "welcomeScreen_page3_intro": "Tanto el protocolo Cashu como la billetera Minibits son experimentales. Al usarlos, acepta riesgos conocidos y desconocidos."
+ "welcomeScreen_hero_instant": "Instantáneo.",
+ "welcomeScreen_hero_private": "Privado.",
+ "welcomeScreen_hero_ecash": "Ecash.",
+ "welcomeScreen_hero_intro": "Minibits es una billetera de Bitcoin Lightning y ecash que ofrece transferencias de valor instantáneas, económicas y privadas, incluso cuando el pagador está sin conexión.",
+ "welcomeScreen_terms_title": "Términos y condiciones",
+ "welcomeScreen_terms_loading": "Cargando los términos…",
+ "welcomeScreen_terms_error": "No se pudieron cargar los términos. Comprueba tu conexión e inténtalo de nuevo.",
+ "welcomeScreen_terms_retry": "Reintentar",
+ "welcomeScreen_terms_agreePrefix": "He leído y acepto los",
+ "welcomeScreen_terms_agreeTerms": "Términos",
+ "welcomeScreen_terms_agreeConjunction": "y la",
+ "welcomeScreen_terms_agreePrivacy": "Política de Privacidad"
}
diff --git a/src/i18n_messages/pt.json b/src/i18n_messages/pt.json
index 96810ba..d46088d 100644
--- a/src/i18n_messages/pt.json
+++ b/src/i18n_messages/pt.json
@@ -734,27 +734,18 @@
"warning": "Aviso",
"welcomeScreen_creatingKeys": "Criando chaves da carteira...",
"welcomeScreen_creatingProfile": "Criando perfil da carteira...",
- "welcomeScreen_generatingWalletSeedStatusMessage": "Criando seed da carteira, isso leva um tempo...",
"welcomeScreen_lastPageConfirmButton": "Entendi, leve-me à carteira",
- "welcomeScreen_lastPageRecoverLostWalletButton": "Recuperar carteira perdida",
"welcomeScreen_offlineWarning": "Certifique-se de estar online para configurar nova carteira.",
- "welcomeScreen_page1_bullet1": "Minibits segue protocolo Cashu, onde mints lastreiam ecash com Bitcoin.",
- "welcomeScreen_page1_bullet2": "Ecash é emitido ou convertido para Bitcoin instantaneamente via pagamentos Lightning.",
- "welcomeScreen_page1_bullet3": "Tokens ecash são armazenados no dispositivo; mints não mantêm registros ou saldos.",
- "welcomeScreen_page1_final": "No Watson, não há blockchain.",
- "welcomeScreen_page1_heading": "Bem-vindo",
- "welcomeScreen_page1_intro": "Minibits é carteira ecash e Lightning focada em facilidade e segurança. Ecash é token assinado emitido por custodiantes chamados mints.",
- "welcomeScreen_page2_bullet1": "Minibits fornece endereço Lightning gratuito que permite transferências ecash via Nostr.",
- "welcomeScreen_page2_bullet2": "Suporte de primeira classe para enviar/receber zaps no Nostr, incluindo Nostr Wallet Connect.",
- "welcomeScreen_page2_bullet3": "Notificações push criptografadas em tempo real e NFC suporte.",
- "welcomeScreen_page2_final": "Minibits é software livre e de código aberto. Encontre-nos no GitHub para roteiro e contribuições.",
- "welcomeScreen_page2_heading": "Por que Minibits?",
- "welcomeScreen_page2_intro": "Minibits explora como ecash pode ajudar pessoas a usar Bitcoin oferecendo onboarding sem atrito, taxas baixas, facilidade e privacidade forte.",
- "welcomeScreen_page3_bullet1": "Mints são custodiantes por design. Execute o seu próprio ou use apenas para pesquisa/testes.",
- "welcomeScreen_page3_bullet2": "Ecash é armazenado no seu dispositivo. Se perdido, ecash é perdido a menos que você faça backup seguro da seed.",
- "welcomeScreen_page3_bullet3": "Minibits oferece seu próprio mint para testes com pequenas quantias. Opera em base de melhor esforço sem garantias.",
- "welcomeScreen_page3_final": "Agora, vamos movimentar ecash!",
- "welcomeScreen_page3_go": "Vamos!",
- "welcomeScreen_page3_heading": "Não esqueça",
- "welcomeScreen_page3_intro": "Tanto protocolo Cashu quanto carteira Minibits são experimentais. Ao usá-los, você aceita riscos conhecidos e desconhecidos."
+ "welcomeScreen_hero_instant": "Instantâneo.",
+ "welcomeScreen_hero_private": "Privado.",
+ "welcomeScreen_hero_ecash": "Ecash.",
+ "welcomeScreen_hero_intro": "Minibits é uma carteira Bitcoin Lightning e ecash que oferece transferências de valor instantâneas, de baixo custo e privadas — mesmo quando o pagador está offline.",
+ "welcomeScreen_terms_title": "Termos e Condições",
+ "welcomeScreen_terms_loading": "Carregando os termos…",
+ "welcomeScreen_terms_error": "Não foi possível carregar os termos. Verifique sua conexão e tente novamente.",
+ "welcomeScreen_terms_retry": "Tentar novamente",
+ "welcomeScreen_terms_agreePrefix": "Li e concordo com os",
+ "welcomeScreen_terms_agreeTerms": "Termos",
+ "welcomeScreen_terms_agreeConjunction": "e a",
+ "welcomeScreen_terms_agreePrivacy": "Política de Privacidade"
}
diff --git a/src/i18n_messages/sk.json b/src/i18n_messages/sk.json
index 8bcfd71..a342185 100644
--- a/src/i18n_messages/sk.json
+++ b/src/i18n_messages/sk.json
@@ -734,27 +734,18 @@
"warning": "Upozornenie",
"welcomeScreen_creatingKeys": "Vytváram kľúče peňaženky...",
"welcomeScreen_creatingProfile": "Vytváram profil peňaženky...",
- "welcomeScreen_generatingWalletSeedStatusMessage": "Vytváram seed kľúč peňaženky, môže to chvíľu trvať",
"welcomeScreen_lastPageConfirmButton": "Jasné, poďme do peňaženky",
- "welcomeScreen_lastPageRecoverLostWalletButton": "Obnov stratenú peňaženku",
"welcomeScreen_offlineWarning": "Uisti sa, že si online pre nastavenie novej peňaženky.",
- "welcomeScreen_page1_bullet1": "Minibity sa riadia protokolom Cashu, kde mince vymieňajú e-hotovosť za Bitcoin.",
- "welcomeScreen_page1_bullet2": "Elektronické peniaze sa vydávajú alebo konvertujú späť na Bitcoin okamžite prostredníctvom platieb Lightning.",
- "welcomeScreen_page1_bullet3": "Tokeny Ecash sú uložené v zariadení; mincovne neuchovávajú účtovné knihy ani zostatky v peňaženkách.",
- "welcomeScreen_page1_final": "Nie, Watson, neexistuje žiadny blockchain.",
- "welcomeScreen_page1_heading": "Vitajte",
- "welcomeScreen_page1_intro": "Minibits je peňaženka pre elektronické peniaze a Lightning, ktorá sa zameriava na jednoduché používanie a bezpečnosť. Ecash je digitálne podpísaný token vydaný správcami známymi ako mints.",
- "welcomeScreen_page2_bullet1": "Minibits poskytuje bezplatnú Lightning adresu, ktorá tiež umožňuje okamžité prevody hotovosti cez Nostr.",
- "welcomeScreen_page2_bullet2": "Prvotriedna podpora pre odosielanie a prijímanie zapov na Nostre, vrátane Nostr Wallet Connect.",
- "welcomeScreen_page2_bullet3": "Šifrované push notifikácie a podpora platieb cez NFC.",
- "welcomeScreen_page2_final": "Minibits je bezplatný softvér s otvoreným zdrojovým kódom. Náš plán a príspevky nájdete na GitHub.",
- "welcomeScreen_page2_heading": "Prečo Minibity?",
- "welcomeScreen_page2_intro": "Cieľom Minibits je preskúmať, ako môžu elektronické peniaze pomôcť ľuďom používať Bitcoin tým, že ponúkajú bezproblémový onboarding, nízke poplatky, jednoduché používanie a silné súkromie.",
- "welcomeScreen_page3_bullet1": "Mätové cukríky sú zámerne určené na uchovávanie. Vyrábajte si vlastné cukríky alebo ich používajte iba na výskum a testovanie.",
- "welcomeScreen_page3_bullet2": "Elektronické peniaze sú uložené vo vašom zariadení. Ak sa stratia, elektronické peniaze sa stratia, pokiaľ si bezpečne nezálohujete svoju počiatočnú frázu.",
- "welcomeScreen_page3_bullet3": "Minibits ponúka vlastnú mincovňu na testovanie s malými sumami. Funguje na princípe „najlepšie možné úsilie“ bez akýchkoľvek záruk.",
- "welcomeScreen_page3_final": "Teraz, poďme presunúť nejaké peniaze v hotovosti!",
- "welcomeScreen_page3_go": "Poďme!",
- "welcomeScreen_page3_heading": "Nezabudnite",
- "welcomeScreen_page3_intro": "Protokol Cashu aj peňaženka Minibits sú experimentálne. Ich používaním akceptujete známe aj neznáme riziká."
+ "welcomeScreen_hero_instant": "Okamžitý.",
+ "welcomeScreen_hero_private": "Súkromný.",
+ "welcomeScreen_hero_ecash": "Ecash.",
+ "welcomeScreen_hero_intro": "Minibits je Bitcoin Lightning a ecash peňaženka, ktorá umožňuje okamžité, lacné a súkromné platby — dokonca aj keď je platiteľ offline.",
+ "welcomeScreen_terms_title": "Podmienky používania",
+ "welcomeScreen_terms_loading": "Načítavajú sa podmienky…",
+ "welcomeScreen_terms_error": "Podmienky sa nepodarilo načítať. Skontroluj pripojenie a skús to znova.",
+ "welcomeScreen_terms_retry": "Skúsiť znova",
+ "welcomeScreen_terms_agreePrefix": "Prečítal som si a súhlasím s",
+ "welcomeScreen_terms_agreeTerms": "Podmienkami",
+ "welcomeScreen_terms_agreeConjunction": "a",
+ "welcomeScreen_terms_agreePrivacy": "Zásadami ochrany osobných údajov"
}
diff --git a/src/screens/WelcomeScreen.tsx b/src/screens/WelcomeScreen.tsx
index fde3efc..914418c 100644
--- a/src/screens/WelcomeScreen.tsx
+++ b/src/screens/WelcomeScreen.tsx
@@ -1,83 +1,50 @@
-// import { observer } from "mobx-react-lite"
-import React, {FC, useLayoutEffect, useRef, useState} from 'react'
+import React, {useCallback, useEffect, useLayoutEffect, useRef, useState} from 'react'
import {
TextStyle,
View,
ViewStyle,
- FlatList,
Animated,
ScrollView,
Linking,
- Pressable,
+ ActivityIndicator,
} from 'react-native'
import PagerView, { PagerViewOnPageScrollEventData } from 'react-native-pager-view'
-import {
- ScalingDot,
- SlidingBorder,
- } from 'react-native-animated-pagination-dots'
-// import { isRTL } from "../i18n"
+import { ScalingDot } from 'react-native-animated-pagination-dots'
import {useStores} from '../models'
-import {spacing, colors, useThemeColor} from '../theme'
+import {spacing, colors, useThemeColor, typography} from '../theme'
import {
Button,
ErrorModal,
- Icon,
InfoModal,
Loading,
Screen,
Text,
+ Toggle,
} from '../components'
-import {TxKeyPath, translate} from '../i18n'
+import {translate} from '../i18n'
import AppError from '../utils/AppError'
import { MINIBITS_MINT_URL } from '@env'
import useIsInternetReachable from '../utils/useIsInternetReachable'
-import { KeyChain, log, WalletKeys } from '../services'
+import { KeyChain, log } from '../services'
import { delay } from '../utils/utils'
+import { htmlToBlocks, Block, InlineSegment } from '../utils/htmlToBlocks'
import { StaticScreenProps, useNavigation } from '@react-navigation/native'
const AnimatedPagerView = Animated.createAnimatedComponent(PagerView)
-const PAGES = [
- {
- key: 1,
- heading: 'welcomeScreen_page1_heading',
- intro: 'welcomeScreen_page1_intro',
- bullets: [
- {id: '1', tx: 'welcomeScreen_page1_bullet1'},
- {id: '2', tx: 'welcomeScreen_page1_bullet2'},
- {id: '3', tx: 'welcomeScreen_page1_bullet3'},
- ],
- final: 'welcomeScreen_page1_final'
- },
- {
- key: 2,
- heading: 'welcomeScreen_page2_heading',
- intro: 'welcomeScreen_page2_intro',
- bullets: [
- {id: '1', tx: 'welcomeScreen_page2_bullet1'},
- {id: '2', tx: 'welcomeScreen_page2_bullet2'},
- {id: '3', tx: 'welcomeScreen_page2_bullet3'},
- ],
- final: 'welcomeScreen_page2_final'
- },
- {
- key: 3,
- heading: 'welcomeScreen_page3_heading',
- intro: 'welcomeScreen_page3_intro',
- bullets: [
- {id: '1', tx: 'welcomeScreen_page3_bullet1'},
- {id: '2', tx: 'welcomeScreen_page3_bullet2'},
- {id: '3', tx: 'welcomeScreen_page3_bullet3'},
- ],
- final: 'welcomeScreen_page3_final'
- }
-]
+const TERMS_URL = 'https://minibits.cash/terms'
+const PRIVACY_URL = 'https://minibits.cash/privacy'
+const TERMS_FETCH_TIMEOUT = 5000
+
+// Two-dot page indicator (hero + terms).
+const PAGE_INDICATORS = [{ key: 1 }, { key: 2 }]
type Props = StaticScreenProps
export const WelcomeScreen = function ({ route }: Props) {
const navigation = useNavigation()
const headerBg = useThemeColor('header')
+ const bgColor = useThemeColor('background')
useLayoutEffect(() => {
navigation.setOptions({ headerShown: false })
@@ -85,10 +52,10 @@ export const WelcomeScreen = function ({ route }: Props) {
const {
authStore,
- userSettingsStore,
- relaysStore,
+ userSettingsStore,
+ relaysStore,
walletProfileStore,
- walletStore,
+ walletStore,
mintsStore
} = useStores()
@@ -98,17 +65,20 @@ export const WelcomeScreen = function ({ route }: Props) {
const [isLoading, setIsLoading] = useState(false)
const [statusMessage, setStatusMessage] = useState('')
const [info, setInfo] = useState('')
-
+ const [hasAgreed, setHasAgreed] = useState(false)
+
const gotoWallet = async function () {
try {
- if(!isInternetReachable) {
+ if(!hasAgreed) { return }
+
+ if(!isInternetReachable) {
setInfo(translate('welcomeScreen_offlineWarning'))
return
- }
-
+ }
+
setIsLoading(true)
setStatusMessage(translate('welcomeScreen_creatingKeys'))
-
+
// check if keys already exist (if onboarding is repeated or if iOS did not wipe keys?)
let keys = await KeyChain.getWalletKeys()
@@ -117,12 +87,12 @@ export const WelcomeScreen = function ({ route }: Props) {
// save keys after successful profile creation
await KeyChain.saveWalletKeys(newKeys)
- walletStore.cleanCachedWalletKeys()
+ walletStore.cleanCachedWalletKeys()
keys = newKeys
- }
+ }
+
+ setStatusMessage(translate('welcomeScreen_creatingProfile'))
- setStatusMessage(translate('welcomeScreen_creatingProfile'))
-
// First, enroll device for JWT authentication then create profile
try {
await authStore.logout()
@@ -132,49 +102,49 @@ export const WelcomeScreen = function ({ route }: Props) {
keys.NOSTR,
walletProfileStore.device
)
-
+
// idempotent if keys and profile exists on the server
- await walletProfileStore.create(
- keys.walletId,
+ await walletProfileStore.create(
+ keys.walletId,
keys.SEED.seedHash
)
-
+
if(!mintsStore.mintExists(MINIBITS_MINT_URL)) {
- await mintsStore.addMint(MINIBITS_MINT_URL)
+ await mintsStore.addMint(MINIBITS_MINT_URL)
}
-
+
relaysStore.addDefaultRelays()
userSettingsStore.setIsOnboarded(true)
navigation.navigate('Tabs')
-
+
await delay(1000)
setStatusMessage('')
- setIsLoading(false)
+ setIsLoading(false)
} catch (e: any) {
handleError(e)
- }
+ }
}
- const handleError = function (e: AppError) {
- setIsLoading(false)
+ const handleError = function (e: AppError) {
+ setIsLoading(false)
setError(e)
}
-
+ // Pager scroll animation wiring for the dot indicator.
const width = spacing.screenWidth
- const ref = useRef(null);
- const scrollOffsetAnimatedValue = React.useRef(new Animated.Value(0)).current;
- const positionAnimatedValue = React.useRef(new Animated.Value(0)).current;
- const inputRange = [0, PAGES.length];
+ const ref = useRef(null)
+ const scrollOffsetAnimatedValue = useRef(new Animated.Value(0)).current
+ const positionAnimatedValue = useRef(new Animated.Value(0)).current
+ const inputRange = [0, PAGE_INDICATORS.length]
const scrollX = Animated.add(
scrollOffsetAnimatedValue,
positionAnimatedValue
).interpolate({
inputRange,
- outputRange: [0, PAGES.length * width],
+ outputRange: [0, PAGE_INDICATORS.length * width],
})
-
+
const onPageScroll = React.useMemo(
() =>
Animated.event(
@@ -190,99 +160,47 @@ export const WelcomeScreen = function ({ route }: Props) {
useNativeDriver: false,
}
),
-
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
- )
-
- const renderBullet = ({item}: {item: {id: string; tx: string}}) => (
-
-
-
-
-
-
)
-
return (
-
+
- {PAGES.map((page) => (
-
-
-
-
-
- item.id}
- contentContainerStyle={{paddingRight: spacing.small}}
- style={{ flexGrow: 0 }}
- />
-
-
-
- {(page.key === PAGES.length) && (
-
-
-
-
- Linking.openURL('https://minibits.cash/terms')}>
-
-
-
-
- )}
-
- ))}
-
-
-
-
+
+
+
+
+
+
+
{error && }
{info && }
@@ -291,70 +209,400 @@ export const WelcomeScreen = function ({ route }: Props) {
)
}
-const $dotsContainer: ViewStyle ={
- height: 50,
- justifyContent: 'space-evenly',
- marginBottom: 50
+
+/* ----------------------------- Page 1: Hero ----------------------------- */
+
+const HeroPage = function () {
+ // Body copy adapts to the theme: dark on light theme, light on dark themes.
+ const textColor = useThemeColor('text')
+
+ return (
+
+
+
+
+ {' '}
+
+
+
+
+
+
+ )
}
-const $dotContainer: ViewStyle ={
- justifyContent: 'center',
- alignSelf: 'center',
+
+/* ------------------------ Page 2: Terms & consent ----------------------- */
+
+type TermsPageProps = {
+ hasAgreed: boolean
+ onAgreeChange: (value: boolean) => void
+ onEnter: () => void
}
+const TermsPage = function ({ hasAgreed, onAgreeChange, onEnter }: TermsPageProps) {
+ // Copy on the themed background adapts: dark on light theme, light on dark themes.
+ const textColor = useThemeColor('text')
+
+ return (
+
+
+
+
+
+
+
+
+ {translate('welcomeScreen_terms_agreePrefix')}{' '}
+ Linking.openURL(TERMS_URL)}
+ text={translate('welcomeScreen_terms_agreeTerms')}
+ />
+ {' '}{translate('welcomeScreen_terms_agreeConjunction')}{' '}
+ Linking.openURL(PRIVACY_URL)}
+ text={translate('welcomeScreen_terms_agreePrivacy')}
+ />.
+
+
+
+
+ )
+}
+
+
+/**
+ * Fetches the Minibits Terms page and renders its content as native text.
+ * Loaded lazily on mount; scrolls independently inside the pager.
+ */
+const TermsContent = function () {
+ const cardBg = useThemeColor('card')
+ const textColor = useThemeColor('text')
+ const separatorColor = useThemeColor('separator')
+ const tintColor = useThemeColor('tint')
+
+ const [state, setState] = useState<'loading' | 'ready' | 'error'>('loading')
+ const [blocks, setBlocks] = useState([])
+
+ const load = useCallback(async () => {
+ setState('loading')
+ const controller = new AbortController()
+ const timeout = setTimeout(() => controller.abort(), TERMS_FETCH_TIMEOUT)
+ try {
+ const response = await fetch(TERMS_URL, { signal: controller.signal as any })
+ const html = await response.text()
+ const parsed = htmlToBlocks(html)
+ if (parsed.length === 0) { throw new Error('No terms content parsed') }
+ setBlocks(parsed)
+ setState('ready')
+ } catch (e: any) {
+ log.warn('[WelcomeScreen.TermsContent] Failed to load terms', { error: e.message })
+ setState('error')
+ } finally {
+ clearTimeout(timeout)
+ }
+ }, [])
+
+ useEffect(() => { load() }, [load])
+
+ const renderSegments = (segments: InlineSegment[]) =>
+ segments.map((segment, index) => (
+ Linking.openURL(segment.href!) : undefined}
+ style={[
+ segment.bold && {fontFamily: typography.primary?.medium},
+ segment.italic && {fontStyle: 'italic'},
+ segment.href && {color: colors.palette.primary400, textDecorationLine: 'underline'},
+ ]}
+ />
+ ))
+
+ const renderBlock = (block: Block, index: number) => {
+ switch (block.type) {
+ // The page-level H1 duplicates our screen title, so skip it.
+ case 'h1':
+ return null
+ case 'h2':
+ return (
+ s.text).join('')}
+ />
+ )
+ case 'h3':
+ return (
+ s.text).join('')}
+ />
+ )
+ case 'p':
+ return (
+
+ {renderSegments(block.segments)}
+
+ )
+ case 'li':
+ return (
+
+
+
+ {renderSegments(block.segments)}
+
+
+ )
+ case 'quote':
+ return (
+
+
+ {renderSegments(block.segments)}
+
+
+ )
+ case 'hr':
+ return
+ default:
+ return null
+ }
+ }
+
+ return (
+
+ {state === 'loading' && (
+
+
+
+
+ )}
+ {state === 'error' && (
+
+
+
+
+ )}
+ {state === 'ready' && (
+
+ {blocks.map(renderBlock)}
+
+ )}
+
+ )
+}
+
+
+/* -------------------------------- Styles -------------------------------- */
+
const $container: ViewStyle = {
- // alignItems: 'center',
flex: 1,
- paddingHorizontal: spacing.medium,
+ paddingHorizontal: spacing.medium,
}
-const $listContainer: ViewStyle = {
- maxHeight: spacing.screenHeight * 0.38,
- alignSelf: 'stretch',
- paddingRight: spacing.medium,
+const $pager: ViewStyle = {
+ flex: 1,
+ marginTop: spacing.large,
}
-const $listItem: ViewStyle = {
- flexDirection: 'row',
- paddingBottom: spacing.extraSmall,
- paddingRight: spacing.extraSmall,
+const $page: ViewStyle = {
+ flex: 1,
}
-const $itemIcon: ViewStyle = {
- flexDirection: 'row',
+const $dotsContainer: ViewStyle = {
+ height: 30,
+ justifyContent: 'center',
+ alignItems: 'center',
+ marginVertical: spacing.small,
+}
+
+/* Hero */
+
+const $heroScroll: ViewStyle = {
+ flexGrow: 1,
+ justifyContent: 'center',
+ paddingBottom: spacing.large,
+}
+
+const $heroHeading: TextStyle = {
+ fontFamily: typography.logo?.normal,
+ fontSize: 42,
+ lineHeight: 50,
+ color: colors.palette.neutral100,
+}
+
+const $heroIntro: TextStyle = {
+ marginTop: spacing.large,
+ fontSize: 18,
+ lineHeight: 27,
+}
+
+/* Terms page */
+
+const $termsPage: ViewStyle = {
+ flex: 1,
+ paddingBottom: spacing.small,
+}
+
+const $termsTitle: TextStyle = {
+ alignSelf: 'center',
marginBottom: spacing.small,
}
-const $buttonContainer: ViewStyle = {
- alignSelf: 'center',
- marginTop: spacing.large,
-
+const $termsBox: ViewStyle = {
+ flex: 1,
+ borderRadius: spacing.small,
+ overflow: 'hidden',
}
-
-const $welcomeHeading: TextStyle = {
- marginBottom: spacing.medium,
- color: 'white',
- alignSelf: 'center',
+const $termsInner: ViewStyle = {
+ flex: 1,
}
-const $welcomeIntro: TextStyle = {
- marginBottom: spacing.large,
- color: 'white',
+const $termsScrollContent: ViewStyle = {
+ padding: spacing.medium,
}
-const $welcomeFinal: TextStyle = {
- marginTop: spacing.large,
- color: 'white',
+const $termsCentered: ViewStyle = {
+ flex: 1,
+ justifyContent: 'center',
+ alignItems: 'center',
+ padding: spacing.large,
}
-const $tc: TextStyle = {
+const $termsStatusText: TextStyle = {
+ marginTop: spacing.small,
+ textAlign: 'center',
+}
+
+const $termsH2: TextStyle = {
+ fontSize: 18,
+ lineHeight: 26,
+ marginTop: spacing.large,
+ marginBottom: spacing.extraSmall,
+ paddingTop: spacing.medium,
+ borderTopWidth: 1,
+}
+
+const $termsH3: TextStyle = {
+ fontSize: 16,
+ lineHeight: 24,
marginTop: spacing.medium,
- color: 'white',
+ marginBottom: spacing.tiny,
}
-const $tcLink: TextStyle = {
+const $termsParagraph: TextStyle = {
+ fontSize: 14,
+ lineHeight: 21,
+ marginTop: spacing.small,
+}
+
+const $termsListItem: ViewStyle = {
+ flexDirection: 'row',
+ marginTop: spacing.extraSmall,
+ paddingRight: spacing.small,
+}
+
+const $termsBullet: TextStyle = {
+ fontSize: 14,
+ lineHeight: 21,
+ marginRight: spacing.small,
+}
+
+const $termsListText: TextStyle = {
+ flex: 1,
+ fontSize: 14,
+ lineHeight: 21,
+}
+
+const $termsQuote: ViewStyle = {
marginTop: spacing.medium,
- color: 'white',
+ paddingVertical: spacing.small,
+ paddingHorizontal: spacing.medium,
+ borderLeftWidth: 3,
+ borderRadius: spacing.tiny,
+}
+
+const $termsRule: ViewStyle = {
+ height: 1,
+ marginTop: spacing.medium,
+}
+
+/* Consent */
+
+const $agreeRow: ViewStyle = {
+ flexDirection: 'row',
+ alignItems: 'center',
+ marginTop: spacing.medium,
+}
+
+const $agreeText: TextStyle = {
+ flex: 1,
+ fontSize: 14,
+ lineHeight: 21,
+}
+
+const $agreeLink: TextStyle = {
+ fontFamily: typography.primary?.medium,
textDecorationLine: 'underline',
}
+
+const $enterButton: ViewStyle = {
+ marginTop: spacing.medium,
+ alignSelf: 'center',
+}
diff --git a/src/utils/htmlToBlocks.ts b/src/utils/htmlToBlocks.ts
new file mode 100644
index 0000000..3d3521d
--- /dev/null
+++ b/src/utils/htmlToBlocks.ts
@@ -0,0 +1,123 @@
+/**
+ * Minimal, dependency-free HTML → block model converter.
+ *
+ * Purpose-built for the semantic, attribute-free markup produced by the
+ * minibits.cash Terms & Privacy pages (h1-h3, p, ul/li, blockquote, hr with
+ * inline strong/em/a). It is intentionally NOT a general-purpose HTML parser —
+ * it only understands the subset of tags those pages emit.
+ */
+
+export type InlineSegment = {
+ text: string
+ bold?: boolean
+ italic?: boolean
+ href?: string
+}
+
+export type Block =
+ | { type: 'h1' | 'h2' | 'h3' | 'p' | 'li' | 'quote'; segments: InlineSegment[] }
+ | { type: 'hr' }
+
+const ENTITIES: Record = {
+ '"': '"',
+ ''': "'",
+ ''': "'",
+ '&': '&',
+ '<': '<',
+ '>': '>',
+ ' ': ' ',
+ '—': '—',
+ '–': '–',
+ '©': '©',
+}
+
+function decodeEntities(input: string): string {
+ return input
+ .replace(/"|'|'|&|<|>| |—|–|©/g, m => ENTITIES[m] ?? m)
+ .replace(/(\d+);/g, (_, code) => String.fromCharCode(Number(code)))
+ .replace(/([0-9a-fA-F]+);/g, (_, code) => String.fromCharCode(parseInt(code, 16)))
+}
+
+/**
+ * Parses the inner HTML of a single block into inline segments, resolving
+ * nested strong/em/a formatting.
+ */
+function parseInline(html: string): InlineSegment[] {
+ const segments: InlineSegment[] = []
+ const re = /<(strong|b|em|i|a)\b([^>]*)>([\s\S]*?)<\/\1>|([^<]+)/g
+ let match: RegExpExecArray | null
+
+ while ((match = re.exec(html)) !== null) {
+ if (match[4] !== undefined) {
+ const text = decodeEntities(match[4]).replace(/\s+/g, ' ')
+ if (text.trim().length > 0 || text === ' ') segments.push({ text })
+ continue
+ }
+
+ const tag = match[1]
+ const attrs = match[2]
+ const inner = match[3]
+ const href = tag === 'a' ? attrs.match(/href="([^"]*)"/)?.[1] : undefined
+
+ for (const child of parseInline(inner)) {
+ segments.push({
+ text: child.text,
+ bold: child.bold || tag === 'strong' || tag === 'b',
+ italic: child.italic || tag === 'em' || tag === 'i',
+ href: child.href || href,
+ })
+ }
+ }
+
+ return segments
+}
+
+/** Extracts the inner HTML of the first element (the content region). */
+export function extractArticle(html: string): string {
+ const start = html.indexOf('', start)
+ const end = html.indexOf(' ', open)
+ if (open < 0 || end < 0) return ''
+ return html.slice(open + 1, end)
+}
+
+/**
+ * Converts the Terms/Privacy page HTML into an ordered list of renderable
+ * blocks. Accepts either a full page or an already-extracted article fragment.
+ */
+export function htmlToBlocks(html: string): Block[] {
+ const article = html.includes('/g, '')
+
+ const blocks: Block[] = []
+ const re = /<(h1|h2|h3|p)>([\s\S]*?)<\/\1>|([\s\S]*?)<\/ul>|([\s\S]*?)<\/blockquote>| /g
+ let match: RegExpExecArray | null
+
+ while ((match = re.exec(cleaned)) !== null) {
+ if (match[1]) {
+ const type = match[1] as 'h1' | 'h2' | 'h3' | 'p'
+ const segments = parseInline(match[2])
+ if (segments.length > 0) blocks.push({ type, segments })
+ } else if (match[3] !== undefined) {
+ const liRe = /([\s\S]*?)<\/li>/g
+ let li: RegExpExecArray | null
+ while ((li = liRe.exec(match[3])) !== null) {
+ blocks.push({ type: 'li', segments: parseInline(li[1]) })
+ }
+ } else if (match[4] !== undefined) {
+ const pRe = /([\s\S]*?)<\/p>/g
+ let p: RegExpExecArray | null
+ let matchedParagraph = false
+ while ((p = pRe.exec(match[4])) !== null) {
+ blocks.push({ type: 'quote', segments: parseInline(p[1]) })
+ matchedParagraph = true
+ }
+ if (!matchedParagraph) blocks.push({ type: 'quote', segments: parseInline(match[4]) })
+ } else {
+ blocks.push({ type: 'hr' })
+ }
+ }
+
+ return blocks
+}