New welcome screen

This commit is contained in:
minibits-cash
2026-07-08 15:44:11 +02:00
parent ddbd1a6848
commit 45cef1c60a
7 changed files with 762 additions and 280 deletions
+147
View File
@@ -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 = `<header>ignored</header><main><article class="prose-minibits">
<h1>Terms and Conditions</h1>
<p><strong>Bitango</strong> &amp; friends said &quot;hello&quot; &#x27;again&#x27;</p>
<hr/>
<h2>Part 0 — General Terms</h2>
<h3>0.1 About</h3>
<p>Read the <a href="https://minibits.cash/privacy">Privacy Policy</a> and the <em>fine print</em>.</p>
<ul>
<li>First <em>item</em></li>
<li>Second item</li>
</ul>
<blockquote>
<p><strong>IMPORTANT</strong></p>
<p>Read carefully.</p>
</blockquote>
</article></main><footer>ignored</footer>`
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('<h1>Terms and Conditions</h1>')
expect(inner).not.toContain('<header>')
expect(inner).not.toContain('<footer>')
})
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 <strong> 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 <em> 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 <ul> into individual <li> blocks', () => {
expect(textOf(blocks[6])).toBe('First item')
expect(textOf(blocks[7])).toBe('Second item')
})
test('expands <blockquote> 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 <article> wrapper)', () => {
const fragment = htmlToBlocks('<p>Hello</p><h3>World</h3>')
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('<article')
blocks = htmlToBlocks(html)
} catch (e: any) {
fetchError = e
}
})
test('the live page is reachable', () => {
// 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 "<tag>" 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)
})
})
+12 -21
View File
@@ -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"
}
+12 -21
View File
@@ -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"
}
+12 -21
View File
@@ -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"
}
+12 -21
View File
@@ -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"
}
+444 -196
View File
@@ -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<undefined>
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<boolean>(false)
const [statusMessage, setStatusMessage] = useState<string>('')
const [info, setInfo] = useState<string>('')
const [hasAgreed, setHasAgreed] = useState<boolean>(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<PagerView>(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<PagerView>(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<PagerViewOnPageScrollEventData>(
@@ -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}}) => (
<View style={$listItem}>
<View style={$itemIcon}>
<Icon
icon="faCheckCircle"
size={spacing.large}
color={colors.palette.primary200}
/>
</View>
<Text
tx={item.tx as TxKeyPath}
style={{flex: 1, paddingHorizontal: spacing.small, color: 'white'}}
preset="default"
/>
</View>
)
return (
<Screen contentContainerStyle={$container} preset="fixed" style={{backgroundColor: headerBg}}>
<Screen
contentContainerStyle={$container}
preset="fixed"
style={{backgroundColor: bgColor}}
safeAreaEdges={['top', 'bottom']}
>
<AnimatedPagerView
testID="pager-view"
initialPage={0}
ref={ref}
style={{flex: 1, marginTop: 80}}
// onPageSelected={onPageSelected}
style={$pager}
onPageScroll={onPageScroll}
>
{PAGES.map((page) => (
<View key={page.key}>
<View>
<Text
tx={page.heading as TxKeyPath}
preset="subheading"
style={$welcomeHeading}
/>
<Text
tx={page.intro as TxKeyPath}
preset="default"
style={$welcomeIntro}
/>
<View style={$listContainer}>
<FlatList
data={page.bullets}
renderItem={renderBullet}
keyExtractor={item => item.id}
contentContainerStyle={{paddingRight: spacing.small}}
style={{ flexGrow: 0 }}
/>
</View>
<Text
tx={page.final as TxKeyPath}
preset="default"
style={$welcomeFinal}
/>
</View>
{(page.key === PAGES.length) && (
<ScrollView style={$buttonContainer}>
<Button
onPress={gotoWallet}
preset='secondary'
tx="welcomeScreen_lastPageConfirmButton"
/>
<View style={{flexDirection: 'row', flexWrap: 'wrap', justifyContent: 'center'}}>
<Text size='xs' preset='formHelper' style={$tc} text={'By continuing you agree with the full '} />
<Pressable onPress={() => Linking.openURL('https://minibits.cash/terms')}>
<Text size='xs' preset='formHelper' style={$tcLink} text={'Minibits Terms'} />
</Pressable>
</View>
</ScrollView>
)}
</View>
))}
</AnimatedPagerView>
<View style={$dotsContainer}>
<View style={$dotContainer}>
<ScalingDot
testID={'sliding-border'}
data={PAGES}
inActiveDotColor={colors.palette.primary300}
activeDotColor={colors.palette.primary100}
activeDotScale={1}
containerStyle={{bottom: undefined, position: undefined, marginTop: -spacing.small, paddingBottom: spacing.medium}}
//@ts-ignore
scrollX={scrollX}
dotSize={30}
<View key="hero" style={$page}>
<HeroPage />
</View>
<View key="terms" style={$page}>
<TermsPage
hasAgreed={hasAgreed}
onAgreeChange={setHasAgreed}
onEnter={gotoWallet}
/>
</View>
</AnimatedPagerView>
<View style={$dotsContainer}>
<ScalingDot
testID={'scaling-dot'}
data={PAGE_INDICATORS}
inActiveDotColor={colors.palette.primary300}
activeDotColor={colors.palette.primary100}
activeDotScale={1}
containerStyle={{bottom: undefined, position: undefined}}
//@ts-ignore
scrollX={scrollX}
dotSize={12}
/>
</View>
{error && <ErrorModal error={error} />}
{info && <InfoModal message={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 (
<ScrollView
style={{flex: 1}}
contentContainerStyle={$heroScroll}
showsVerticalScrollIndicator={false}
>
<View>
<Text style={$heroHeading}>
<Text
tx="welcomeScreen_hero_instant"
style={[$heroHeading, {color: textColor}]}
/>
{' '}
<Text
tx="welcomeScreen_hero_private"
style={[$heroHeading, {color: colors.palette.primary400}]}
/>
</Text>
<Text
tx="welcomeScreen_hero_ecash"
style={[$heroHeading, {color: colors.palette.green400}]}
/>
<Text
tx="welcomeScreen_hero_intro"
style={[$heroIntro, {color: textColor}]}
/>
</View>
</ScrollView>
)
}
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 (
<View style={$termsPage}>
<Text
tx="welcomeScreen_terms_title"
preset="subheading"
style={[$termsTitle, {color: textColor}]}
/>
<View style={$termsBox}>
<TermsContent />
</View>
<View style={$agreeRow}>
<Toggle
variant="checkbox"
value={hasAgreed}
onValueChange={onAgreeChange}
containerStyle={{marginRight: spacing.small}}
/>
<Text style={[$agreeText, {color: textColor}]}>
{translate('welcomeScreen_terms_agreePrefix')}{' '}
<Text
style={[$agreeLink, {color: textColor}]}
onPress={() => Linking.openURL(TERMS_URL)}
text={translate('welcomeScreen_terms_agreeTerms')}
/>
{' '}{translate('welcomeScreen_terms_agreeConjunction')}{' '}
<Text
style={[$agreeLink, {color: textColor}]}
onPress={() => Linking.openURL(PRIVACY_URL)}
text={translate('welcomeScreen_terms_agreePrivacy')}
/>.
</Text>
</View>
<Button
onPress={onEnter}
//preset="secondary"
tx="welcomeScreen_lastPageConfirmButton"
disabled={!hasAgreed}
style={[$enterButton, !hasAgreed && {opacity: 0.5}]}
/>
</View>
)
}
/**
* 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<Block[]>([])
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) => (
<Text
key={index}
text={segment.text}
onPress={segment.href ? () => 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 (
<Text
key={index}
preset="bold"
style={[$termsH2, {color: textColor, borderTopColor: separatorColor}]}
text={block.segments.map(s => s.text).join('')}
/>
)
case 'h3':
return (
<Text
key={index}
preset="bold"
style={[$termsH3, {color: textColor}]}
text={block.segments.map(s => s.text).join('')}
/>
)
case 'p':
return (
<Text key={index} style={[$termsParagraph, {color: textColor}]}>
{renderSegments(block.segments)}
</Text>
)
case 'li':
return (
<View key={index} style={$termsListItem}>
<Text style={[$termsBullet, {color: textColor}]} text={'•'} />
<Text style={[$termsListText, {color: textColor}]}>
{renderSegments(block.segments)}
</Text>
</View>
)
case 'quote':
return (
<View
key={index}
style={[$termsQuote, {borderLeftColor: tintColor, backgroundColor: separatorColor}]}
>
<Text style={[$termsParagraph, {color: textColor, marginTop: 0}]}>
{renderSegments(block.segments)}
</Text>
</View>
)
case 'hr':
return <View key={index} style={[$termsRule, {backgroundColor: separatorColor}]} />
default:
return null
}
}
return (
<View style={[$termsInner, {backgroundColor: cardBg}]}>
{state === 'loading' && (
<View style={$termsCentered}>
<ActivityIndicator color={tintColor} />
<Text
tx="welcomeScreen_terms_loading"
style={[$termsStatusText, {color: textColor}]}
/>
</View>
)}
{state === 'error' && (
<View style={$termsCentered}>
<Text
tx="welcomeScreen_terms_error"
style={[$termsStatusText, {color: textColor}]}
/>
<Button
preset="tertiary"
onPress={load}
tx="welcomeScreen_terms_retry"
style={{marginTop: spacing.small}}
/>
</View>
)}
{state === 'ready' && (
<ScrollView
style={{flex: 1}}
contentContainerStyle={$termsScrollContent}
nestedScrollEnabled={true}
showsVerticalScrollIndicator={true}
>
{blocks.map(renderBlock)}
</ScrollView>
)}
</View>
)
}
/* -------------------------------- 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',
}
+123
View File
@@ -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<string, string> = {
'&quot;': '"',
'&#x27;': "'",
'&#39;': "'",
'&amp;': '&',
'&lt;': '<',
'&gt;': '>',
'&nbsp;': ' ',
'&mdash;': '—',
'&ndash;': '',
'&copy;': '©',
}
function decodeEntities(input: string): string {
return input
.replace(/&quot;|&#x27;|&#39;|&amp;|&lt;|&gt;|&nbsp;|&mdash;|&ndash;|&copy;/g, m => ENTITIES[m] ?? m)
.replace(/&#(\d+);/g, (_, code) => String.fromCharCode(Number(code)))
.replace(/&#x([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 <article> element (the content region). */
export function extractArticle(html: string): string {
const start = html.indexOf('<article')
if (start < 0) return ''
const open = html.indexOf('>', start)
const end = html.indexOf('</article>', 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('<article') ? extractArticle(html) : html
const cleaned = article.replace(/<!--[\s\S]*?-->/g, '')
const blocks: Block[] = []
const re = /<(h1|h2|h3|p)>([\s\S]*?)<\/\1>|<ul>([\s\S]*?)<\/ul>|<blockquote>([\s\S]*?)<\/blockquote>|<hr\s*\/?>/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 = /<li>([\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 = /<p>([\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
}