| … | … | @@ -1,19 +1,23 @@ |
| 1 | 1 | // favela-android — the app shell. |
| 2 | 2 | // |
| 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. |
| 6 | 7 | // |
| 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). |
| 10 | | import React, { useEffect, useState, useCallback, useMemo } from 'react' |
| 11 | | import { 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. |
| 15 | import React, { useEffect, useState, useCallback, useRef } from 'react' |
| 16 | import { View, Text, ActivityIndicator, StyleSheet, SafeAreaView, BackHandler, Pressable } from 'react-native' |
| 17 | import { WebView } from 'react-native-webview' |
| 12 | 18 | import { startNode, stopNode } from './worklet' |
| 13 | 19 | import Pair from './Pair' |
| 14 | | import Post from './Post' |
| 15 | | import { Thread, Profile, Search, Compose, Settings, Invite, Identities, Communities, People, Header, Center, requireApi } from './Screens' |
| 16 | | import { apiFromStatus } from '../lib/favela-api' |
| 20 | import { Center } from './Screens' |
| 17 | 21 | |
| 18 | 22 | export default function App () { |
| 19 | 23 | const [client, setClient] = useState(null) |
| … | … | @@ -44,188 +48,86 @@ export default function App () { |
| 44 | 48 | }, [boot, client]) |
| 45 | 49 | |
| 46 | 50 | 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} /> |
| 55 | 65 | } |
| 56 | 66 | |
| 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. |
| 59 | | function 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. |
| 67 | function 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. |
| 86 | 83 | useEffect(() => { |
| 87 | 84 | 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 } |
| 89 | 86 | return false |
| 90 | 87 | }) |
| 91 | 88 | return () => sub.remove() |
| 92 | | }, [stack.length]) |
| 89 | }, [canBack]) |
| 93 | 90 | |
| 94 | | if (top) { |
| 95 | | const title = top.name === 'tag' ? '#' + top.tag : top.name |
| 91 | if (failed) { |
| 96 | 92 | 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> |
| 109 | 98 | ) |
| 110 | 99 | } |
| 111 | 100 | |
| 112 | 101 | return ( |
| 113 | 102 | <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} |
| 118 | 123 | /> |
| 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> |
| 137 | 124 | </SafeAreaView> |
| 138 | 125 | ) |
| 139 | 126 | } |
| 140 | 127 | |
| 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. |
| 148 | | function 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 | | |
| 200 | | function 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 | | |
| 220 | 128 | const 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 } |
| 231 | 133 | }) |