OverviewFilesCommitsIssuesDocsReleasesPeers

favela-android / app / Pair.jsx

177 lines · jsx ref:
// 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 &lt;did&gt; --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 }
})