OverviewFilesCommitsIssuesDocsReleasesPeers

favela-android / app / Post.jsx

197 lines · jsx ref:
// 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 }
})