OverviewFilesCommitsIssuesDocsReleasesPeers

favela-android commits / f45bbec

port the remaining read screens to favela-web, and delete the verbs they replaced
Thread, Profile, Search, People and TagFeed now go through lib/favela-api.js. With Timeline (367a3f9) that is the whole read surface, and the ten IPC verbs behind it are gone: timeline, author, profile, thread, post, replies, tag, search, follows, followers, people. lib/node.js drops ~70 lines. NO FALLBACK. The screens previously read `api || client`, which looks safe and is not: the worklet's client reads its OWN appview — a SECOND index over the same records — so a fallback answers the same question from a different place and says nothing about it. requireApi() fails loudly instead; if favela-web is down the node log already names the reason. Silent disagreement is the bug this port removes. favela-web 0.3.52 adds hydrate=1 to /api/follows|followers so `people` survives as one request rather than N+1 in the client. What stays on IPC, deliberately: status, hostedDids, viewDid, trackedDids, untrackDid, blob, devices, revokeDevice, invite, adopt, syncFollows, collections — node-level operations hpp owns and favela-web has no route for — plus the writes (compose, deletePost, follow), which are the next piece. ⚠ Restored `compose`, which the first cut of this deletion removed by accident: the one-line `follows`/`followers` handlers do not end on a standalone `},`, so the block matcher ran past them and swallowed the next handler whole. Caught by cross-checking every UI call against surviving wrappers and handlers, which is now the check to run after any handler deletion — not by the suite, which never exercised compose. test/ipc.test.mjs now asserts the read verbs are ABSENT rather than testing them. Their absence is the invariant: a verb that still answered would be the second implementation this port exists to delete. Verified: 98 assertions green (adopt + ipc + session + favela-api), both JSX files parse, and every verb the UI calls has a wrapper AND a handler. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
f45bbecc75fd5c9fb19cc050a0d281553872d5b0
by plankatron <fleeky@gmail.com> · 2026-08-11T18:29:49.000Z

Changed files (6) +57 -129

app/App.jsx +17 -21
……@@ -12,7 +12,7 @@ import { View, Text, FlatList, RefreshControl, ActivityIndicator, StyleSheet, Pr
1212import { startNode, stopNode } from './worklet'
1313import Pair from './Pair'
1414import Post from './Post'
15import { Thread, Profile, Search, Compose, Settings, Invite, Identities, Communities, People, Header, Center } from './Screens'
15import { Thread, Profile, Search, Compose, Settings, Invite, Identities, Communities, People, Header, Center, requireApi } from './Screens'
1616import { apiFromStatus } from '../lib/favela-api'
1717
1818export default function App () {
……@@ -96,15 +96,15 @@ function Shell ({ client, status, onPaired }) {
9696 return (
9797 <SafeAreaView style={s.fill}>
9898 <Header title={title} onBack={nav.pop} />
99 {top.name === 'thread' ? <Thread client={client} uri={top.uri} nav={nav} /> : null}
100 {top.name === 'profile' ? <Profile client={client} did={top.did} nav={nav} /> : null}
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}
101101 {top.name === 'compose' ? <Compose client={client} replyTo={top.replyTo || null} onDone={nav.pop} /> : null}
102 {top.name === 'tag' ? <TagFeed client={client} tag={top.tag} nav={nav} /> : null}
102 {top.name === 'tag' ? <TagFeed client={client} api={api} tag={top.tag} nav={nav} /> : null}
103103 {top.name === 'pair' ? <Pair client={client} onPaired={async (r) => { await onPaired(r); nav.pop() }} /> : null}
104104 {top.name === 'invite' ? <Invite client={client} /> : null}
105105 {top.name === 'identities' ? <Identities client={client} current={acting} onPick={(d) => { setActingDid(d.did); nav.pop() }} /> : null}
106106 {top.name === 'communities' ? <Communities client={client} nav={nav} /> : null}
107 {top.name === 'people' ? <People client={client} did={top.did} kind={top.kind} nav={nav} /> : null}
107 {top.name === 'people' ? <People client={client} api={api} did={top.did} kind={top.kind} nav={nav} /> : null}
108108 </SafeAreaView>
109109 )
110110 }
……@@ -123,7 +123,7 @@ function Shell ({ client, status, onPaired }) {
123123 : null}
124124 <View style={s.fill}>
125125 {tab === 'home' ? <Timeline client={client} nav={nav} acting={acting} api={api} /> : null}
126 {tab === 'search' ? <Search client={client} nav={nav} /> : null}
126 {tab === 'search' ? <Search client={client} api={api} nav={nav} /> : null}
127127 {tab === 'browse' ? <Communities client={client} nav={nav} /> : null}
128128 {tab === 'settings' ? <Settings client={client} status={status} nav={nav} /> : null}
129129 </View>
……@@ -157,22 +157,16 @@ function Timeline ({ client, nav, acting, api }) {
157157 const community = acting && !acting.primary ? acting.did : null
158158 const load = useCallback(async (reset) => {
159159 try {
160 let r
161 if (api) {
162 // Both feeds now come from favela-web, through the ONE client that carries the session — a raw
163 // fetch here would silently drop the cookie and the rows would come back without liked/reposted
164 // state, with no error to notice.
165 r = community ? await api.author({ did: community, limit: 30 }) : await api.timeline({ limit: 30 })
166 } else {
167 r = community
168 ? await client.author({ did: community, limit: 30, cursor: reset ? null : cursor })
169 : await client.timeline({ limit: 30, cursor: reset ? null : cursor })
170 }
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 })
171165 setPosts((prev) => (reset ? (r.posts || []) : [...prev, ...(r.posts || [])]))
172166 setCursor(r.cursor)
173167 setErr(null)
174168 } catch (e) { setErr(e.message) }
175 }, [client, cursor, community, api])
169 }, [cursor, community, api])
176170
177171 // Reload when the identity changes — otherwise switching shows the previous identity's feed.
178172 useEffect(() => { setPosts([]); setCursor(null); load(true) }, [community]) // eslint-disable-line react-hooks/exhaustive-deps
……@@ -203,12 +197,14 @@ function Timeline ({ client, nav, acting, api }) {
203197 )
204198}
205199
206function TagFeed ({ client, tag, nav }) {
200function TagFeed ({ client, api, tag, nav }) {
207201 const [posts, setPosts] = useState([])
208202 const [err, setErr] = useState(null)
209203 useEffect(() => {
210 client.tag({ tag, limit: 30 }).then((r) => setPosts(r.posts || [])).catch((e) => setErr(e.message))
211 }, [client, tag])
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])
212208 if (err) return <Center><Text style={s.err}>{err}</Text></Center>
213209 return (
214210 <FlatList
app/Screens.jsx +22 -10
……@@ -51,9 +51,19 @@ export function PersonRow ({ client, person, onOpen }) {
5151 )
5252}
5353
54// The read surface lives in favela-web now. A screen must NOT silently fall back to the worklet's own
55// appview: that is a SECOND index over the same records, so a fallback would quietly show a different
56// answer to the same question. If favela-web is down, say so — the node log names the reason.
57export function requireApi (api) {
58 if (!api) throw new Error('favela-web is not running — the node log says why')
59 return api
60}
61
5462// Followers / following as a browsable list.
55export function People ({ client, did, kind, nav }) {
56 const { loading, error, data, reload } = useAsync(() => client.people({ did, kind }), [client, did, kind])
63export function People ({ client, api, did, kind, nav }) {
64 // hydrate=1 gets profiles in the SAME request; the fallback is the legacy verb, for a phone whose
65 // favela-web did not start.
66 const { loading, error, data, reload } = useAsync(() => requireApi(api).people({ did, kind }), [api, did, kind])
5767 if (loading || error) return <Loading error={error} onRetry={reload} />
5868 const people = (data && data.people) || []
5969 if (!people.length) return <Center><Text style={s.dim}>no {kind} held for this account yet</Text></Center>
……@@ -199,11 +209,12 @@ function Loading ({ error, onRetry }) {
199209// ── thread ───────────────────────────────────────────────────────────────────────────────────────────
200210// A post and its replies. The web page renders ancestors + the post + descendants; the appview's
201211// getPostThread returns exactly that tree, so this walks it rather than re-deriving structure.
202export function Thread ({ client, uri, nav }) {
212export function Thread ({ client, api, uri, nav }) {
203213 const { loading, error, data, reload } = useAsync(async () => {
204 const [{ thread }, { replies }] = await Promise.all([client.thread({ uri }), client.replies({ uri })])
214 const src = requireApi(api)
215 const [{ thread }, { replies }] = await Promise.all([src.thread({ uri }), src.replies({ uri })])
205216 return { thread, replies }
206 }, [uri])
217 }, [uri, api])
207218
208219 if (loading || error) return <Loading error={error} onRetry={reload} />
209220 const root = data.thread && (data.thread.post || data.thread)
……@@ -227,12 +238,13 @@ export function Thread ({ client, uri, nav }) {
227238}
228239
229240// ── profile ──────────────────────────────────────────────────────────────────────────────────────────
230export function Profile ({ client, did, nav }) {
241export function Profile ({ client, api, did, nav }) {
231242 const [busy, setBusy] = useState(false)
232243 const { loading, error, data, reload } = useAsync(async () => {
233 const [{ profile }, feed] = await Promise.all([client.profile({ did }), client.author({ did, limit: 30 })])
244 const src = requireApi(api)
245 const [{ profile }, feed] = await Promise.all([src.profile({ did }), src.author({ did, limit: 30 })])
234246 return { profile, posts: feed.posts || [] }
235 }, [did])
247 }, [did, api])
236248
237249 const toggleFollow = async () => {
238250 setBusy(true)
……@@ -268,7 +280,7 @@ export function Profile ({ client, did, nav }) {
268280}
269281
270282// ── search ───────────────────────────────────────────────────────────────────────────────────────────
271export function Search ({ client, nav }) {
283export function Search ({ client, api, nav }) {
272284 const [q, setQ] = useState('')
273285 const [kind, setKind] = useState('posts')
274286 const [res, setRes] = useState(null)
……@@ -277,7 +289,7 @@ export function Search ({ client, nav }) {
277289 const go = async () => {
278290 if (!q.trim()) return
279291 setBusy(true)
280 try { setRes(await client.search({ q: q.trim(), kind })) } catch (e) { setRes({ error: e.message }) } finally { setBusy(false) }
292 try { setRes(await requireApi(api).search({ q: q.trim(), kind })) } catch (e) { setRes({ error: e.message }) } finally { setBusy(false) }
281293 }
282294
283295 return (
lib/favela-api.js +5 -0
……@@ -66,6 +66,11 @@ export class FavelaApi {
6666 search ({ q, kind = 'posts', limit = 25 } = {}) { return this.get('/api/search', { q, kind, limit }) }
6767 follows ({ did } = {}) { return this.get('/api/follows', { did }) }
6868 followers ({ did } = {}) { return this.get('/api/followers', { did }) }
69 // hydrate=1 — profiles included, so a list of PEOPLE is one request rather than N+1. The bare-list
70 // form above stays for callers that only need DIDs and would discard the hydration.
71 people ({ did, kind = 'follows', limit = 200 } = {}) {
72 return this.get(kind === 'followers' ? '/api/followers' : '/api/follows', { did, limit, hydrate: 1 })
73 }
6974 tagFeed ({ tag, limit = 30 } = {}) { return this.get('/api/feed', { tag, limit }) }
7075}
7176
lib/ipc-client.js +0 -11
……@@ -127,19 +127,9 @@ export class WorkletClient {
127127
128128 // Typed wrappers. Thin on purpose — they exist so a screen never types an op string, not to add logic.
129129 status () { return this.request('status') }
130 timeline (params) { return this.request('timeline', params) }
131 author (params) { return this.request('author', params) }
132 profile (params) { return this.request('profile', params) }
133130 collections () { return this.request('collections') }
134131 // The ported surface. Thin by design — they exist so a screen never types an op string, not to add
135132 // logic; every one of them maps to an appview query or a device write in the worklet.
136 thread (params) { return this.request('thread', params) }
137 post (params) { return this.request('post', params) }
138 replies (params) { return this.request('replies', params) }
139 tag (params) { return this.request('tag', params) }
140 search (params) { return this.request('search', params) }
141 follows (params) { return this.request('follows', params) }
142 followers (params) { return this.request('followers', params) }
143133 compose (params) { return this.request('compose', params) }
144134 deletePost (params) { return this.request('deletePost', params) }
145135 follow (params) { return this.request('follow', params) }
……@@ -154,7 +144,6 @@ export class WorkletClient {
154144 viewDid (params) { return this.request('viewDid', params) }
155145 trackedDids () { return this.request('trackedDids') }
156146 untrackDid (params) { return this.request('untrackDid', params) }
157 people (params) { return this.request('people', params) }
158147 syncFollows (params) { return this.request('syncFollows', params) }
159148 devices () { return this.request('devices') }
160149 revokeDevice (params) { return this.request('revokeDevice', params) }
lib/node.js +0 -67
……@@ -305,16 +305,6 @@ const handlers = {
305305 // scope 'following' (default) — you + everyone you follow + anything you track (communities)
306306 // scope 'all' — every author in the local index, unfiltered (what it used to do)
307307 // scope '<did>' — one account/community
308 async timeline ({ limit = 30, cursor = null, scope = 'following', viewer = null } = {}) {
309 const me = viewerDid(viewer)
310 let authors = null
311 if (scope && scope.startsWith('did:')) authors = [scope]
312 else if (scope !== 'all') {
313 const following = me ? await state.appview.following(me).catch(() => []) : []
314 authors = [me, ...(following || []), ...readTracked()].filter(Boolean)
315 }
316 return state.appview.getTimeline({ limit, cursor, authors, viewerDid: me })
317 },
318308
319309 // Pull the repos of accounts you follow but do not host, so the scoped timeline has something in it.
320310 // Bounded and non-blocking on purpose: this is a phone, each one is a topic join plus a verify, and a
……@@ -334,12 +324,6 @@ const handlers = {
334324 }
335325 return { queued: todo.length, following: following.length }
336326 },
337 async author ({ did, limit = 30, cursor = null, filter } = {}) {
338 return state.appview.getAuthorFeed(did, { limit, cursor, filter })
339 },
340 async profile ({ did } = {}) {
341 return { profile: await state.appview.getProfile(did) }
342 },
343327 // The pairing ceremony runs IN THE WORKLET, never in the UI. lib/adopt.js touches keys, proofs and the
344328 // blind-pairing tunnel; a React Native component must not. The screen collects an invite and shows
345329 // progress — that is the whole of its involvement.
……@@ -407,19 +391,6 @@ const handlers = {
407391
408392 // People, hydrated. `follows`/`followers` returned bare DID strings, which a UI can only render as
409393 // hex — the reason there is no way to browse anyone. Same queries, with the profile attached.
410 async people ({ did = null, kind = 'follows', limit = 200 } = {}) {
411 const subject = did || viewerDid()
412 const dids = kind === 'followers'
413 ? await state.appview.followers(subject)
414 : await state.appview.following(subject)
415 const out = []
416 for (const d of (dids || []).slice(0, limit)) {
417 let p = null
418 try { p = await state.appview.getProfile(d) } catch {}
419 out.push({ did: d, handle: (p && p.handle) || null, displayName: (p && p.displayName) || null, avatar: (p && p.avatar) || null, description: (p && p.description) || null })
420 }
421 return { people: out, count: (dids || []).length }
422 },
423394
424395 // ── identities ────────────────────────────────────────────────────────────────────────────────────
425396 // Every DID this node hosts, not just the first. The daemon has always been multi-DID — a user's own
……@@ -499,44 +470,6 @@ const handlers = {
499470 // and every query are shared verbatim, so a screen here answers the same question the web page does
500471 // from the same code. `viewerDid` is this node's own DID so viewer state (liked/reposted/muted) is
501472 // hydrated before anything crosses IPC, which is what keeps a row a render rather than a lookup.
502 async thread ({ uri, depth = 6, parentHeight = 80, viewer = null } = {}) {
503 if (!uri) throw new Error('thread requires { uri }')
504 return { thread: await state.appview.getPostThread(uri, { depth, parentHeight, viewerDid: viewerDid(viewer) }) }
505 },
506 async post ({ uri, viewer = null } = {}) {
507 if (!uri) throw new Error('post requires { uri }')
508 return { post: await state.appview.getPost(uri, { viewerDid: viewerDid(viewer) }) }
509 },
510 async replies ({ uri, limit = 50, viewer = null } = {}) {
511 if (!uri) throw new Error('replies requires { uri }')
512 return { replies: await state.appview.getReplies(uri, { limit, viewerDid: viewerDid(viewer) }) }
513 },
514 async tag ({ tag, limit = 30, cursor = null, viewer = null } = {}) {
515 if (!tag) throw new Error('tag requires { tag }')
516 return state.appview.getTagFeed(tag, { limit, cursor, viewerDid: viewerDid(viewer) })
517 },
518 async search ({ q, kind = 'posts', limit = 25, viewer = null } = {}) {
519 if (!q) throw new Error('search requires { q }')
520 // The two differ in shape at the source and the op must not "tidy" that: searchPosts already
521 // returns { posts, cursor, truncated } (truncated matters — a scan-capped result is not an empty
522 // one), searchActors returns a bare array. Wrapping the first again produced {posts:{posts:[…]}}.
523 return kind === 'actors'
524 ? { actors: await state.appview.searchActors(q, { limit }) }
525 : await state.appview.searchPosts(q, { limit, viewerDid: viewerDid(viewer) })
526 },
527 async follows ({ did } = {}) { return { dids: await state.appview.following(did || viewerDid()) } },
528 async followers ({ did } = {}) { return { dids: await state.appview.followers(did || viewerDid()) } },
529
530 // ── the ported write surface ───────────────────────────────────────────────────────────────────────
531 // ⛔ EVERY WRITE GOES THROUGH THE DEVICE, never appview.put* directly. hpp is the authority for record
532 // BYTES: it hashes what it writes and commits that hash to the autobase. A second writer into its
533 // store is verbatim the 2026-07-31 clobber that permanently destroyed two records. The appview is
534 // updated AFTERWARDS so the local index reflects the write immediately (favela-web does the same) —
535 // that is an index update, not a second authority.
536 //
537 // The record SHAPES are not rebuilt here: favela-web's Writer builds them (scope->collection, facets,
538 // embeds, langs, the 300-grapheme cap) and this hands it the in-process device instead of a socket.
539 // Re-deriving those shapes is how a uri-only strongRef shipped once already.
540473 async compose ({ text, replyTo = null, quoteOf = null, lang = null, scope = 'public' } = {}) {
541474 const w = await writer()
542475 const langs = /^[a-z]{2}(-[A-Za-z0-9]{2,8})?$/.test(String(lang || '')) ? [String(lang)] : ['en']
test/ipc.test.mjs +13 -20
……@@ -92,31 +92,22 @@ console.log('\n# status')
9292 assert(s.signing === false, 'signing:false with no DID paired — honest about being read-only')
9393}
9494
95console.log('\n# timeline on an empty node')
96{
97 const t = await client.timeline({ limit: 10 })
98 assert(Array.isArray(t.posts), 'timeline returns a posts array')
99 assert(t.posts.length === 0, 'empty on a fresh node (not an error)')
100}
101
10295console.log('\n# collections — the #33 ledger, reachable from the phone')
10396{
10497 const c = await client.collections()
10598 assert(Array.isArray(c.ledger), 'collections returns the ingest ledger')
10699}
107100
108console.log('\n# the ported READ surface answers on an empty node')
101// ⛔ THE READ SURFACE IS NO LONGER AN IPC VERB. timeline/thread/replies/search/follows/followers/
102// people/tag/author/profile moved to favela-web's JSON routes (favela-web 0.3.51+), reached through
103// lib/favela-api.js — one engine, one index, one definition. Their absence here is the POINT: a verb
104// that still answered would be a second implementation reading a second appview, which is the bug this
105// port exists to remove. lib/favela-api.js has its own test; screens call it, never this client.
106console.log('\n# the read verbs are GONE — the routes own that surface now')
109107{
110 const t = await client.thread({ uri: 'at://did:plc:nobody/app.bsky.feed.post/none' })
111 assert('thread' in t, 'thread responds (null thread on an unknown uri is an answer, not an error)')
112 const r = await client.replies({ uri: 'at://did:plc:nobody/app.bsky.feed.post/none' })
113 assert(Array.isArray(r.replies), 'replies returns an array')
114 const se = await client.search({ q: 'anything' })
115 assert(Array.isArray(se.posts), 'search returns posts')
116 const sa = await client.search({ q: 'anything', kind: 'actors' })
117 assert(Array.isArray(sa.actors), 'search kind:actors returns actors')
118 const f = await client.follows({ did: 'did:plc:nobody' })
119 assert(Array.isArray(f.dids), 'follows returns a did array')
108 for (const dead of ['timeline', 'thread', 'replies', 'search', 'follows', 'followers', 'people', 'tag', 'author', 'profile']) {
109 assert(typeof client[dead] !== 'function', dead + ' is no longer an IPC verb')
110 }
120111}
121112
122113console.log('\n# the WRITE surface refuses cleanly on a node with no identity')
……@@ -140,8 +131,10 @@ console.log('\n# an unknown op is an error, not a hang')
140131
141132console.log('\n# id correlation — concurrent requests do not cross')
142133{
143 const [a, b, c] = await Promise.all([client.status(), client.timeline({ limit: 1 }), client.status()])
144 assert(a.ready === true && c.ready === true && Array.isArray(b.posts),
134 // Three DIFFERENT ops in flight at once — the point is that each reply finds its own caller, so they
135 // must be distinguishable by shape, not just by count.
136 const [a, b, c] = await Promise.all([client.status(), client.hostedDids(), client.trackedDids()])
137 assert(a.ready === true && Array.isArray(b.dids) && Array.isArray(c.dids),
145138 'three in flight resolve to their OWN replies')
146139}
147140