| … | … | @@ -7,12 +7,13 @@ |
| 7 | 7 | // ⛔ NO ATPROTO KNOWLEDGE LIVES HERE. No CAR parsing, no MST, no signing, no record shapes beyond what |
| 8 | 8 | // the appview already hydrated. If a component ever needs atproto-repo, the boundary is in the wrong |
| 9 | 9 | // place (spec/architecture.md §3). |
| 10 | import React, { useEffect, useState, useCallback } from 'react' | |
| 10 | import React, { useEffect, useState, useCallback, useMemo } from 'react' | |
| 11 | 11 | import { View, Text, FlatList, RefreshControl, ActivityIndicator, StyleSheet, Pressable, SafeAreaView, BackHandler } from 'react-native' |
| 12 | 12 | import { startNode, stopNode } from './worklet' |
| 13 | 13 | import Pair from './Pair' |
| 14 | 14 | import Post from './Post' |
| 15 | 15 | import { Thread, Profile, Search, Compose, Settings, Invite, Identities, Communities, People, Header, Center } from './Screens' |
| 16 | import { apiFromStatus } from '../lib/favela-api' | |
| 16 | 17 | |
| 17 | 18 | export default function App () { |
| 18 | 19 | const [client, setClient] = useState(null) |
| … | … | @@ -69,6 +70,9 @@ function Shell ({ client, status, onPaired }) { |
| 69 | 70 | return () => { live = false } |
| 70 | 71 | }, [client]) |
| 71 | 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]) | |
| 72 | 76 | const nav = { |
| 73 | 77 | push: (screen) => setStack((st) => [...st, screen]), |
| 74 | 78 | pop: () => setStack((st) => st.slice(0, -1)) |
| … | … | @@ -118,7 +122,7 @@ function Shell ({ client, status, onPaired }) { |
| 118 | 122 | </Pressable> |
| 119 | 123 | : null} |
| 120 | 124 | <View style={s.fill}> |
| 121 | {tab === 'home' ? <Timeline client={client} nav={nav} acting={acting} favelaUrl={status && status.favelaUrl} /> : null} | |
| 125 | {tab === 'home' ? <Timeline client={client} nav={nav} acting={acting} api={api} /> : null} | |
| 122 | 126 | {tab === 'search' ? <Search client={client} nav={nav} /> : null} |
| 123 | 127 | {tab === 'browse' ? <Communities client={client} nav={nav} /> : null} |
| 124 | 128 | {tab === 'settings' ? <Settings client={client} status={status} nav={nav} /> : null} |
| … | … | @@ -141,7 +145,7 @@ function Shell ({ client, status, onPaired }) { |
| 141 | 145 | // |
| 142 | 146 | // The fallback is deliberate and temporary: if favela-web did not start, the screen still works on the |
| 143 | 147 | // legacy surface rather than being blank, and the log says which one is in use. |
| 144 | function Timeline ({ client, nav, acting, favelaUrl }) { | |
| 148 | function Timeline ({ client, nav, acting, api }) { | |
| 145 | 149 | const [posts, setPosts] = useState([]) |
| 146 | 150 | const [cursor, setCursor] = useState(null) |
| 147 | 151 | const [refreshing, setRefreshing] = useState(false) |
| … | … | @@ -154,10 +158,11 @@ function Timeline ({ client, nav, acting, favelaUrl }) { |
| 154 | 158 | const load = useCallback(async (reset) => { |
| 155 | 159 | try { |
| 156 | 160 | let r |
| 157 | if (favelaUrl && !community) { | |
| 158 | const res = await fetch(favelaUrl + '/api/timeline?limit=30') | |
| 159 | if (!res.ok) throw new Error('favela-web /api/timeline ' + res.status) | |
| 160 | r = await res.json() | |
| 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 }) | |
| 161 | 166 | } else { |
| 162 | 167 | r = community |
| 163 | 168 | ? await client.author({ did: community, limit: 30, cursor: reset ? null : cursor }) |
| … | … | @@ -167,7 +172,7 @@ function Timeline ({ client, nav, acting, favelaUrl }) { |
| 167 | 172 | setCursor(r.cursor) |
| 168 | 173 | setErr(null) |
| 169 | 174 | } catch (e) { setErr(e.message) } |
| 170 | }, [client, cursor, community, favelaUrl]) | |
| 175 | }, [client, cursor, community, api]) | |
| 171 | 176 | |
| 172 | 177 | // Reload when the identity changes — otherwise switching shows the previous identity's feed. |
| 173 | 178 | useEffect(() => { setPosts([]); setCursor(null); load(true) }, [community]) // eslint-disable-line react-hooks/exhaustive-deps |
| … | … | @@ -0,0 +1,76 @@ |
| 1 | // The client for favela-web's HTTP API — the ONE place that knows the URL shape and how the session | |
| 2 | // is carried. | |
| 3 | // | |
| 4 | // ⛔ NO SCREEN MAY CALL fetch(favelaUrl + …) DIRECTLY. The moment two components build their own | |
| 5 | // request the session handling has two definitions, and the one that forgets the cookie degrades | |
| 6 | // SILENTLY: favela-web answers 200 with viewerDid null, so the posts render un-liked, un-reposted and | |
| 7 | // unmuted, and nothing anywhere says why. That is the same "two definitions drift apart" failure the | |
| 8 | // IPC client exists to prevent (see lib/ipc-client.js), one layer out. | |
| 9 | // | |
| 10 | // The session is a cookie because favela-web is a web server and the browser client is its first | |
| 11 | // consumer. There is no cookie jar here, so we set the header explicitly — bare-fetch and RN's fetch | |
| 12 | // differ on jar behaviour and relying on either would be a silent per-platform difference. | |
| 13 | ||
| 14 | const DEFAULT_TIMEOUT = 30_000 | |
| 15 | ||
| 16 | export class FavelaApi { | |
| 17 | /** | |
| 18 | * @param {object} opts | |
| 19 | * @param {string} opts.baseUrl e.g. http://127.0.0.1:4041 | |
| 20 | * @param {string?} opts.session favela_session token, or null for anonymous | |
| 21 | * @param {Function} [opts.fetchImpl] defaults to globalThis.fetch | |
| 22 | * @param {number} [opts.timeout] | |
| 23 | */ | |
| 24 | constructor ({ baseUrl, session = null, fetchImpl = null, timeout = DEFAULT_TIMEOUT } = {}) { | |
| 25 | if (!baseUrl) throw new Error('FavelaApi requires baseUrl') | |
| 26 | this.baseUrl = String(baseUrl).replace(/\/+$/, '') | |
| 27 | this.session = session | |
| 28 | this._fetch = fetchImpl || (typeof globalThis.fetch === 'function' ? globalThis.fetch.bind(globalThis) : null) | |
| 29 | if (!this._fetch) throw new Error('FavelaApi: no fetch available') | |
| 30 | this._timeout = timeout | |
| 31 | } | |
| 32 | ||
| 33 | /** True when requests carry a viewer. A screen that renders viewer-dependent UI should check this. */ | |
| 34 | get authenticated () { return !!this.session } | |
| 35 | ||
| 36 | url (path, params) { | |
| 37 | const qs = params | |
| 38 | ? Object.entries(params) | |
| 39 | .filter(([, v]) => v !== undefined && v !== null && v !== '') | |
| 40 | .map(([k, v]) => encodeURIComponent(k) + '=' + encodeURIComponent(String(v))) | |
| 41 | .join('&') | |
| 42 | : '' | |
| 43 | return this.baseUrl + path + (qs ? '?' + qs : '') | |
| 44 | } | |
| 45 | ||
| 46 | async get (path, params) { | |
| 47 | const headers = {} | |
| 48 | // Explicit, not jar-dependent — see the header note above. | |
| 49 | if (this.session) headers.cookie = 'favela_session=' + this.session | |
| 50 | const res = await this._fetch(this.url(path, params), { headers }) | |
| 51 | let body = null | |
| 52 | try { body = await res.json() } catch { /* non-JSON error page — fall through to status */ } | |
| 53 | if (!res.ok) { | |
| 54 | // Name the ROUTE. "HTTP 400" alone sends the reader to the wrong screen. | |
| 55 | throw new Error('favela-web ' + path + ' → ' + res.status + (body && body.error ? ': ' + body.error : '')) | |
| 56 | } | |
| 57 | return body | |
| 58 | } | |
| 59 | ||
| 60 | // Typed wrappers. Thin on purpose — they exist so a screen never types a path, not to add logic. | |
| 61 | timeline ({ limit = 30 } = {}) { return this.get('/api/timeline', { limit }) } | |
| 62 | author ({ did, limit = 30 } = {}) { return this.get('/api/author', { did, limit }) } | |
| 63 | profile ({ did } = {}) { return this.get('/api/profile', { did }) } | |
| 64 | thread ({ uri, depth, parentHeight } = {}) { return this.get('/api/thread', { uri, depth, parentHeight }) } | |
| 65 | replies ({ uri, limit = 50 } = {}) { return this.get('/api/replies', { uri, limit }) } | |
| 66 | search ({ q, kind = 'posts', limit = 25 } = {}) { return this.get('/api/search', { q, kind, limit }) } | |
| 67 | follows ({ did } = {}) { return this.get('/api/follows', { did }) } | |
| 68 | followers ({ did } = {}) { return this.get('/api/followers', { did }) } | |
| 69 | tagFeed ({ tag, limit = 30 } = {}) { return this.get('/api/feed', { tag, limit }) } | |
| 70 | } | |
| 71 | ||
| 72 | /** Build an API client from the worklet's `status`, or null if favela-web is not up. */ | |
| 73 | export function apiFromStatus (status, fetchImpl = null) { | |
| 74 | if (!status || !status.favelaUrl) return null | |
| 75 | return new FavelaApi({ baseUrl: status.favelaUrl, session: status.favelaSession || null, fetchImpl }) | |
| 76 | } |
| … | … | @@ -9,7 +9,7 @@ |
| 9 | 9 | "pack:worklet": "bare-pack --preset android --defer bare-abstract-socket --defer pgh-runtime --out android/app/src/main/assets/worklet.bundle lib/node.js", |
| 10 | 10 | "stage:addons": "node scripts/stage-addons.mjs", |
| 11 | 11 | "prebuild:android": "npm run pack:worklet && npm run stamp:worklet && npm run stage:addons", |
| 12 | "test": "bare test/adopt.test.mjs && bare test/ipc.test.mjs && bare test/session.test.mjs", | |
| 12 | "test": "bare test/adopt.test.mjs && bare test/ipc.test.mjs && bare test/session.test.mjs && bare test/favela-api.test.mjs", | |
| 13 | 13 | "stamp:worklet": "node -e \"const fs=require('fs');const p='android/app/src/main/assets/worklet.bundle';fs.writeFileSync(p+'.size',String(fs.statSync(p).size))\"" |
| 14 | 14 | }, |
| 15 | 15 | "dependencies": { |
| … | … | @@ -0,0 +1,64 @@ |
| 1 | // The favela-web HTTP client. Pure — a fake fetch records what would go on the wire. | |
| 2 | // | |
| 3 | // The claim under test is narrow and load-bearing: every request carries the session cookie when one | |
| 4 | // exists. A client that quietly omits it still gets 200s back, with viewerDid null — so the bug shows | |
| 5 | // up as posts rendering un-liked, never as an error. That is exactly why it is asserted here rather | |
| 6 | // than left to a device. | |
| 7 | // | |
| 8 | // Run: bare test/favela-api.test.mjs | |
| 9 | import { FavelaApi, apiFromStatus } from '../lib/favela-api.js' | |
| 10 | ||
| 11 | const assert = (c, m) => { if (!c) { console.error('FAIL:', m); process.exit(1) } console.log(' ok -', m) } | |
| 12 | ||
| 13 | const calls = [] | |
| 14 | const fakeFetch = async (url, init) => { | |
| 15 | calls.push({ url, init }) | |
| 16 | return { ok: true, status: 200, json: async () => ({ posts: [], url }) } | |
| 17 | } | |
| 18 | ||
| 19 | console.log('url building') | |
| 20 | const api = new FavelaApi({ baseUrl: 'http://127.0.0.1:4041/', session: 'tok-1', fetchImpl: fakeFetch }) | |
| 21 | assert(api.baseUrl === 'http://127.0.0.1:4041', 'trailing slash trimmed (else //api/…)') | |
| 22 | assert(api.url('/api/timeline', { limit: 30 }) === 'http://127.0.0.1:4041/api/timeline?limit=30', 'query string built') | |
| 23 | assert(api.url('/api/thread', { uri: 'at://x/y/z' }).includes('uri=at%3A%2F%2Fx%2Fy%2Fz'), 'params are URL-ENCODED (an at:// uri must survive)') | |
| 24 | assert(api.url('/api/search', { q: 'a', kind: null, limit: undefined }) === 'http://127.0.0.1:4041/api/search?q=a', 'null/undefined params dropped, not sent as "null"') | |
| 25 | ||
| 26 | console.log('session') | |
| 27 | assert(api.authenticated === true, 'reports authenticated when it holds a token') | |
| 28 | await api.timeline({ limit: 5 }) | |
| 29 | assert(calls[0].init.headers.cookie === 'favela_session=tok-1', 'sends the session cookie') | |
| 30 | ||
| 31 | const anon = new FavelaApi({ baseUrl: 'http://x', fetchImpl: fakeFetch }) | |
| 32 | assert(anon.authenticated === false, 'reports anonymous without a token') | |
| 33 | await anon.timeline() | |
| 34 | assert(!calls[1].init.headers.cookie, 'sends NO cookie when anonymous (rather than an empty one)') | |
| 35 | ||
| 36 | console.log('every typed wrapper carries the session') | |
| 37 | // The regression that matters: a wrapper added later that forgets the cookie. Assert across ALL of them | |
| 38 | // rather than trusting the one that happened to be tested. | |
| 39 | calls.length = 0 | |
| 40 | await api.timeline(); await api.author({ did: 'd' }); await api.profile({ did: 'd' }) | |
| 41 | await api.thread({ uri: 'u' }); await api.replies({ uri: 'u' }); await api.search({ q: 'x' }) | |
| 42 | await api.follows({ did: 'd' }); await api.followers({ did: 'd' }); await api.tagFeed({ tag: 't' }) | |
| 43 | assert(calls.length === 9, 'nine wrappers exercised') | |
| 44 | assert(calls.every((c) => c.init.headers.cookie === 'favela_session=tok-1'), 'ALL nine send the cookie') | |
| 45 | assert(calls.map((c) => c.url).every((u) => u.startsWith('http://127.0.0.1:4041/api/')), 'all hit /api/') | |
| 46 | ||
| 47 | console.log('errors name the route') | |
| 48 | const failing = new FavelaApi({ | |
| 49 | baseUrl: 'http://x', | |
| 50 | fetchImpl: async () => ({ ok: false, status: 400, json: async () => ({ error: 'uri required' }) }) | |
| 51 | }) | |
| 52 | let e = null | |
| 53 | try { await failing.thread({ uri: '' }) } catch (err) { e = err } | |
| 54 | assert(e && /\/api\/thread/.test(e.message) && /uri required/.test(e.message), 'error names the route AND the reason') | |
| 55 | ||
| 56 | console.log('apiFromStatus') | |
| 57 | assert(apiFromStatus(null) === null, 'null when there is no status') | |
| 58 | assert(apiFromStatus({ favelaUrl: null }) === null, 'null when favela-web did not start') | |
| 59 | const built = apiFromStatus({ favelaUrl: 'http://127.0.0.1:4041', favelaSession: 'tok-2' }, fakeFetch) | |
| 60 | assert(built.authenticated && built.session === 'tok-2', 'carries the session through from status') | |
| 61 | const anonBuilt = apiFromStatus({ favelaUrl: 'http://127.0.0.1:4041', favelaSession: null }, fakeFetch) | |
| 62 | assert(anonBuilt.authenticated === false, 'anonymous when sign-in failed — the screen can SAY so') | |
| 63 | ||
| 64 | console.log('\nall favela-api tests passed') |