// Facets → renderable segments. The port of favela-web's renderRichText, minus the HTML.
//
// favela-web emits an HTML string; React Native has no HTML. What is actually worth porting is the
// SEGMENTATION — which is where all the correctness lives — so this returns plain data and the RN
// component decides how a link looks. That also makes it testable with no device (test/richtext.test.mjs).
//
// ⛔ OFFSETS ARE BYTES. `facet.index` indexes the UTF-8 encoding of `text`, not the JS string. Slicing
// the string directly mis-cuts everything after the first non-ASCII character — every emoji, every
// accented word. So the walk goes through TextEncoder/TextDecoder.
//
// ⛔ FACETS ARE PUBLISHED DATA, NOT A REGEX RESULT. They are what the author declared. A link whose
// label differs from its target, a mention, or a tag not spelled with '#' cannot be recovered by
// re-parsing the text, and inventing them disagrees with what federated.
//
// Segment kinds: text | link{uri} | tag{tag} | mention{did}. Concatenating every `.text` always
// reproduces the input exactly — the tests assert this on every case, because a renderer that drops or
// duplicates a character is a silent corruption of someone's words.
//
// ⛔ NOT TextEncoder/TextDecoder. Bare does not define them (that is what bare-globals installs) and
// React Native does not guarantee them either, so a module that reaches for them works in node, passes
// review, and throws on both surfaces this actually runs on. lib/utf8.js is the dependency-free codec.
import { encodeUtf8, decodeUtf8 } from './utf8.js'
const KIND = [
['#link', (f) => (typeof f.uri === 'string' && /^https?:\/\//i.test(f.uri) ? { kind: 'link', uri: f.uri } : null)],
['#tag', (f) => (typeof f.tag === 'string' ? { kind: 'tag', tag: f.tag } : null)],
['#mention', (f) => (typeof f.did === 'string' ? { kind: 'mention', did: f.did } : null)]
]
function classify (features) {
const f = (Array.isArray(features) ? features : [])[0] || {}
const type = String(f.$type || '')
for (const [suffix, build] of KIND) if (type.endsWith(suffix)) return build(f)
return null // unknown feature: render the text, invent nothing
}
export function segments (text, facets) {
const t = String(text == null ? '' : text)
const push = (out, kind, str, extra) => { if (str) out.push({ kind, text: str, ...(extra || {}) }) }
if (!Array.isArray(facets) || !facets.length) return t ? [{ kind: 'text', text: t }] : []
let bytes
try { bytes = encodeUtf8(t) } catch { return [{ kind: 'text', text: t }] }
const slice = (a, b) => { try { return decodeUtf8(bytes.slice(a, b)) } catch { return '' } }
// Sorted, in range, non-inverted. A malformed set degrades to plain text rather than interleaving.
const ranges = facets
.filter((f) => f && f.index && Number.isInteger(f.index.byteStart) && Number.isInteger(f.index.byteEnd))
.filter((f) => f.index.byteStart >= 0 && f.index.byteEnd <= bytes.length && f.index.byteStart < f.index.byteEnd)
.sort((a, b) => a.index.byteStart - b.index.byteStart)
const out = []
let at = 0
for (const f of ranges) {
if (f.index.byteStart < at) continue // overlaps the previous facet — skip, never nest
const meta = classify(f.features)
const label = slice(f.index.byteStart, f.index.byteEnd)
push(out, 'text', slice(at, f.index.byteStart))
if (meta) push(out, meta.kind, label, meta)
else push(out, 'text', label)
at = f.index.byteEnd
}
push(out, 'text', slice(at, bytes.length))
return out
}
// did:plc:wj62ebbusln6gxo5azo6ogzo -> wj62ebbu…ogzo. Enough to tell two authors apart without spending
// a line on an opaque string. Ported verbatim from render.js so both surfaces shorten identically.
export function shortDid (did) {
const d = String(did || '')
const id = d.startsWith('did:plc:') ? d.slice('did:plc:'.length) : d
return id.length > 14 ? id.slice(0, 8) + '…' + id.slice(-4) : id
}
// "2h", "3d" — the form a reader scans. Ported from render.js.
export function relTime (iso, now = Date.now()) {
const t = Date.parse(iso)
if (!Number.isFinite(t)) return String(iso || '')
const secs = Math.round((now - t) / 1000)
if (secs < 0) return 'just now'
if (secs < 60) return secs + 's'
const mins = Math.round(secs / 60); if (mins < 60) return mins + 'm'
const hrs = Math.round(mins / 60); if (hrs < 24) return hrs + 'h'
const days = Math.round(hrs / 24); if (days < 7) return days + 'd'
const wks = Math.round(days / 7); if (wks < 5) return wks + 'w'
const mos = Math.round(days / 30); if (mos < 12) return mos + 'mo'
return Math.round(days / 365) + 'y'
}
// displayName -> VERIFIED handle -> short DID. The handle shows ONLY when the resolver verified it
// bidirectionally (it yields 'handle.invalid' otherwise), so a DID claiming someone else's handle can
// never render as that person. Same rule as favela-web's byline.
export function displayName (post) {
const a = (post && post.author) || {}
return a.displayName || (a.handleVerified && a.handle ? '@' + a.handle : shortDid(post && post.did))
}
export function subHandle (post) {
const a = (post && post.author) || {}
return (a.handleVerified && a.handle && a.displayName) ? '@' + a.handle : null
}