mirror of
https://github.com/minibits-cash/minibits_wallet.git
synced 2026-08-09 00:04:56 +00:00
New welcome screen
This commit is contained in:
@@ -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> & friends said "hello" 'again'</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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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',
|
||||
}
|
||||
|
||||
@@ -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> = {
|
||||
'"': '"',
|
||||
''': "'",
|
||||
''': "'",
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
' ': ' ',
|
||||
'—': '—',
|
||||
'–': '–',
|
||||
'©': '©',
|
||||
}
|
||||
|
||||
function decodeEntities(input: string): string {
|
||||
return input
|
||||
.replace(/"|'|'|&|<|>| |—|–|©/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
|
||||
}
|
||||
Reference in New Issue
Block a user