// extractThreadReplies — turning an app.bsky.feed.getPostThread body into attributed rows.
//
// The union is the whole point. A thread node is #threadViewPost, #notFoundPost or #blockedPost, and only
// the first carries a post. Rendering a blocked node as though we had its content would present ANOTHER
// AppView's moderation decision as our own; dropping it is the correct reading, not a convenience.
//
// The other property pinned here is de-duplication for DISPLAY: a reply this node already indexed belongs
// in our tree with our affordances, so the lens section must not repeat it. That is not a merge — no
// counts are combined and nothing is reconciled.
//
// Run: node test/lens-thread-extract.test.mjs
import { extractThreadReplies } from '../lib/lens-import.js'
const assert = (cond, msg) => { if (!cond) throw new Error('FAIL: ' + msg); console.log(' ok -', msg) }
const view = (uri, text, extra = {}) => ({
$type: 'app.bsky.feed.defs#threadViewPost',
post: {
uri,
cid: 'bafycid',
author: { did: 'did:plc:remote', handle: 'someone.bsky.social', displayName: 'Someone', avatar: 'https://cdn/x.jpg' },
record: { text, createdAt: '2026-07-30T10:00:00.000Z' },
replyCount: 2,
...extra
}
})
console.log('\n# the happy path')
const body = { thread: { $type: 'app.bsky.feed.defs#threadViewPost', post: { uri: 'at://root' }, replies: [view('at://a', 'hello'), view('at://b', 'world')] } }
const rows = extractThreadReplies(body)
assert(rows.length === 2, 'both replies are extracted')
assert(rows[0].uri === 'at://a' && rows[0].text === 'hello', 'uri and text carry through')
assert(rows[0].handle === 'someone.bsky.social' && rows[0].displayName === 'Someone', 'author attribution carries through')
assert(rows[0].replyCount === 2, 'the lens\'s OWN reply count rides along, to be shown as theirs')
assert(rows[0].createdAt === '2026-07-30T10:00:00.000Z', 'and the timestamp')
console.log('\n# the union members that must NOT be rendered')
const mixed = { thread: { replies: [
view('at://ok', 'visible'),
{ $type: 'app.bsky.feed.defs#notFoundPost', uri: 'at://gone', notFound: true },
{ $type: 'app.bsky.feed.defs#blockedPost', uri: 'at://blocked', blocked: true, author: { did: 'did:plc:x' } }
] } }
const m = extractThreadReplies(mixed)
assert(m.length === 1 && m[0].uri === 'at://ok', 'notFound and blocked nodes are dropped')
assert(!m.some((r) => r.uri === 'at://blocked'), 'a blocked post is THEIR moderation call — we do not render it as content')
console.log('\n# de-duplication against what we already hold')
const dedup = extractThreadReplies(body, { exclude: new Set(['at://a']) })
assert(dedup.length === 1 && dedup[0].uri === 'at://b', 'a reply we already indexed is not repeated under the lens')
assert(extractThreadReplies(body, { exclude: new Set(['at://a', 'at://b']) }).length === 0, 'if we hold them all, the lens section is empty')
console.log('\n# malformed input is survivable — this data is from another service')
assert(extractThreadReplies(null).length === 0, 'null body')
assert(extractThreadReplies({}).length === 0, 'no thread')
assert(extractThreadReplies({ thread: {} }).length === 0, 'no replies array')
assert(extractThreadReplies({ thread: { replies: 'nope' } }).length === 0, 'replies is not an array')
assert(extractThreadReplies({ thread: { replies: [null, 42, 'x'] } }).length === 0, 'junk entries')
assert(extractThreadReplies({ thread: { replies: [{ $type: 'app.bsky.feed.defs#threadViewPost' }] } }).length === 0, 'a node with no post')
assert(extractThreadReplies({ thread: { replies: [{ post: { uri: 'at://z' } }] } }).length === 1, 'a node with no $type but a real post is kept')
const sparse = extractThreadReplies({ thread: { replies: [{ post: { uri: 'at://s' } }] } })[0]
assert(sparse.text === '' && sparse.handle === null && sparse.replyCount === 0, 'missing fields normalize rather than land as undefined')
console.log('\n# nested descendants are dropped on purpose')
const deep = { thread: { replies: [{ ...view('at://p', 'parent'), replies: [view('at://c', 'child')] }] } }
const d = extractThreadReplies(deep)
assert(d.length === 1 && d[0].uri === 'at://p', 'one level only — we do not reproduce a remote AppView\'s tree inside ours')
// ── rendering: remote content is UNTRUSTED ──────────────────────────────────────────────────────
// This is the one place favela puts another service's bytes on its own origin. Escaping is not a nicety
// here; a lens is an injection vector if renderLensReplies ever reaches for the rich-text path.
console.log('\n# remote content is escaped, and rendered without affordances we cannot honour')
const { postPage } = await import('../lib/render.js')
const evil = '<script>alert(1)</script>'
const local = { did: 'did:plc:me', rkey: 'r1', uri: 'at://did:plc:me/app.bsky.feed.post/r1', createdAt: '2026-07-30T09:00:00.000Z', record: { text: 'ours' }, author: {}, likeCount: 0, replyCount: 0, repostCount: 0 }
const page = postPage({
post: local,
viewer: { did: 'did:plc:me' },
lensesOn: true,
lensReplies: [{ lens: 'bsky', replies: [{ uri: 'at://x', did: 'did:plc:r', handle: evil, displayName: evil, text: evil, createdAt: '2026-07-30T10:00:00.000Z', replyCount: 0 }] }]
})
assert(!page.includes('<script>alert(1)</script>'), 'a script tag in remote text/handle/displayName is NOT emitted raw')
assert(page.includes('<script>'), 'it is escaped')
assert(page.includes('class="lens"'), 'the lens section renders')
assert(page.includes('via <span class="lensname">bsky</span>'), 'and is ATTRIBUTED to the named lens')
assert(!/class="lpost"[\s\S]*?class="likebtn"/.test(page), 'remote posts get no like button — acting on them is not wired through this node')
console.log('\n# the honesty line, per lenses.js: "no replies" must not look like "we did not look"')
const off = postPage({ post: local, viewer: null, lensesOn: false })
assert(off.includes('class="empty lensoff"'), 'with no lens configured, the page SAYS its view may be partial')
const on = postPage({ post: local, viewer: null, lensesOn: true })
assert(!on.includes('class="empty lensoff"'), 'with a lens configured, the note is gone')
// ── ancestors: the gap our own walk cannot cross ────────────────────────────────────────────────
console.log('\n# extractThreadParents — the chain is a linked list, not an array')
const { extractThreadParents } = await import('../lib/lens-import.js')
const par = (uri, text, parent = null) => ({ $type: 'app.bsky.feed.defs#threadViewPost', post: { uri, cid: 'c', author: { did: 'did:plc:r', handle: 'h.bsky.social', displayName: 'H' }, record: { text, createdAt: '2026-07-30T10:00:00.000Z' }, replyCount: 0 }, ...(parent ? { parent } : {}) })
const chain = { thread: { parent: par('at://c', 'child', par('at://b', 'middle', par('at://a', 'oldest'))) } }
const ups = extractThreadParents(chain)
assert(ups.length === 3, 'the whole chain is walked')
assert(ups.map((u) => u.uri).join(',') === 'at://a,at://b,at://c', 'and returned OLDEST-FIRST — the order a reader scans')
assert(ups[0].text === 'oldest' && ups[0].handle === 'h.bsky.social', 'rows carry text and attribution')
assert(extractThreadParents({ thread: {} }).length === 0, 'no parent → nothing')
assert(extractThreadParents(null).length === 0, 'null body → nothing')
console.log('\n# a hole in the chain STOPS the walk')
const holed = { thread: { parent: par('at://c', 'child', { $type: 'app.bsky.feed.defs#notFoundPost', uri: 'at://gone' }) } }
const h = extractThreadParents(holed)
assert(h.length === 1 && h[0].uri === 'at://c', 'we stop at the hole rather than skipping over it')
const blocked = { thread: { parent: par('at://c', 'child', { $type: 'app.bsky.feed.defs#blockedPost', uri: 'at://b' }) } }
assert(extractThreadParents(blocked).length === 1, 'same for a blocked ancestor — their moderation call, not ours')
console.log('\n# guards')
const loop = { post: { uri: 'at://self', author: {}, record: {} }, $type: 'app.bsky.feed.defs#threadViewPost' }
loop.parent = loop
assert(extractThreadParents({ thread: { parent: loop } }).length === 1, 'a cycle in someone else\'s data does not hang the walk')
let deepChain = par('at://d0', 't')
for (let i = 1; i < 40; i++) deepChain = par('at://d' + i, 't', deepChain)
assert(extractThreadParents({ thread: { parent: deepChain } }).length === 20, 'maxDepth bounds a very long chain')
assert(extractThreadParents(chain, { exclude: new Set(['at://b']) }).length === 2, 'ancestors we already hold are excluded')
console.log('\n# rendering: the fill lands IN the gap, and the marker survives when nothing answers')
const gapPost = { did: 'did:plc:me', rkey: 'r1', uri: 'at://did:plc:me/app.bsky.feed.post/r1', createdAt: '2026-07-30T09:00:00.000Z', record: { text: 'ours' }, author: {}, likeCount: 0, replyCount: 0, repostCount: 0 }
const withGap = { post: gapPost, viewer: null, lensesOn: true, ancestors: [{ missing: true, uri: 'at://unseen' }] }
const unfilled = postPage(withGap)
assert(unfilled.includes('class="empty missing"'), 'with no lens answer, the honest marker stands')
const filledPage = postPage({ ...withGap, lensAncestors: [{ lens: 'bsky', posts: ups }] })
assert(!filledPage.includes('class="empty missing"'), 'once filled, the marker is replaced')
assert(filledPage.includes('class="lens lensup"'), 'by an attributed ancestor section')
assert(filledPage.indexOf('lensup') < filledPage.indexOf('class="focus"'), 'rendered ABOVE the focused post — the conversation reads in order')
assert(filledPage.includes('earlier in this thread, via <span class="lensname">bsky</span>'), 'and says which lens it came from')
assert(postPage({ ...withGap, lensAncestors: [{ lens: 'bsky', posts: [] }] }).includes('class="empty missing"'), 'an EMPTY lens answer is not a fill — the marker stays')
console.log('\nall lens-extract assertions passed')