// Threading — a post with what happened to it attached, instead of a flat stream of arrivals.
//
// ⛔ NOTHING IS DROPPED BY GROUPING. A reply to a post that is not on this page (older than the
// window, or never ours) stays TOP-LEVEL. Grouping that silently swallowed it would make the page a
// worse record than the flat list it replaces, and "it was there, just not shown" is the exact shape
// of every interaction defect this project has had.
import test from 'brittle'
import { buildFeed, threadFeed } from '../lib/feed.js'
const OURS = 'https://ap.7rnx.net/ap/objects/1'
const OTHER = 'https://ap.7rnx.net/ap/objects/999'
const post = (id, at) => ({ id, type: 'Note', content: '<p>hello</p>', published: at })
const act = (type, over = {}) => ({
id: type + Math.random(), type, actor: 'https://m.example/users/alice',
public: true, published: '2026-09-15T10:00:00Z', objectId: OURS, ...over
})
test('replies, likes and boosts attach to the post they acted on', (t) => {
const rows = buildFeed({
self: { handle: '@p2p@ap.7rnx.net' },
notes: [post(OURS, '2026-09-15T09:00:00Z')],
inbound: [
act('Create', { inReplyTo: OURS, content: '<p>nice</p>' }),
act('Like'),
act('Announce')
]
})
const thread = threadFeed(rows)
t.is(thread.length, 1, 'one top-level post, not four rows')
t.is(thread[0].replies.length, 1)
t.is(thread[0].likes.length, 1)
t.is(thread[0].boosts.length, 1)
t.is(thread[0].replies[0].text, 'nice')
t.is(thread[0].likes[0].author.handle, '@alice@m.example', 'and we know WHO liked it')
})
test('⛔ an interaction on a post that is not on this page stays visible', (t) => {
const rows = buildFeed({
self: {},
notes: [post(OURS, '2026-09-15T09:00:00Z')],
inbound: [act('Like', { objectId: OTHER })]
})
const thread = threadFeed(rows)
t.is(thread.length, 2, 'the post, and the orphaned like — nothing is swallowed')
t.ok(thread.some((r) => r.kind === 'like'), 'it is still a row of its own')
})
test('a follow has no subject and is always top-level', (t) => {
const thread = threadFeed(buildFeed({ self: {}, inbound: [act('Follow', { objectId: null })] }))
t.is(thread.length, 1)
t.is(thread[0].kind, 'follow')
})
test('counts are what the icons show, and they match the lists', (t) => {
const rows = buildFeed({
self: {},
notes: [post(OURS, '2026-09-15T09:00:00Z')],
inbound: [act('Like'), act('Like', { actor: 'https://m.example/users/bob' }), act('Create', { inReplyTo: OURS })]
})
const [p] = threadFeed(rows)
t.is(p.counts.likes, 2)
t.is(p.counts.replies, 1)
t.is(p.counts.boosts, 0)
t.is(p.counts.likes, p.likes.length, 'a count that can disagree with its own list is a lie waiting')
})
test('our own posts keep their order, newest first', (t) => {
const rows = buildFeed({
self: {},
notes: [post('a', '2026-09-15T09:00:00Z'), post('b', '2026-09-15T11:00:00Z')]
})
t.alike(threadFeed(rows).map((r) => r.id), ['b', 'a'])
})