// favela-android — the app shell.
//
// THE UI IS favela-web. Not a port of it, not a native re-render of it: the actual pages, served by the
// actual favela-web running inside this app's worklet, displayed in a WebView. The previous shell was a
// hand-written React Native rendering of the same data, and it looked nothing like favela — every screen
// favela-web gained had to be built a second time here, by hand, to a lower standard.
//
// This is why favela-web is embedded rather than reached over the network: the phone IS the server. It
// holds the DID key, authors its own repo, indexes its own appview, and serves its own pages to loopback.
// Nothing renders from a remote host, and the signing key never leaves the handset.
//
// ⛔ NO ATPROTO KNOWLEDGE LIVES HERE, and now no social UI either. This file boots the node, waits for the
// server to answer, hands the session across, and shows a page. Anything about posts, threads or profiles
// belongs in favela-web, where every other device gets it for free.
import React, { useEffect, useState, useCallback, useRef } from 'react'
import { View, Text, ActivityIndicator, StyleSheet, SafeAreaView, BackHandler, Pressable } from 'react-native'
import { WebView } from 'react-native-webview'
import { startNode, stopNode } from './worklet'
import { parseShellMessage } from '../lib/shell-handoff'
import { initialScreen } from '../lib/first-run'
import Pair from './Pair'
import Create from './Create'
import { Center } from './Screens'
export default function App () {
const [client, setClient] = useState(null)
const [status, setStatus] = useState(null)
const [error, setError] = useState(null)
const [booting, setBooting] = useState('starting the node…')
// Pairing on demand, for a device that ALREADY has an identity.
//
// ⛔ HAVING AN IDENTITY IS NOT THE SAME AS BEING A MEMBER. The first cut of this shell showed the
// pairing screen only when there was no identity at all, which removed the one route to the ceremony
// for the case that actually needs it: a handset holding the DID and its signing key, but absent from
// the room's __members__, so every write is refused with "this room lists members and this device is
// not among them". Re-pairing from a roster-holding node is the FIX for that, and the app had no way
// to run it. The old UI reached pairing from settings for exactly this reason.
const [pairing, setPairing] = useState(false)
// The user chose to carry on unpaired. THIS RUN ONLY, deliberately: the phone genuinely has no
// identity, that is still the main thing to resolve, and a launch is the honest moment to say so
// again. Persisting the choice is a separate decision, not a detail to settle by leaving state lying
// around. Reaching pairing again does not depend on it either way — favela-web's settings offer the
// ceremony to an anonymous viewer (render.js settingsPage renders pairSection with no viewer).
const [readOnly, setReadOnly] = useState(false)
// Creating an identity, rather than receiving one. Same shape as `pairing`: a request, not a state of
// the node. The node's state is `status.signing`.
const [creating, setCreating] = useState(false)
// Boot is a named operation because it happens TWICE: once at launch, and again after pairing. The
// worklet reads `cfg.dids` only in bootMultiDevice (lib/node.js), so a node that has just been given
// an identity cannot sign until it is restarted — `adopt` says so by returning restartRequired.
const boot = useCallback(async () => {
setClient(null); setError(null); setBooting('starting the node…')
const { client } = await startNode()
client.on('log', (m) => setBooting(m.msg || String(m)))
client.on('fatal', (m) => setError(m.error || 'node failed'))
await client.whenReady()
const st = await client.status()
setStatus(st)
setClient(client)
}, [])
useEffect(() => { boot().catch((e) => setError(e.message)) }, [boot])
const paired = useCallback(async (r) => {
try {
if (r && r.restartRequired) { stopNode(); await boot() } else if (client) setStatus(await client.status())
} catch (e) { setError(e.message) }
}, [boot, client])
// THE SETTINGS PAGE'S UPDATE BUTTONS. favela-web renders them (render.js appUpdateSection) and posts
// here, because neither half can do this alone: the worklet owns the OTA runtime, and only this side
// owns the RUNTIME ITSELF and can start a different bundle.
//
// ⛔ NAMED VERBS, NOT A DISPATCHER. parseShellMessage allow-lists them rather than passing an op name
// through to a handler map — this is a string from a web page reaching functions that restart the node
// and open a ceremony granting a signing key, and the fact that the page is ours today is not a
// property enforced anywhere in this file.
//
// `reload` is how the page learns the result: the worklet writes the state to disk and favela-web
// renders it server-side, so re-fetching the page IS the update. No reply channel, no second copy of
// those three fields.
const onShellMessage = useCallback(async (data, reload) => {
const msg = parseShellMessage(data)
if (!msg) return
try {
if (msg.op === 'check') {
if (client) await client.checkForUpdate()
reload()
} else if (msg.op === 'pair') {
// Pairing came OFF the page and back into settings, where favela-android's own note always said
// it belonged. The native screen still owns the ceremony and its warning (app/Pair.jsx) — the
// page only asks for it.
setPairing(true)
} else if (msg.op === 'restart') {
// Not `reload` — the whole UI comes down with the node, and boot() puts the splash back with the
// real progress lines. A restart takes ~30s on a handset and pretending otherwise reads as a hang.
stopNode()
await boot()
}
} catch (e) { setError(e.message) }
}, [boot, client])
if (error) return <Center><Text style={s.err}>{error}</Text></Center>
// The splash is not decoration: a cold boot takes ~40s (corestore, swarm, appview index, then the
// server), and the worklet reports each stage. A WebView pointed at the server before it listens shows
// a browser connection error, which reads as "the app is broken" rather than "it is still starting".
if (!client || !status) return <Center><ActivityIndicator /><Text style={s.dim}>{booting}</Text></Center>
// The ENTRY POINT moved into favela-web's settings (render.js pairSection, via the bridge above); the
// ceremony SCREEN is still native, because its wording is part of the security model and it runs before
// there is any identity to serve a page with. Auto-showing it stays: `signing` is false exactly when
// there is no identity config at all (lib/node.js only boots hpp when one exists), which is the
// fresh-install case — and a fresh install has no settings page to reach a button on.
if (!status.favelaUrl) return <Center><Text style={s.err}>favela-web did not start — the UI has nothing to show</Text><Text style={s.dim}>{booting}</Text></Center>
// ⛔ THE RULE IS IN lib/first-run.js AND IS TESTED. It used to be `!status.signing || pairing` inline
// here, which had no third state — so an unpaired phone could leave this screen ONLY by completing the
// ceremony, while the screen's own footer promised it could carry on without one. Reported on a handset
// 2026-08-25: "it also will not go past the initial pairing modal". Skipping shows the app that is
// ALREADY RUNNING behind it — read-only and loopback-only (hyper-social #54 §7).
const screen = initialScreen({ signing: status.signing, pairing, creating, readOnly })
if (screen === 'create') {
return (
<Create
client={client}
// `paired` is not a misnomer here — it is the "an identity arrived, act on restartRequired"
// path, and mint returns the same contract for the same reason: the config is written but
// bootMultiDevice only reads it at boot.
onCreated={async (r) => { setCreating(false); await paired(r) }}
onCancel={() => setCreating(false)}
/>
)
}
if (screen === 'pair') {
return (
<Pair
client={client}
onPaired={async (r) => { setPairing(false); await paired(r) }}
// Both flags are cleared: `pairing` because the ceremony is no longer open, `readOnly` because
// the user just declined it. Leaving `pairing` set would re-open this screen immediately.
onSkip={() => { setPairing(false); setReadOnly(true) }}
// FIRST RUN ONLY — see Pair.jsx's note. A device that already signs is re-pairing, and offering
// to mint a second identity there would answer "my posts are refused" with "make a new you".
onCreate={!status.signing ? () => { setPairing(false); setCreating(true) } : null}
/>
)
}
return <Web status={status} client={client} onShellMessage={onShellMessage} />
}
function Web ({ status, client, onShellMessage }) {
const ref = useRef(null)
const [canBack, setCanBack] = useState(false)
const [failed, setFailed] = useState(null)
// Has anything EVER loaded? Only a failure before the first successful page is fatal.
const loaded = useRef(false)
// THE SESSION, handed to the WebView's own cookie jar. The worklet signed in over loopback and holds
// the token; a WebView cannot be told about that, so favela-web's /auth/handoff swaps the token for the
// cookie form of the same session and redirects to the page. Without it every page renders anonymously:
// no liked/reposted state, and every write refused — the silent degradation that made the hand-written
// UI look fine while doing less.
// `nonce` forces a re-render with a fresh handoff URL after a re-session, since the URL is otherwise
// identical and the WebView would not reload it.
const [session, setSession] = useState(status.favelaSession)
const [nonce, setNonce] = useState(0)
const recovering = useRef(false)
const uri = session
? status.favelaUrl + '/auth/handoff?token=' + encodeURIComponent(session) + (nonce ? '&r=' + nonce : '')
: status.favelaUrl
// A STALE TOKEN IS NOT A SIGN-OUT. favela-web keeps sessions in memory, so any restart of it makes
// this side's token unknown and /auth/handoff answers 403 — and we used to render that refusal page
// and stop, which looks exactly like being signed out and could only be escaped by force-stopping the
// app. Nothing about the credentials changed: the worklet holds the DID key and can mint a new
// session on demand. So ask for one, once, and reload.
//
// Guarded by `recovering` so a 403 that is NOT about staleness cannot become a re-login loop: one
// attempt per mount, and if the fresh token is refused too, the refusal stands and is shown.
const recoverSession = useCallback(async () => {
if (recovering.current) return
recovering.current = true
try {
const r = await client.resession()
if (r && r.ok && r.favelaSession) {
setSession(r.favelaSession)
setNonce((n) => n + 1)
}
} catch { /* leave the refusal visible rather than spinning */ }
}, [client])
// HARDWARE BACK follows the page history, then falls through to the default (exit) at the first page.
// Without this, BACK from a thread finishes the activity and drops the user to the launcher.
useEffect(() => {
const sub = BackHandler.addEventListener('hardwareBackPress', () => {
if (canBack && ref.current) { ref.current.goBack(); return true }
return false
})
return () => sub.remove()
}, [canBack])
if (failed) {
return (
<Center>
<Text style={s.err}>could not load the local UI</Text>
<Text style={s.dim}>{failed}</Text>
<Pressable onPress={() => { setFailed(null); if (ref.current) ref.current.reload() }}><Text style={s.retry}>retry</Text></Pressable>
</Center>
)
}
return (
<SafeAreaView style={s.fill}>
<WebView
ref={ref}
source={{ uri }}
// The server is ours, on loopback, and the pages it serves are the product. Everything else is
// somebody else's website: a link to bsky.app or an image host must not silently replace the app
// with a browser page that looks like it. Off-origin navigations are refused here; opening them
// in a real browser is a later refinement, and refusing is the safe default in the meantime.
onShouldStartLoadWithRequest={(r) => r.url.startsWith(status.favelaUrl)}
onNavigationStateChange={(n) => setCanBack(n.canGoBack)}
// The settings page's App-updates section (favela-web render.js) is the only sender. Handled in
// App, not here, because a restart tears down the client this component was handed.
onMessage={(e) => {
if (onShellMessage) onShellMessage(e && e.nativeEvent && e.nativeEvent.data, () => { if (ref.current) ref.current.reload() })
}}
onLoadEnd={() => { loaded.current = true }}
// ⛔ A FAILED REQUEST IS NOT A FAILED APP. These fire for the MAIN FRAME, which includes the POST
// a compose submits — so a single 500 from one write tore down the entire rendered UI and replaced
// it with a full-screen "could not load the local UI", losing the user's place and making a
// one-request failure look like the app dying. (Observed on device 2026-08-11: post fails once,
// whole UI is replaced, retry works.)
//
// Only a failure BEFORE anything has ever rendered is fatal — that is the case the screen is for
// (server never came up). After that, the page is still there and owns its own error reporting.
onError={(e) => { if (!loaded.current) setFailed((e.nativeEvent && e.nativeEvent.description) || 'load failed') }}
// A 403 from /auth/handoff is the STALE-SESSION case, not a broken app: favela-web restarted and
// forgot the token this side still holds. Recover it rather than tearing the UI down — and
// deliberately do NOT setFailed here, or a first load with a stale token would show "could not
// load the local UI" while the re-login it needs is already in flight.
onHttpError={(e) => {
const { statusCode, url } = (e && e.nativeEvent) || {}
if (statusCode === 403 && String(url || '').includes('/auth/handoff')) { recoverSession(); return }
if (!loaded.current) setFailed('HTTP ' + (statusCode || '?'))
}}
// Loopback only, so no mixed-content or remote-code concern; the pages need JS for likes/compose.
javaScriptEnabled
domStorageEnabled
// Pull-to-refresh belongs to the page, and favela-web's timeline is server-rendered — the gesture
// reloading the document is exactly right.
pullToRefreshEnabled
startInLoadingState
renderLoading={() => <Center><ActivityIndicator /></Center>}
style={s.fill}
/>
{/* ⛔ THE FLOATING `pair` BUTTON IS GONE, and this is the "design §4" the old note here promised.
It sat at left:10/bottom:14 — which is exactly where favela-web's bottom nav put its Home item
once that shipped, so tapping Home opened the pairing screen instead. Reported on a handset
2026-08-25. It now lives in favela-web's settings page (render.js pairSection) and arrives
through the same bridge as the update controls, gated on the shell announcing it can service
it, so the web UI owns this screen like every other. */}
</SafeAreaView>
)
}
const s = StyleSheet.create({
fill: { flex: 1 },
err: { color: '#b00020', padding: 16, textAlign: 'center' },
dim: { color: '#666', padding: 8, textAlign: 'center' },
retry: { color: '#2563eb', padding: 12, fontSize: 16 },
})