mirror of
https://github.com/minibits-cash/minibits_wallet.git
synced 2026-10-05 11:18:24 +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",
|
"warning": "Warning",
|
||||||
"welcomeScreen_creatingKeys": "Creating wallet keys...",
|
"welcomeScreen_creatingKeys": "Creating wallet keys...",
|
||||||
"welcomeScreen_creatingProfile": "Creating wallet profile...",
|
"welcomeScreen_creatingProfile": "Creating wallet profile...",
|
||||||
"welcomeScreen_generatingWalletSeedStatusMessage": "Creating wallet seed, this takes a while...",
|
|
||||||
"welcomeScreen_lastPageConfirmButton": "Got it, take me to the wallet",
|
"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_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_hero_instant": "Instant.",
|
||||||
"welcomeScreen_page1_bullet2": "Ecash is issued or converted back to Bitcoin instantly via Lightning payments.",
|
"welcomeScreen_hero_private": "Private.",
|
||||||
"welcomeScreen_page1_bullet3": "Ecash tokens are stored on-device; mints do not maintain ledgers or wallet balances.",
|
"welcomeScreen_hero_ecash": "Ecash.",
|
||||||
"welcomeScreen_page1_final": "No Watson, there's no blockchain.",
|
"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_page1_heading": "Welcome",
|
"welcomeScreen_terms_title": "Terms & Conditions",
|
||||||
"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_terms_loading": "Loading Terms…",
|
||||||
"welcomeScreen_page2_bullet1": "Minibits provides a free Lightning address that also allows instant ecash transfers over Nostr.",
|
"welcomeScreen_terms_error": "Couldn't load the Terms. Please check your connection and try again.",
|
||||||
"welcomeScreen_page2_bullet2": "First-class support for sending and receiving zaps on Nostr, including Nostr Wallet Connect.",
|
"welcomeScreen_terms_retry": "Retry",
|
||||||
"welcomeScreen_page2_bullet3": "Realtime encrypted push notifications and true Tap to pay experience using NFC.",
|
"welcomeScreen_terms_agreePrefix": "I have read and agree to the",
|
||||||
"welcomeScreen_page2_final": "Minibits is free, open-source software. Find us on GitHub for our roadmap and contributions.",
|
"welcomeScreen_terms_agreeTerms": "Terms",
|
||||||
"welcomeScreen_page2_heading": "Why Minibits?",
|
"welcomeScreen_terms_agreeConjunction": "and",
|
||||||
"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_terms_agreePrivacy": "Privacy Policy"
|
||||||
"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."
|
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-21
@@ -733,27 +733,18 @@
|
|||||||
"warning": "Advertencia",
|
"warning": "Advertencia",
|
||||||
"welcomeScreen_creatingKeys": "Creando claves de billetera...",
|
"welcomeScreen_creatingKeys": "Creando claves de billetera...",
|
||||||
"welcomeScreen_creatingProfile": "Creando perfil 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_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_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_hero_instant": "Instantáneo.",
|
||||||
"welcomeScreen_page1_bullet2": "Ecash se emite o se convierte nuevamente a Bitcoin instantáneamente a través de pagos Lightning.",
|
"welcomeScreen_hero_private": "Privado.",
|
||||||
"welcomeScreen_page1_bullet3": "Los tokens de Ecash se almacenan en el dispositivo; las mints no mantienen registros ni saldos de billetera.",
|
"welcomeScreen_hero_ecash": "Ecash.",
|
||||||
"welcomeScreen_page1_final": "Sin Watson, no hay blockchain.",
|
"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_page1_heading": "Bienvenido",
|
"welcomeScreen_terms_title": "Términos y condiciones",
|
||||||
"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_terms_loading": "Cargando los términos…",
|
||||||
"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_terms_error": "No se pudieron cargar los términos. Comprueba tu conexión e inténtalo de nuevo.",
|
||||||
"welcomeScreen_page2_bullet2": "Soporte de primera clase para enviar y recibir zaps en Nostr, incluido Nostr Wallet Connect.",
|
"welcomeScreen_terms_retry": "Reintentar",
|
||||||
"welcomeScreen_page2_bullet3": "Notificaciones push encriptadas en tiempo real y NFC soporte.",
|
"welcomeScreen_terms_agreePrefix": "He leído y acepto los",
|
||||||
"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_terms_agreeTerms": "Términos",
|
||||||
"welcomeScreen_page2_heading": "¿Por qué Minibits?",
|
"welcomeScreen_terms_agreeConjunction": "y la",
|
||||||
"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_terms_agreePrivacy": "Política de 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."
|
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-21
@@ -734,27 +734,18 @@
|
|||||||
"warning": "Aviso",
|
"warning": "Aviso",
|
||||||
"welcomeScreen_creatingKeys": "Criando chaves da carteira...",
|
"welcomeScreen_creatingKeys": "Criando chaves da carteira...",
|
||||||
"welcomeScreen_creatingProfile": "Criando perfil 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_lastPageConfirmButton": "Entendi, leve-me à carteira",
|
||||||
"welcomeScreen_lastPageRecoverLostWalletButton": "Recuperar carteira perdida",
|
|
||||||
"welcomeScreen_offlineWarning": "Certifique-se de estar online para configurar nova carteira.",
|
"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_hero_instant": "Instantâneo.",
|
||||||
"welcomeScreen_page1_bullet2": "Ecash é emitido ou convertido para Bitcoin instantaneamente via pagamentos Lightning.",
|
"welcomeScreen_hero_private": "Privado.",
|
||||||
"welcomeScreen_page1_bullet3": "Tokens ecash são armazenados no dispositivo; mints não mantêm registros ou saldos.",
|
"welcomeScreen_hero_ecash": "Ecash.",
|
||||||
"welcomeScreen_page1_final": "No Watson, não há blockchain.",
|
"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_page1_heading": "Bem-vindo",
|
"welcomeScreen_terms_title": "Termos e Condições",
|
||||||
"welcomeScreen_page1_intro": "Minibits é carteira ecash e Lightning focada em facilidade e segurança. Ecash é token assinado emitido por custodiantes chamados mints.",
|
"welcomeScreen_terms_loading": "Carregando os termos…",
|
||||||
"welcomeScreen_page2_bullet1": "Minibits fornece endereço Lightning gratuito que permite transferências ecash via Nostr.",
|
"welcomeScreen_terms_error": "Não foi possível carregar os termos. Verifique sua conexão e tente novamente.",
|
||||||
"welcomeScreen_page2_bullet2": "Suporte de primeira classe para enviar/receber zaps no Nostr, incluindo Nostr Wallet Connect.",
|
"welcomeScreen_terms_retry": "Tentar novamente",
|
||||||
"welcomeScreen_page2_bullet3": "Notificações push criptografadas em tempo real e NFC suporte.",
|
"welcomeScreen_terms_agreePrefix": "Li e concordo com os",
|
||||||
"welcomeScreen_page2_final": "Minibits é software livre e de código aberto. Encontre-nos no GitHub para roteiro e contribuições.",
|
"welcomeScreen_terms_agreeTerms": "Termos",
|
||||||
"welcomeScreen_page2_heading": "Por que Minibits?",
|
"welcomeScreen_terms_agreeConjunction": "e a",
|
||||||
"welcomeScreen_page2_intro": "Minibits explora como ecash pode ajudar pessoas a usar Bitcoin oferecendo onboarding sem atrito, taxas baixas, facilidade e privacidade forte.",
|
"welcomeScreen_terms_agreePrivacy": "Política de Privacidade"
|
||||||
"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."
|
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-21
@@ -734,27 +734,18 @@
|
|||||||
"warning": "Upozornenie",
|
"warning": "Upozornenie",
|
||||||
"welcomeScreen_creatingKeys": "Vytváram kľúče peňaženky...",
|
"welcomeScreen_creatingKeys": "Vytváram kľúče peňaženky...",
|
||||||
"welcomeScreen_creatingProfile": "Vytváram profil 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_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_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_hero_instant": "Okamžitý.",
|
||||||
"welcomeScreen_page1_bullet2": "Elektronické peniaze sa vydávajú alebo konvertujú späť na Bitcoin okamžite prostredníctvom platieb Lightning.",
|
"welcomeScreen_hero_private": "Súkromný.",
|
||||||
"welcomeScreen_page1_bullet3": "Tokeny Ecash sú uložené v zariadení; mincovne neuchovávajú účtovné knihy ani zostatky v peňaženkách.",
|
"welcomeScreen_hero_ecash": "Ecash.",
|
||||||
"welcomeScreen_page1_final": "Nie, Watson, neexistuje žiadny blockchain.",
|
"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_page1_heading": "Vitajte",
|
"welcomeScreen_terms_title": "Podmienky používania",
|
||||||
"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_terms_loading": "Načítavajú sa podmienky…",
|
||||||
"welcomeScreen_page2_bullet1": "Minibits poskytuje bezplatnú Lightning adresu, ktorá tiež umožňuje okamžité prevody hotovosti cez Nostr.",
|
"welcomeScreen_terms_error": "Podmienky sa nepodarilo načítať. Skontroluj pripojenie a skús to znova.",
|
||||||
"welcomeScreen_page2_bullet2": "Prvotriedna podpora pre odosielanie a prijímanie zapov na Nostre, vrátane Nostr Wallet Connect.",
|
"welcomeScreen_terms_retry": "Skúsiť znova",
|
||||||
"welcomeScreen_page2_bullet3": "Šifrované push notifikácie a podpora platieb cez NFC.",
|
"welcomeScreen_terms_agreePrefix": "Prečítal som si a súhlasím s",
|
||||||
"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_terms_agreeTerms": "Podmienkami",
|
||||||
"welcomeScreen_page2_heading": "Prečo Minibity?",
|
"welcomeScreen_terms_agreeConjunction": "a",
|
||||||
"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_terms_agreePrivacy": "Zásadami ochrany osobných údajov"
|
||||||
"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á."
|
|
||||||
}
|
}
|
||||||
|
|||||||
+419
-171
@@ -1,83 +1,50 @@
|
|||||||
// import { observer } from "mobx-react-lite"
|
import React, {useCallback, useEffect, useLayoutEffect, useRef, useState} from 'react'
|
||||||
import React, {FC, useLayoutEffect, useRef, useState} from 'react'
|
|
||||||
import {
|
import {
|
||||||
TextStyle,
|
TextStyle,
|
||||||
View,
|
View,
|
||||||
ViewStyle,
|
ViewStyle,
|
||||||
FlatList,
|
|
||||||
Animated,
|
Animated,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
Linking,
|
Linking,
|
||||||
Pressable,
|
ActivityIndicator,
|
||||||
} from 'react-native'
|
} from 'react-native'
|
||||||
import PagerView, { PagerViewOnPageScrollEventData } from 'react-native-pager-view'
|
import PagerView, { PagerViewOnPageScrollEventData } from 'react-native-pager-view'
|
||||||
import {
|
import { ScalingDot } from 'react-native-animated-pagination-dots'
|
||||||
ScalingDot,
|
|
||||||
SlidingBorder,
|
|
||||||
} from 'react-native-animated-pagination-dots'
|
|
||||||
// import { isRTL } from "../i18n"
|
|
||||||
import {useStores} from '../models'
|
import {useStores} from '../models'
|
||||||
import {spacing, colors, useThemeColor} from '../theme'
|
import {spacing, colors, useThemeColor, typography} from '../theme'
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
ErrorModal,
|
ErrorModal,
|
||||||
Icon,
|
|
||||||
InfoModal,
|
InfoModal,
|
||||||
Loading,
|
Loading,
|
||||||
Screen,
|
Screen,
|
||||||
Text,
|
Text,
|
||||||
|
Toggle,
|
||||||
} from '../components'
|
} from '../components'
|
||||||
import {TxKeyPath, translate} from '../i18n'
|
import {translate} from '../i18n'
|
||||||
import AppError from '../utils/AppError'
|
import AppError from '../utils/AppError'
|
||||||
import { MINIBITS_MINT_URL } from '@env'
|
import { MINIBITS_MINT_URL } from '@env'
|
||||||
import useIsInternetReachable from '../utils/useIsInternetReachable'
|
import useIsInternetReachable from '../utils/useIsInternetReachable'
|
||||||
import { KeyChain, log, WalletKeys } from '../services'
|
import { KeyChain, log } from '../services'
|
||||||
import { delay } from '../utils/utils'
|
import { delay } from '../utils/utils'
|
||||||
|
import { htmlToBlocks, Block, InlineSegment } from '../utils/htmlToBlocks'
|
||||||
import { StaticScreenProps, useNavigation } from '@react-navigation/native'
|
import { StaticScreenProps, useNavigation } from '@react-navigation/native'
|
||||||
|
|
||||||
const AnimatedPagerView = Animated.createAnimatedComponent(PagerView)
|
const AnimatedPagerView = Animated.createAnimatedComponent(PagerView)
|
||||||
|
|
||||||
const PAGES = [
|
const TERMS_URL = 'https://minibits.cash/terms'
|
||||||
{
|
const PRIVACY_URL = 'https://minibits.cash/privacy'
|
||||||
key: 1,
|
const TERMS_FETCH_TIMEOUT = 5000
|
||||||
heading: 'welcomeScreen_page1_heading',
|
|
||||||
intro: 'welcomeScreen_page1_intro',
|
// Two-dot page indicator (hero + terms).
|
||||||
bullets: [
|
const PAGE_INDICATORS = [{ key: 1 }, { key: 2 }]
|
||||||
{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'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
type Props = StaticScreenProps<undefined>
|
type Props = StaticScreenProps<undefined>
|
||||||
|
|
||||||
export const WelcomeScreen = function ({ route }: Props) {
|
export const WelcomeScreen = function ({ route }: Props) {
|
||||||
const navigation = useNavigation()
|
const navigation = useNavigation()
|
||||||
const headerBg = useThemeColor('header')
|
const headerBg = useThemeColor('header')
|
||||||
|
const bgColor = useThemeColor('background')
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
navigation.setOptions({ headerShown: false })
|
navigation.setOptions({ headerShown: false })
|
||||||
@@ -98,9 +65,12 @@ export const WelcomeScreen = function ({ route }: Props) {
|
|||||||
const [isLoading, setIsLoading] = useState<boolean>(false)
|
const [isLoading, setIsLoading] = useState<boolean>(false)
|
||||||
const [statusMessage, setStatusMessage] = useState<string>('')
|
const [statusMessage, setStatusMessage] = useState<string>('')
|
||||||
const [info, setInfo] = useState<string>('')
|
const [info, setInfo] = useState<string>('')
|
||||||
|
const [hasAgreed, setHasAgreed] = useState<boolean>(false)
|
||||||
|
|
||||||
const gotoWallet = async function () {
|
const gotoWallet = async function () {
|
||||||
try {
|
try {
|
||||||
|
if(!hasAgreed) { return }
|
||||||
|
|
||||||
if(!isInternetReachable) {
|
if(!isInternetReachable) {
|
||||||
setInfo(translate('welcomeScreen_offlineWarning'))
|
setInfo(translate('welcomeScreen_offlineWarning'))
|
||||||
return
|
return
|
||||||
@@ -161,18 +131,18 @@ export const WelcomeScreen = function ({ route }: Props) {
|
|||||||
setError(e)
|
setError(e)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pager scroll animation wiring for the dot indicator.
|
||||||
const width = spacing.screenWidth
|
const width = spacing.screenWidth
|
||||||
const ref = useRef<PagerView>(null);
|
const ref = useRef<PagerView>(null)
|
||||||
const scrollOffsetAnimatedValue = React.useRef(new Animated.Value(0)).current;
|
const scrollOffsetAnimatedValue = useRef(new Animated.Value(0)).current
|
||||||
const positionAnimatedValue = React.useRef(new Animated.Value(0)).current;
|
const positionAnimatedValue = useRef(new Animated.Value(0)).current
|
||||||
const inputRange = [0, PAGES.length];
|
const inputRange = [0, PAGE_INDICATORS.length]
|
||||||
const scrollX = Animated.add(
|
const scrollX = Animated.add(
|
||||||
scrollOffsetAnimatedValue,
|
scrollOffsetAnimatedValue,
|
||||||
positionAnimatedValue
|
positionAnimatedValue
|
||||||
).interpolate({
|
).interpolate({
|
||||||
inputRange,
|
inputRange,
|
||||||
outputRange: [0, PAGES.length * width],
|
outputRange: [0, PAGE_INDICATORS.length * width],
|
||||||
})
|
})
|
||||||
|
|
||||||
const onPageScroll = React.useMemo(
|
const onPageScroll = React.useMemo(
|
||||||
@@ -190,99 +160,47 @@ export const WelcomeScreen = function ({ route }: Props) {
|
|||||||
useNativeDriver: false,
|
useNativeDriver: false,
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// 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 (
|
return (
|
||||||
<Screen contentContainerStyle={$container} preset="fixed" style={{backgroundColor: headerBg}}>
|
<Screen
|
||||||
|
contentContainerStyle={$container}
|
||||||
|
preset="fixed"
|
||||||
|
style={{backgroundColor: bgColor}}
|
||||||
|
safeAreaEdges={['top', 'bottom']}
|
||||||
|
>
|
||||||
<AnimatedPagerView
|
<AnimatedPagerView
|
||||||
testID="pager-view"
|
testID="pager-view"
|
||||||
initialPage={0}
|
initialPage={0}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
style={{flex: 1, marginTop: 80}}
|
style={$pager}
|
||||||
// onPageSelected={onPageSelected}
|
|
||||||
onPageScroll={onPageScroll}
|
onPageScroll={onPageScroll}
|
||||||
>
|
>
|
||||||
{PAGES.map((page) => (
|
<View key="hero" style={$page}>
|
||||||
<View key={page.key}>
|
<HeroPage />
|
||||||
<View>
|
</View>
|
||||||
<Text
|
<View key="terms" style={$page}>
|
||||||
tx={page.heading as TxKeyPath}
|
<TermsPage
|
||||||
preset="subheading"
|
hasAgreed={hasAgreed}
|
||||||
style={$welcomeHeading}
|
onAgreeChange={setHasAgreed}
|
||||||
/>
|
onEnter={gotoWallet}
|
||||||
<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>
|
</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>
|
</View>
|
||||||
{error && <ErrorModal error={error} />}
|
{error && <ErrorModal error={error} />}
|
||||||
{info && <InfoModal message={info} />}
|
{info && <InfoModal message={info} />}
|
||||||
@@ -291,70 +209,400 @@ export const WelcomeScreen = function ({ route }: Props) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const $dotsContainer: ViewStyle ={
|
|
||||||
height: 50,
|
/* ----------------------------- Page 1: Hero ----------------------------- */
|
||||||
justifyContent: 'space-evenly',
|
|
||||||
marginBottom: 50
|
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',
|
/* ------------------------ Page 2: Terms & consent ----------------------- */
|
||||||
alignSelf: 'center',
|
|
||||||
|
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 = {
|
const $container: ViewStyle = {
|
||||||
// alignItems: 'center',
|
|
||||||
flex: 1,
|
flex: 1,
|
||||||
paddingHorizontal: spacing.medium,
|
paddingHorizontal: spacing.medium,
|
||||||
}
|
}
|
||||||
|
|
||||||
const $listContainer: ViewStyle = {
|
const $pager: ViewStyle = {
|
||||||
maxHeight: spacing.screenHeight * 0.38,
|
flex: 1,
|
||||||
alignSelf: 'stretch',
|
marginTop: spacing.large,
|
||||||
paddingRight: spacing.medium,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const $listItem: ViewStyle = {
|
const $page: ViewStyle = {
|
||||||
flexDirection: 'row',
|
flex: 1,
|
||||||
paddingBottom: spacing.extraSmall,
|
|
||||||
paddingRight: spacing.extraSmall,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const $itemIcon: ViewStyle = {
|
const $dotsContainer: ViewStyle = {
|
||||||
flexDirection: 'row',
|
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,
|
marginBottom: spacing.small,
|
||||||
}
|
}
|
||||||
|
|
||||||
const $buttonContainer: ViewStyle = {
|
const $termsBox: ViewStyle = {
|
||||||
alignSelf: 'center',
|
flex: 1,
|
||||||
marginTop: spacing.large,
|
borderRadius: spacing.small,
|
||||||
|
overflow: 'hidden',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const $termsInner: ViewStyle = {
|
||||||
const $welcomeHeading: TextStyle = {
|
flex: 1,
|
||||||
marginBottom: spacing.medium,
|
|
||||||
color: 'white',
|
|
||||||
alignSelf: 'center',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const $welcomeIntro: TextStyle = {
|
const $termsScrollContent: ViewStyle = {
|
||||||
marginBottom: spacing.large,
|
padding: spacing.medium,
|
||||||
color: 'white',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const $welcomeFinal: TextStyle = {
|
const $termsCentered: ViewStyle = {
|
||||||
marginTop: spacing.large,
|
flex: 1,
|
||||||
color: 'white',
|
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,
|
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,
|
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',
|
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