OverviewFilesCommitsIssuesDocsReleasesPeers

favela-android commits / bdfb36d

the UI is favela-web itself, rendered in a WebView
The hand-written React Native UI looked nothing like favela and never could: every screen favela-web gained had to be rebuilt here by hand, to a lower standard, and the port that was supposed to fix this only changed where the RN screens fetched their DATA — the rendering stayed hand-written, so nothing on screen changed at all. So render the real thing. favela-web already runs inside this app's worklet and serves its pages to loopback; it just had no viewer. It is mobile-ready already (viewport meta, small-screen rules, dark mode), so this is the actual favela surface, not an approximation of it, and every device gets the same one. The session crosses into the WebView's own cookie jar via favela-web's /auth/handoff — without it every page renders anonymously, which is the silent degradation that made the old UI look fine while doing less. Hardware BACK follows page history. Off-origin navigation is refused: a link to bsky.app must not quietly replace the app with a browser page that resembles it. ⚠ The native Pair screen stays until pairing moves into favela-web (design §4). It is currently the only way to adopt an identity, so dropping it now would make a fresh install unpairable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
bdfb36d28567c78fb88b2287d9d93c77911c1c43
by plankatron <fleeky@gmail.com> · 2026-08-11T21:07:29.000Z

Changed files (3) +123 -181

app/App.jsx +77 -175
……@@ -1,19 +1,23 @@
11// favela-android — the app shell.
22//
3// This is favela-web's UI ported to React Native over a node running IN THE SAME PROCESS. The web
4// server answers pages over HTTP from its local hpp; here the worklet answers ops over IPC from an hpp
5// it contains. Same appview, same queries, same record shapes — only the rendering surface is new.
3// THE UI IS favela-web. Not a port of it, not a native re-render of it: the actual pages, served by the
4// actual favela-web running inside this app's worklet, displayed in a WebView. The previous shell was a
5// hand-written React Native rendering of the same data, and it looked nothing like favela — every screen
6// favela-web gained had to be built a second time here, by hand, to a lower standard.
67//
7// ⛔ NO ATPROTO KNOWLEDGE LIVES HERE. No CAR parsing, no MST, no signing, no record shapes beyond what
8// the appview already hydrated. If a component ever needs atproto-repo, the boundary is in the wrong
9// place (spec/architecture.md §3).
10import React, { useEffect, useState, useCallback, useMemo } from 'react'
11import { View, Text, FlatList, RefreshControl, ActivityIndicator, StyleSheet, Pressable, SafeAreaView, BackHandler } from 'react-native'
8// This is why favela-web is embedded rather than reached over the network: the phone IS the server. It
9// holds the DID key, authors its own repo, indexes its own appview, and serves its own pages to loopback.
10// Nothing renders from a remote host, and the signing key never leaves the handset.
11//
12// ⛔ NO ATPROTO KNOWLEDGE LIVES HERE, and now no social UI either. This file boots the node, waits for the
13// server to answer, hands the session across, and shows a page. Anything about posts, threads or profiles
14// belongs in favela-web, where every other device gets it for free.
15import React, { useEffect, useState, useCallback, useRef } from 'react'
16import { View, Text, ActivityIndicator, StyleSheet, SafeAreaView, BackHandler, Pressable } from 'react-native'
17import { WebView } from 'react-native-webview'
1218import { startNode, stopNode } from './worklet'
1319import Pair from './Pair'
14import Post from './Post'
15import { Thread, Profile, Search, Compose, Settings, Invite, Identities, Communities, People, Header, Center, requireApi } from './Screens'
16import { apiFromStatus } from '../lib/favela-api'
20import { Center } from './Screens'
1721
1822export default function App () {
1923 const [client, setClient] = useState(null)
……@@ -44,188 +48,86 @@ export default function App () {
4448 }, [boot, client])
4549
4650 if (error) return <Center><Text style={s.err}>{error}</Text></Center>
47 if (!client) return <Center><ActivityIndicator /><Text style={s.dim}>{booting}</Text></Center>
48
49 // ⛔ AN UNPAIRED NODE IS NOT A LOCKED APP. It replicates and reads — only authoring needs a key, and
50 // the pairing screen says exactly that ("you will see posts, but you will not be able to write"). The
51 // first cut gated the ENTIRE UI on `signing`, so the one thing the copy promised was the one thing
52 // the app would not do. Reading is the default; pairing is reachable from settings, and every write
53 // op already refuses with "no identity yet — pair it before writing" rather than half-writing.
54 return <Shell client={client} status={status} onPaired={paired} />
51 // The splash is not decoration: a cold boot takes ~40s (corestore, swarm, appview index, then the
52 // server), and the worklet reports each stage. A WebView pointed at the server before it listens shows
53 // a browser connection error, which reads as "the app is broken" rather than "it is still starting".
54 if (!client || !status) return <Center><ActivityIndicator /><Text style={s.dim}>{booting}</Text></Center>
55
56 // ⚠ TEMPORARY, until pairing moves into favela-web (design §4 — hpp grows an `adopt` control op and
57 // favela-web serves the screen, so every device gets pairing the same way). Until then this native
58 // screen is the ONLY way to adopt an identity, and dropping it would make a fresh install unpairable.
59 // `signing` is false exactly when there is no identity config at all (lib/node.js only boots hpp when
60 // one exists), which is the fresh-install case this screen is for.
61 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>
62 if (!status.signing) return <Pair client={client} onPaired={paired} />
63
64 return <Web status={status} />
5565}
5666
57// A one-level stack over a tab bar. Deliberately hand-rolled: react-navigation is another native
58// dependency, and the whole surface is timeline/search/settings plus push-a-thread-or-profile.
59function Shell ({ client, status, onPaired }) {
60 const [tab, setTab] = useState('home')
61 const [stack, setStack] = useState([])
62 // WHICH IDENTITY. The node hosts several (your own + each co-owned community) and everything used to
63 // resolve to the first one silently. Held here rather than per screen so the timeline, the header and
64 // the compose gate cannot disagree about who you currently are.
65 const [ids, setIds] = useState({ primary: null, dids: [] })
66 const [actingDid, setActingDid] = useState(null)
67 useEffect(() => {
68 let live = true
69 client.hostedDids().then((r) => { if (live) setIds(r || { primary: null, dids: [] }) }).catch(() => {})
70 return () => { live = false }
71 }, [client])
72 const acting = ids.dids.find((d) => d.did === actingDid) || ids.dids.find((d) => d.primary) || null
73 // MEMOISED, not built in render: `api` is a dependency of Timeline's load callback, so a fresh
74 // instance every render would re-arm the callback every render and re-fetch forever.
75 const api = useMemo(() => apiFromStatus(status), [status])
76 const nav = {
77 push: (screen) => setStack((st) => [...st, screen]),
78 pop: () => setStack((st) => st.slice(0, -1))
79 }
80 const top = stack[stack.length - 1]
81
82 // HARDWARE BACK was never wired, so Android finished the activity even with a screen open: BACK from
83 // a thread dropped you to the launcher instead of back to the timeline, and — since BACK also dismisses
84 // the keyboard — a text field plus one back press left the app entirely. Pop our own stack first; only
85 // fall through to the default (exit) at the root.
67function Web ({ status }) {
68 const ref = useRef(null)
69 const [canBack, setCanBack] = useState(false)
70 const [failed, setFailed] = useState(null)
71
72 // THE SESSION, handed to the WebView's own cookie jar. The worklet signed in over loopback and holds
73 // the token; a WebView cannot be told about that, so favela-web's /auth/handoff swaps the token for the
74 // cookie form of the same session and redirects to the page. Without it every page renders anonymously:
75 // no liked/reposted state, and every write refused — the silent degradation that made the hand-written
76 // UI look fine while doing less.
77 const uri = status.favelaSession
78 ? status.favelaUrl + '/auth/handoff?token=' + encodeURIComponent(status.favelaSession)
79 : status.favelaUrl
80
81 // HARDWARE BACK follows the page history, then falls through to the default (exit) at the first page.
82 // Without this, BACK from a thread finishes the activity and drops the user to the launcher.
8683 useEffect(() => {
8784 const sub = BackHandler.addEventListener('hardwareBackPress', () => {
88 if (stack.length) { setStack((st) => st.slice(0, -1)); return true }
85 if (canBack && ref.current) { ref.current.goBack(); return true }
8986 return false
9087 })
9188 return () => sub.remove()
92 }, [stack.length])
89 }, [canBack])
9390
94 if (top) {
95 const title = top.name === 'tag' ? '#' + top.tag : top.name
91 if (failed) {
9692 return (
97 <SafeAreaView style={s.fill}>
98 <Header title={title} onBack={nav.pop} />
99 {top.name === 'thread' ? <Thread client={client} api={api} uri={top.uri} nav={nav} /> : null}
100 {top.name === 'profile' ? <Profile client={client} api={api} did={top.did} nav={nav} /> : null}
101 {top.name === 'compose' ? <Compose client={client} replyTo={top.replyTo || null} onDone={nav.pop} /> : null}
102 {top.name === 'tag' ? <TagFeed client={client} api={api} tag={top.tag} nav={nav} /> : null}
103 {top.name === 'pair' ? <Pair client={client} onPaired={async (r) => { await onPaired(r); nav.pop() }} /> : null}
104 {top.name === 'invite' ? <Invite client={client} /> : null}
105 {top.name === 'identities' ? <Identities client={client} current={acting} onPick={(d) => { setActingDid(d.did); nav.pop() }} /> : null}
106 {top.name === 'communities' ? <Communities client={client} nav={nav} /> : null}
107 {top.name === 'people' ? <People client={client} api={api} did={top.did} kind={top.kind} nav={nav} /> : null}
108 </SafeAreaView>
93 <Center>
94 <Text style={s.err}>could not load the local UI</Text>
95 <Text style={s.dim}>{failed}</Text>
96 <Pressable onPress={() => { setFailed(null); if (ref.current) ref.current.reload() }}><Text style={s.retry}>retry</Text></Pressable>
97 </Center>
10998 )
11099 }
111100
112101 return (
113102 <SafeAreaView style={s.fill}>
114 <Header
115 title={tab === 'home' ? ((acting && (acting.handle || acting.did)) || 'favela') : tab}
116 onTitlePress={tab === 'home' && ids.dids.length ? () => nav.push({ name: 'identities' }) : null}
117 right={tab === 'home' && acting && acting.signing ? <Pressable onPress={() => nav.push({ name: 'compose' })} hitSlop={10}><Text style={s.compose}>+</Text></Pressable> : null}
103 <WebView
104 ref={ref}
105 source={{ uri }}
106 // The server is ours, on loopback, and the pages it serves are the product. Everything else is
107 // somebody else's website: a link to bsky.app or an image host must not silently replace the app
108 // with a browser page that looks like it. Off-origin navigations are refused here; opening them
109 // in a real browser is a later refinement, and refusing is the safe default in the meantime.
110 onShouldStartLoadWithRequest={(r) => r.url.startsWith(status.favelaUrl)}
111 onNavigationStateChange={(n) => setCanBack(n.canGoBack)}
112 onError={(e) => setFailed((e.nativeEvent && e.nativeEvent.description) || 'load failed')}
113 onHttpError={(e) => setFailed('HTTP ' + ((e.nativeEvent && e.nativeEvent.statusCode) || '?'))}
114 // Loopback only, so no mixed-content or remote-code concern; the pages need JS for likes/compose.
115 javaScriptEnabled
116 domStorageEnabled
117 // Pull-to-refresh belongs to the page, and favela-web's timeline is server-rendered — the gesture
118 // reloading the document is exactly right.
119 pullToRefreshEnabled
120 startInLoadingState
121 renderLoading={() => <Center><ActivityIndicator /></Center>}
122 style={s.fill}
118123 />
119 {!status.signing
120 ? <Pressable style={s.banner} onPress={() => nav.push({ name: 'pair' })}>
121 <Text style={s.bannerT}>read-only — this phone has no identity yet. Connect it ›</Text>
122 </Pressable>
123 : null}
124 <View style={s.fill}>
125 {tab === 'home' ? <Timeline client={client} nav={nav} acting={acting} api={api} /> : null}
126 {tab === 'search' ? <Search client={client} api={api} nav={nav} /> : null}
127 {tab === 'browse' ? <Communities client={client} nav={nav} /> : null}
128 {tab === 'settings' ? <Settings client={client} status={status} nav={nav} /> : null}
129 </View>
130 <View style={s.tabs}>
131 {['home', 'search', 'browse', 'settings'].map((k) => (
132 <Pressable key={k} style={s.tab} onPress={() => setTab(k)}>
133 <Text style={[s.tabT, tab === k && s.tabOn]}>{k}</Text>
134 </Pressable>
135 ))}
136 </View>
137124 </SafeAreaView>
138125 )
139126}
140127
141// THE FEED COMES FROM favela-web. It runs in this same process (the worklet starts it on loopback), it
142// owns the appview and the inbound lens, and it is already following ashatron's and loser-club's
143// firehoses — which is why hand-written queries beside it could only ever show less. This is the first
144// screen switched over; the rest follow, and the hand-written verbs go when their route takes over.
145//
146// The fallback is deliberate and temporary: if favela-web did not start, the screen still works on the
147// legacy surface rather than being blank, and the log says which one is in use.
148function Timeline ({ client, nav, acting, api }) {
149 const [posts, setPosts] = useState([])
150 const [cursor, setCursor] = useState(null)
151 const [refreshing, setRefreshing] = useState(false)
152 const [err, setErr] = useState(null)
153
154 // TWO DIFFERENT FEEDS, and conflating them would misrepresent both. Your own identity gets the
155 // TIMELINE (what this node has indexed for you). A co-owned community IS a DID with its own repo, so
156 // its feed is that repo's author feed — the community's posts, not a personalised timeline.
157 const community = acting && !acting.primary ? acting.did : null
158 const load = useCallback(async (reset) => {
159 try {
160 // Both feeds come from favela-web, through the ONE client that carries the session — a raw fetch
161 // here would silently drop the cookie and the rows would come back without liked/reposted state,
162 // with no error to notice.
163 const src = requireApi(api)
164 const r = community ? await src.author({ did: community, limit: 30 }) : await src.timeline({ limit: 30 })
165 setPosts((prev) => (reset ? (r.posts || []) : [...prev, ...(r.posts || [])]))
166 setCursor(r.cursor)
167 setErr(null)
168 } catch (e) { setErr(e.message) }
169 }, [cursor, community, api])
170
171 // Reload when the identity changes — otherwise switching shows the previous identity's feed.
172 useEffect(() => { setPosts([]); setCursor(null); load(true) }, [community]) // eslint-disable-line react-hooks/exhaustive-deps
173
174 const refresh = async () => { setRefreshing(true); await load(true); setRefreshing(false) }
175
176 if (err) return <Center><Text style={s.err}>{err}</Text><Pressable onPress={() => load(true)}><Text style={s.link}>retry</Text></Pressable></Center>
177
178 return (
179 <FlatList
180 style={s.list}
181 data={posts}
182 keyExtractor={(p, i) => p.uri || String(i)}
183 refreshControl={<RefreshControl refreshing={refreshing} onRefresh={refresh} />}
184 onEndReached={() => cursor && load(false)}
185 onEndReachedThreshold={0.5}
186 ListEmptyComponent={<Center><Text style={s.dim}>nothing yet — the node is still replicating</Text></Center>}
187 renderItem={({ item }) => (
188 <Post
189 post={item}
190 client={client}
191 onOpen={(p) => nav.push({ name: 'thread', uri: p.uri })}
192 onProfile={(did) => nav.push({ name: 'profile', did })}
193 onTag={(t) => nav.push({ name: 'tag', tag: t })}
194 />
195 )}
196 />
197 )
198}
199
200function TagFeed ({ client, api, tag, nav }) {
201 const [posts, setPosts] = useState([])
202 const [err, setErr] = useState(null)
203 useEffect(() => {
204 // favela-web calls this route /api/feed; the legacy verb called it `tag`. Same query either way.
205 const p = requireApi(api).tagFeed({ tag, limit: 30 })
206 p.then((r) => setPosts(r.posts || [])).catch((e) => setErr(e.message))
207 }, [client, api, tag])
208 if (err) return <Center><Text style={s.err}>{err}</Text></Center>
209 return (
210 <FlatList
211 style={s.list}
212 data={posts}
213 keyExtractor={(p, i) => p.uri || String(i)}
214 ListEmptyComponent={<Center><Text style={s.dim}>no posts with this tag held here</Text></Center>}
215 renderItem={({ item }) => <Post post={item} client={client} onOpen={(p) => nav.push({ name: 'thread', uri: p.uri })} onProfile={(d) => nav.push({ name: 'profile', did: d })} />}
216 />
217 )
218}
219
220128const s = StyleSheet.create({
221 fill: { flex: 1, backgroundColor: '#fff' },
222 list: { flex: 1, backgroundColor: '#fff' },
223 dim: { color: '#888', textAlign: 'center' },
224 err: { color: '#b00', textAlign: 'center' },
225 link: { color: '#06c', marginTop: 12 },
226 compose: { fontSize: 26, color: '#06c', lineHeight: 28 },
227 tabs: { flexDirection: 'row', borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: '#ddd' },
228 tab: { flex: 1, alignItems: 'center', paddingVertical: 12 },
229 tabT: { color: '#888', fontSize: 13 },
230 tabOn: { color: '#111', fontWeight: '700' }
129 fill: { flex: 1 },
130 err: { color: '#b00020', padding: 16, textAlign: 'center' },
131 dim: { color: '#666', padding: 8, textAlign: 'center' },
132 retry: { color: '#2563eb', padding: 12, fontSize: 16 }
231133})
package-lock.json +42 -3
……@@ -9,6 +9,7 @@
99 "version": "0.0.1",
1010 "dependencies": {
1111 "atproto-identity": "file:../atproto-identity",
12 "atproto-store": "file:../atproto-store",
1213 "b4a": "1.8.1",
1314 "bare-fetch": "3.0.1",
1415 "bare-fs": "4.7.4",
……@@ -32,7 +33,8 @@
3233 "react": "^19.2.8",
3334 "react-native": "^0.86.2",
3435 "react-native-bare-kit": "^0.14.5",
35 "react-native-fs": "^2.20.0"
36 "react-native-fs": "^2.20.0",
37 "react-native-webview": "^14.0.1"
3638 }
3739 },
3840 "../atproto-identity": {
……@@ -52,14 +54,31 @@
5254 "brittle": "^4.0.2"
5355 }
5456 },
57 "../atproto-store": {
58 "version": "0.0.25",
59 "dependencies": {
60 "@ipld/dag-cbor": "^9.2.1",
61 "atproto-repo": "file:../atproto-repo",
62 "b4a": "^1.6.0",
63 "bare-fs": "^4.7.3",
64 "bare-path": "^3.0.1",
65 "corestore": "^7.4.0",
66 "hyperbee": "^2.20.0",
67 "random-access-memory": "^6.2.0"
68 },
69 "devDependencies": {
70 "brittle": "^4.0.2"
71 }
72 },
5573 "../favela-web": {
56 "version": "0.3.49",
74 "version": "0.3.55",
5775 "dependencies": {
5876 "@atcute/client": "^5.1.1",
5977 "@ipld/dag-cbor": "^9.2.7",
6078 "atproto-did-peer": "file:../atproto-did-peer",
6179 "atproto-identity": "file:../atproto-identity",
6280 "atproto-repo": "file:../atproto-repo",
81 "atproto-room": "file:../atproto-room",
6382 "atproto-store": "file:../atproto-store",
6483 "b4a": "^1.8.1",
6584 "bare-dns": "^2.1.4",
……@@ -72,6 +91,7 @@
7291 "corestore": "^7.4.0",
7392 "favela-community": "file:../favela-community",
7493 "hyper-identity": "file:../packages/hyper-identity",
94 "hyper-proto-peer": "file:../hyper-proto-peer",
7595 "hyper-svc": "file:../hyper-svc",
7696 "hyperbee": "^2.20.0",
7797 "hypercore-crypto": "^3.4.0",
……@@ -83,7 +103,7 @@
83103 }
84104 },
85105 "../hyper-proto-peer": {
86 "version": "0.3.109",
106 "version": "0.3.112",
87107 "hasInstallScript": true,
88108 "dependencies": {
89109 "@ipld/dag-cbor": "^9.2.1",
……@@ -2059,6 +2079,10 @@
20592079 "resolved": "../atproto-identity",
20602080 "link": true
20612081 },
2082 "node_modules/atproto-store": {
2083 "resolved": "../atproto-store",
2084 "link": true
2085 },
20622086 "node_modules/b4a": {
20632087 "version": "1.8.1",
20642088 "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.1.tgz",
……@@ -6204,6 +6228,21 @@
62046228 }
62056229 }
62066230 },
6231 "node_modules/react-native-webview": {
6232 "version": "14.0.1",
6233 "resolved": "https://registry.npmjs.org/react-native-webview/-/react-native-webview-14.0.1.tgz",
6234 "integrity": "sha512-lBUuvFk459Mo+5vUNqwzGvn+I8rDJE6zvOulG2hWWudeEOJfWTsQwOfuYnRu+27djUIXrESFfC+EQE3PGu+FWA==",
6235 "license": "MIT",
6236 "peer": true,
6237 "dependencies": {
6238 "escape-string-regexp": "^4.0.0",
6239 "invariant": "2.2.4"
6240 },
6241 "peerDependencies": {
6242 "react": "*",
6243 "react-native": "*"
6244 }
6245 },
62076246 "node_modules/react-refresh": {
62086247 "version": "0.14.2",
62096248 "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz",
package.json +4 -3
……@@ -1,7 +1,7 @@
11{
22 "name": "favela-android",
33 "version": "0.0.1",
4 "description": "favela-android \u2014 a React Native client over a bare-kit worklet running a FULL favela node (hyper-proto-peer + appview + hyperswarm) on the phone. favela-web is reference for the logic, NOT the rendering: this is a different rendering surface. See spec/architecture.md.",
4 "description": "favela-android — a React Native client over a bare-kit worklet running a FULL favela node (hyper-proto-peer + appview + hyperswarm) on the phone. favela-web is reference for the logic, NOT the rendering: this is a different rendering surface. See spec/architecture.md.",
55 "private": true,
66 "type": "module",
77 "main": "lib/node.js",
……@@ -29,7 +29,7 @@
2929 "keet-identity-key": "3.2.0"
3030 },
3131 "pgh": {
32 "note": "Not a pgh app. The deliverable is an APK, installed by Android's package manager. pgh-runtime is used INSIDE the app for worklet-bundle OTA only \u2014 see spec/architecture.md \u00a72 and favela-web/spec/mobile-node.md \u00a77."
32 "note": "Not a pgh app. The deliverable is an APK, installed by Android's package manager. pgh-runtime is used INSIDE the app for worklet-bundle OTA only — see spec/architecture.md §2 and favela-web/spec/mobile-node.md §7."
3333 },
3434 "overrides": {
3535 "hypercore": "11.34.0",
……@@ -46,6 +46,7 @@
4646 "react": "^19.2.8",
4747 "react-native": "^0.86.2",
4848 "react-native-bare-kit": "^0.14.5",
49 "react-native-fs": "^2.20.0"
49 "react-native-fs": "^2.20.0",
50 "react-native-webview": "^14.0.1"
5051 }
5152}