// The pairing screen — the phone side of spec/pairing.md.
//
// ⛔ THE WORDING ON THIS SCREEN IS PART OF THE SECURITY MODEL, not copy. What the user approves is not
// "let this phone sign in": the bundle that comes back over the tunnel CARRIES THE SHARED ATPROTO SIGNING
// KEY, and from that moment the phone can author as them. And it is not cheaply undoable — removing a
// device from the chain stops it being ADMITTED, but cannot un-know a key the phone already holds. A
// genuinely lost phone means rotating the DID's key with a PLC op.
//
// So this screen says what it grants, and what losing the phone costs, before anything is scanned.
import React, { useState } from 'react'
import { View, Text, TextInput, Pressable, ActivityIndicator, StyleSheet, ScrollView } from 'react-native'
// `onSkip` and `onCreate` are OPTIONAL and each control renders only when its handler is given, so this
// screen can never again offer a way out that its caller does not actually implement — the exact shape
// of the bug it fixes.
//
// `onCreate` is passed only on a FIRST RUN. A device that already holds an identity is re-pairing (the
// __members__ case), and offering "create a new identity" there would invite someone to mint a second,
// unrelated DID as a fix for their posts being refused — which is the opposite of the fix.
export default function Pair ({ client, onPaired, onSkip = null, onCreate = null }) {
const [invite, setInvite] = useState('')
const [busy, setBusy] = useState(false)
const [step, setStep] = useState(null)
const [err, setErr] = useState(null)
const pair = async () => {
setBusy(true); setErr(null); setStep('opening the tunnel…')
// The ceremony's own progress. lib/adopt.js reports every stage through `onStep` and the worklet
// forwards it as a log line ("adopt: device key ready", "adopt: bundle received", "adopt: joined").
// Without this the screen shows a spinner for as long as a DHT rendezvous takes, which is
// indistinguishable from a hang — the state existed here and nothing ever set it.
const onLog = (m) => { const s = m.msg || String(m); if (s.startsWith('adopt:')) setStep(s.slice(6).trim()) }
client.on('log', onLog)
try {
// The worklet owns the ceremony (lib/adopt.js). The UI never touches keys, proofs or the tunnel —
// it collects an invite and shows progress.
const r = await client.request('adopt', { inviteHex: invite.trim() })
setStep('restarting the node…')
// Hand the RESULT up. It carries restartRequired, and the node cannot sign until that happens —
// the config is written but bootMultiDevice only reads it at boot.
await onPaired(r)
} catch (e) {
setErr(e.message)
setStep(null)
} finally {
if (typeof client.off === 'function') client.off('log', onLog)
setBusy(false)
}
}
return (
<ScrollView style={s.screen} contentContainerStyle={s.wrap}>
<Text style={s.h1}>Connect this phone</Text>
<Text style={s.body}>
This node is running and replicating, but it has no identity yet — so it can read and cannot post.
</Text>
<View style={s.warn}>
<Text style={s.warnH}>What pairing does</Text>
<Text style={s.warnB}>
Your other device will send this phone the key that signs your posts. After that, this phone can
post as you — on its own, offline, without asking anything.
</Text>
<Text style={s.warnB}>
If you lose this phone, removing it stops it joining, but it will still hold that key. Undoing
that properly means rotating your identity key, which is a permanent change.
</Text>
</View>
<Text style={s.label}>On a device that already has your identity</Text>
<Text style={s.code}>hyper-proto-peer coowner-invite <did> --pair</Text>
<Text style={s.dim}>then paste the code it prints, while that command is still running — it holds
the tunnel open. That one string is the whole handoff: this phone learns which identity it is
joining from the reply, so there is nothing else to type.</Text>
<Text style={s.dim}>⚠ Not `favela-web pair` — that mints a BROWSER invite (a delegation). This
phone is a node and needs the co-owner invite above; pasting the browser one just times out.</Text>
<Text style={s.label}>Invite</Text>
<TextInput
style={[s.input, s.mono]}
value={invite}
onChangeText={setInvite}
placeholder="paste the invite"
autoCapitalize="none"
autoCorrect={false}
multiline
/>
<Pressable
style={[s.btn, (!invite || busy) && s.btnOff]}
disabled={!invite || busy}
onPress={pair}
>
{busy ? <ActivityIndicator color="#fff" /> : <Text style={s.btnT}>Pair this phone</Text>}
</Pressable>
{step ? <Text style={s.dim}>{step}</Text> : null}
{err ? <Text style={s.err}>{err}</Text> : null}
{/* THE OTHER HALF OF THE MODAL. Pairing needs an identity you already have somewhere else — so on
a phone that has never had one, this screen on its own was a dead end dressed as a choice.
Presented as the secondary route because pairing is the recoverable one: it copies an identity
that already exists, while this mints a new one and a PLC genesis cannot be taken back. */}
{onCreate ? (
<View style={s.alt}>
<Text style={s.altH}>No identity yet?</Text>
<Text style={s.dim}>Create one on this phone. It is yours, it needs no account anywhere, and
you can connect it to a domain handle later.</Text>
<Pressable style={s.altBtn} disabled={busy} onPress={onCreate}>
<Text style={[s.altBtnT, busy && s.skipOff]}>Create a new identity</Text>
</Pressable>
</View>
) : null}
{/* ⛔ THIS FOOTER USED TO BE A LIE. It said "you can keep using the app without pairing" and there
was no control that did it — the only exit from this screen was a successful ceremony, so a
fresh install was walled in (reported on a handset 2026-08-25). The node behind this screen is
up and serving pages the whole time; the promise was true about the SYSTEM and false about the
UI. Now the text and the button say the same thing.
Pairing stays the primary action above — this is deliberately the quieter of the two, because on
a phone with no identity pairing IS what you most likely came to do. */}
{onSkip ? (
<Pressable style={s.skip} disabled={busy} onPress={onSkip}>
<Text style={[s.skipT, busy && s.skipOff]}>Continue without pairing</Text>
</Pressable>
) : null}
<Text style={s.foot}>
You can keep using the app without pairing — you will see posts, and writing stays refused until
this phone has an identity. Pairing is always available again from Settings.
</Text>
</ScrollView>
)
}
// ⛔ THIS SCREEN MUST PAINT ITS OWN BACKGROUND. AndroidManifest sets
// Theme.AppCompat.DayNight.NoActionBar, so on a handset in dark mode the window behind an unpainted
// view is DARK — while every colour here (and the default black of an unstyled <Text>) was chosen for
// white. The result was a screen of near-invisible text, reported on a handset 2026-08-25. Every other
// screen in the app already does this (Screens.jsx `list`); these two ceremonies were the ones that
// never did, because they were written as overlays on top of an app that was already painted.
//
// Painted explicitly rather than made theme-aware: the app is a light design throughout, and a single
// ceremony screen quietly following the system theme would be the only dark surface in it.
//
// TEXT COLOURS ARE EXPLICIT for the same reason — an unstyled <Text> inherits the platform's, which is
// what flips to white and vanishes. Same for the inputs: a TextInput with no `color` renders typed
// characters in the theme's colour, so on a dark phone you could not read what you had pasted.
const s = StyleSheet.create({
screen: { flex: 1, backgroundColor: '#fff' },
wrap: { padding: 24, gap: 10, backgroundColor: '#fff' },
h1: { fontSize: 22, fontWeight: '700', marginBottom: 4, color: '#14171a' },
body: { fontSize: 15, lineHeight: 21, color: '#333' },
warn: { backgroundColor: '#fff6e5', borderColor: '#e8c98a', borderWidth: 1, borderRadius: 8, padding: 14, gap: 8, marginVertical: 10 },
warnH: { fontWeight: '700' },
warnB: { fontSize: 14, lineHeight: 20, color: '#4a3c1a' },
label: { fontWeight: '600', marginTop: 10, color: '#14171a' },
input: { borderWidth: 1, borderColor: '#ccc', borderRadius: 6, padding: 10, fontSize: 14, color: '#14171a', backgroundColor: '#fff' },
mono: { fontFamily: 'monospace', minHeight: 70 },
code: { fontFamily: 'monospace', backgroundColor: '#f2f2f2', padding: 10, borderRadius: 6, color: '#14171a' },
btn: { backgroundColor: '#111', borderRadius: 8, padding: 14, alignItems: 'center', marginTop: 16 },
btnOff: { opacity: 0.4 },
btnT: { color: '#fff', fontWeight: '600' },
dim: { color: '#5b6b78', fontSize: 13 },
err: { color: '#b00', marginTop: 8 },
alt: { borderTopWidth: 1, borderTopColor: '#e5e5e5', marginTop: 22, paddingTop: 18, gap: 6 },
altH: { fontWeight: '700', fontSize: 15, color: '#14171a' },
altBtn: { borderWidth: 1, borderColor: '#111', borderRadius: 8, padding: 13, alignItems: 'center', marginTop: 8 },
altBtnT: { color: '#111', fontWeight: '600' },
skip: { alignItems: 'center', paddingVertical: 14, marginTop: 6 },
skipT: { color: '#2563eb', fontSize: 16 },
skipOff: { opacity: 0.4 },
foot: { color: '#5b6b78', fontSize: 13, marginTop: 20, lineHeight: 19 }
})