// One post row — the port of favela-web's renderPost/byline/renderReach.
//
// The appview hydrates author, counts and viewer state BEFORE anything crosses IPC, so this component
// is a pure render and never a lookup. That property is what makes a FlatList of these viable on a
// phone; if a row ever has to await something, the port has gone wrong.
//
// ⛔ REACH: `observed` and `eligible` render DIFFERENTLY and that is the point. `fediverse` is observed
// (ap-bridge's outbox names the rkey, so it WAS published); `bsky` is eligible (the lexicon is one bsky
// indexes, so the post was OFFERED — whether it indexed needs a per-post call we do not make on a
// timeline). Rendering them alike would claim something unverified, which is the habit that made the
// federation outages hard to diagnose. `favela` is never badged: true of every post here, so it carries
// no information. Absence is silent — no badge means not projected OR not asked.
import React, { useState, useEffect } from 'react'
import { View, Text, Pressable, StyleSheet, Image } from 'react-native'
import RichText from './RichText'
import { displayName, subHandle, relTime } from '../lib/richtext'
const REACH = {
bsky: { label: 'bsky', observed: false },
fediverse: { label: 'fediverse', observed: true }
}
export default function Post ({ post, client, onOpen, onProfile, onTag, onLike, onRepost }) {
const rec = post.record || {}
const name = displayName(post)
const sub = subHandle(post)
const reach = Array.isArray(post.reach) ? post.reach : []
return (
<Pressable style={s.post} onPress={() => onOpen && onOpen(post)}>
<View style={s.head}>
<Pressable onPress={() => onProfile && onProfile(post.did)} hitSlop={6}>
<Text style={s.name} numberOfLines={1}>
{name}
{sub ? <Text style={s.handle}> {sub}</Text> : null}
</Text>
</Pressable>
<Text style={s.time}>{relTime(rec.createdAt)}</Text>
</View>
<RichText text={rec.text} facets={rec.facets} onTag={onTag} onMention={onProfile} />
{rec.embed ? <Embed embed={rec.embed} client={client} did={post.did} /> : null}
<View style={s.foot}>
<Pressable hitSlop={8} onPress={() => onLike && onLike(post)}>
<Text style={[s.act, post.viewer && post.viewer.like ? s.actOn : null]}>♥ {post.likeCount ?? 0}</Text>
</Pressable>
<Pressable hitSlop={8} onPress={() => onRepost && onRepost(post)}>
<Text style={[s.act, post.viewer && post.viewer.repost ? s.actOn : null]}>↻ {post.repostCount ?? 0}</Text>
</Pressable>
<Text style={s.act}>💬 {post.replyCount ?? 0}</Text>
{rec.editedAt || post.editedAt ? <Text style={s.dim}>edited</Text> : null}
<View style={s.spacer} />
{reach.map((r) => {
const meta = REACH[r]
if (!meta) return null
return (
<Text key={r} style={[s.badge, meta.observed ? s.badgeObserved : s.badgeEligible]}>
{meta.label}
</Text>
)
})}
</View>
</Pressable>
)
}
// Blob bytes are fetched ONCE per cid and shared by every row that shows them. A timeline scrolls the
// same avatars and images past repeatedly, and each fetch is a base64 round-trip across the worklet —
// without this, scrolling back re-fetches everything. Bounded, because this is a phone: oldest-out at a
// small cap, which is enough for a screenful and cannot grow into the app's memory ceiling.
const BLOB_CACHE = new Map()
const BLOB_CACHE_MAX = 48
function cacheGet (cid) { return BLOB_CACHE.get(cid) }
function cachePut (cid, uri) {
if (BLOB_CACHE.has(cid)) BLOB_CACHE.delete(cid)
BLOB_CACHE.set(cid, uri)
while (BLOB_CACHE.size > BLOB_CACHE_MAX) BLOB_CACHE.delete(BLOB_CACHE.keys().next().value)
}
// One image from a record's blob ref. The ref is passed through UNPARSED — the worklet resolves it with
// favela-web's own blobCid, so the four ref shapes have one definition rather than a second one here.
//
// Three outcomes, and they must not look alike: bytes (render), `found:false` (the pointer replicated
// but the bytes have not — or the blob is genuinely gone, as wj62's avatar is), and a real error. The
// middle case is normal on a P2P client and says so quietly instead of looking broken.
export function BlobImage ({ client, did, imgRef, alt, aspectRatio, style }) {
const cid = typeof imgRef === 'string' ? imgRef : (imgRef && (imgRef.$link || imgRef['/'])) || null
const [uri, setUri] = useState(() => (cid ? cacheGet(cid) || null : null))
const [state, setState] = useState(() => (cid && cacheGet(cid) ? 'ok' : 'loading'))
useEffect(() => {
if (!client || !imgRef || uri) return
let live = true
client.blob({ did, ref: imgRef })
.then((r) => {
if (!live) return
if (r && r.found) {
const u = 'data:' + (r.mimeType || 'image/jpeg') + ';base64,' + r.base64
if (r.cid) cachePut(r.cid, u)
setUri(u); setState('ok')
} else setState(r && r.tooLarge ? 'too-large' : 'missing')
})
.catch(() => { if (live) setState('error') })
return () => { live = false }
}, [client, did, cid])
if (state === 'ok' && uri) {
const ar = aspectRatio && aspectRatio.width && aspectRatio.height ? aspectRatio.width / aspectRatio.height : 4 / 3
return <Image source={{ uri }} style={style || [s.image, { aspectRatio: ar }]} resizeMode="cover" accessibilityLabel={alt || undefined} />
}
if (state === 'loading') return <View style={style || [s.image, s.imagePlaceholder]} />
return <Text style={s.dim}>{state === 'missing' ? 'image not here yet' : state === 'too-large' ? 'image too large to display' : 'image failed'}</Text>
}
// Embeds. Images render from blob bytes fetched over IPC (BlobImage); quotes and external cards are
// text-shaped and render inline.
function Embed ({ embed, client, did }) {
const type = String(embed.$type || '')
if (type.endsWith('#recordWithMedia')) {
return (
<View>
<Embed embed={embed.media} client={client} did={did} />
<Quote rec={embed.record && embed.record.record} />
</View>
)
}
if (type.endsWith('embed.record')) return <Quote rec={embed.record} />
if (type.endsWith('embed.external')) {
const e = embed.external || {}
return (
<View style={s.card}>
<Text style={s.cardTitle} numberOfLines={2}>{e.title || e.uri}</Text>
{e.description ? <Text style={s.cardDesc} numberOfLines={3}>{e.description}</Text> : null}
<Text style={s.cardUri} numberOfLines={1}>{e.uri}</Text>
</View>
)
}
if (type.endsWith('embed.images')) {
const imgs = Array.isArray(embed.images) ? embed.images : []
if (!imgs.length) return null
return (
<View style={s.media}>
{imgs.map((img, i) => (
<BlobImage
key={i}
client={client}
did={did}
imgRef={img.image && img.image.ref ? img.image.ref : img.image}
alt={img.alt}
aspectRatio={img.aspectRatio}
/>
))}
</View>
)
}
// Video stays a placeholder DELIBERATELY. A base64 round-trip on this wire cannot stream, and a
// <video> without range support cannot seek — favela-web renders one today and that is exactly why
// seeking misbehaves there. It needs a streaming transport, not a bigger blob call.
if (type.endsWith('embed.video')) return <Text style={s.dim}>video (not playable yet)</Text>
return null
}
function Quote ({ rec }) {
if (!rec || !rec.uri) return null
return (
<View style={s.quote}>
<Text style={s.dim} numberOfLines={1}>quoting {rec.uri.replace('at://', '')}</Text>
</View>
)
}
const s = StyleSheet.create({
media: { marginTop: 8, gap: 6 },
image: { width: '100%', borderRadius: 10, backgroundColor: '#e6e8eb' },
imagePlaceholder: { aspectRatio: 4 / 3 },
post: { padding: 14, borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: '#ddd' },
head: { flexDirection: 'row', alignItems: 'baseline', marginBottom: 4, gap: 6 },
name: { fontWeight: '600', flexShrink: 1 },
handle: { color: '#888', fontWeight: '400', fontSize: 13 },
time: { color: '#888', fontSize: 12, marginLeft: 'auto' },
foot: { flexDirection: 'row', alignItems: 'center', gap: 16, marginTop: 8 },
act: { color: '#666', fontSize: 13 },
actOn: { color: '#c0392b' },
spacer: { flex: 1 },
dim: { color: '#888', fontSize: 12 },
badge: { fontSize: 11, paddingHorizontal: 6, paddingVertical: 2, borderRadius: 4, overflow: 'hidden', marginLeft: 4 },
badgeObserved: { backgroundColor: '#e6f4ea', color: '#1e6b34' },
badgeEligible: { backgroundColor: '#eef1f5', color: '#55606e' },
card: { borderWidth: 1, borderColor: '#ddd', borderRadius: 8, padding: 10, marginTop: 8 },
cardTitle: { fontWeight: '600', fontSize: 14 },
cardDesc: { color: '#444', fontSize: 13, marginTop: 2 },
cardUri: { color: '#888', fontSize: 11, marginTop: 4 },
quote: { borderLeftWidth: 3, borderLeftColor: '#ddd', paddingLeft: 10, marginTop: 8 }
})