// Likes and replies.
//
// Both are records in YOUR OWN feed naming a target in someone's. Nothing is written to the target's
// feed, so a popular post never turns its likers into writers on it — the property the whole model
// is built to keep.
//
// ⛔ The target is stored as its COMPONENTS — { feed, collection, name } — never as a URI string.
// The address form is still open (hyper-social #61 §12 #1), and a string in a record's bytes is
// exactly the thing §8 forbids rewriting later. Components serialise to whatever the URI turns out
// to be; a URI does not un-serialise into components we never stored.
//
// There is deliberately NO count() anywhere. You get the entries and you may count what you can
// see — so no number exists that could be mistaken for a global total.
import test from 'brittle'
import fs from '#node:fs'
import os from '#node:os'
import { join } from '#node:path'
import Corestore from 'corestore'
import { RecordStore } from '../lib/record-store.js'
import { Feed } from '../lib/feed-base.js'
import { Peer, POST, LIKE, REACTION, REPOST, BLOCK, LIKE_VALUE, REPLY } from '../lib/peer.js'
import { attest } from './helpers.js'
const tmp = () => fs.mkdtempSync(join(os.tmpdir(), 'favela-int-'))
const rec = (body, createdAt = '2026-09-06T00:00:00Z') => ({ frontmatter: { createdAt }, body })
async function mkFeed () {
const f = new Feed(new Corestore(tmp()), null, { store: new RecordStore(tmp()) })
await f.ready()
await f.addDevice(await attest(f))
await f.update()
return f
}
const mkPeer = async () => new Peer(await mkFeed()).ready()
// Alice posts; Bob follows and holds her feed.
async function pair () {
const alice = await mkPeer()
const bob = await mkPeer()
await alice.feed.put(POST, 'p1', rec('a post worth liking'))
await alice.update()
await bob.follow(alice.feed.key, alice.feed)
await bob.update()
const target = { feed: alice.feed.key.toString('hex'), collection: POST, name: 'p1' }
return { alice, bob, target, close: () => Promise.all([alice.close(), bob.close()]) }
}
test('a like is a record in the LIKER\'s feed — the target\'s is untouched', async (t) => {
const { alice, bob, target, close } = await pair()
t.teardown(close)
await bob.like(target)
await bob.update()
t.is((await bob.feed.list(REACTION)).length, 1, 'in bob\'s feed')
t.is((await alice.feed.list(REACTION)).length, 0, 'and not in alice\'s')
t.alike(await alice.feed.devices(), [alice.feed.deviceKey], 'her writer set never grew')
})
test('the like stores the target as components, not a URI', async (t) => {
const { bob, target, close } = await pair()
t.teardown(close)
await bob.like(target)
await bob.update()
const [row] = await bob.feed.list(REACTION)
t.alike(row.record.frontmatter.target, target, 'exactly the three components')
t.is(row.record.body, '\n', 'a like has no body')
})
test('★ liking twice is idempotent — the name is derived from the target', async (t) => {
const { bob, target, close } = await pair()
t.teardown(close)
await bob.like(target)
await bob.update()
await bob.like(target)
await bob.update()
t.is((await bob.feed.list(REACTION)).length, 1, 'one like, not two — same record overwritten')
})
test('unlike tombstones it', async (t) => {
const { bob, target, close } = await pair()
t.teardown(close)
await bob.like(target)
await bob.update()
await bob.unlike(target)
await bob.update()
t.is((await bob.feed.list(REACTION)).length, 0, 'gone from the listing')
t.is((await bob.feed.resolve(REACTION, Peer.reactionId(target, LIKE_VALUE))).status, 'deleted',
'as positive evidence')
})
test('likesOn returns what THIS NODE can see, across held feeds', async (t) => {
const { alice, bob, target, close } = await pair()
t.teardown(close)
await bob.like(target)
await alice.like(target) // alice likes her own post
await bob.update()
await alice.update()
// bob holds alice's feed, so he sees both
const seen = await bob.likesOn(target)
t.is(seen.length, 2, 'both likes are visible to bob')
t.alike(seen.map((x) => x.author).sort(),
[alice.feed.key.toString('hex'), bob.feed.key.toString('hex')].sort())
// alice holds only her own, so she sees one — legitimately, not as an error
t.is((await alice.likesOn(target)).length, 1, 'alice sees only her own — different view, both correct')
})
test('a reply IS a post with a target', async (t) => {
const { bob, target, close } = await pair()
t.teardown(close)
await bob.reply(target, 'i agree', 'r1')
await bob.update()
const [row] = await bob.feed.list(REPLY)
t.is(row.record.body, 'i agree\n', 'it has a body — that is what makes it a reply and not a like')
t.alike(row.record.frontmatter.target, target)
})
test('repliesTo gathers across held feeds, oldest first', async (t) => {
const { alice, bob, target, close } = await pair()
t.teardown(close)
await bob.reply(target, 'second', 'r1', '2026-09-05T00:00:00Z')
await alice.reply(target, 'first', 'r0', '2026-09-01T00:00:00Z')
await bob.update()
await alice.update()
t.alike((await bob.repliesTo(target)).map((x) => x.record.body), ['first\n', 'second\n'],
'a thread reads forwards, unlike a timeline')
})
test('⛔ likes and replies never appear in the timeline', async (t) => {
const { bob, target, close } = await pair()
t.teardown(close)
await bob.like(target)
await bob.reply(target, 'a reply', 'r1')
// Distinct timestamps ON PURPOSE. An earlier version of this test gave both posts the same
// createdAt, so the order fell through to the tie-break on feed key — which is random per run, and
// the test passed about two runs in three. That is the correct BEHAVIOUR (there is no cross-feed
// order for equal timestamps, so the tie-break only promises stability within one set of keys);
// it was the assertion that was wrong to depend on it.
await bob.feed.put(POST, 'mine', rec('my own post', '2026-09-07T00:00:00Z'))
await bob.update()
t.alike((await bob.timeline()).map((x) => x.record.body),
['my own post\n', 'a post worth liking\n'], 'posts only — a reply is read in its thread')
})
test('★ there is no count API', (t) => {
t.absent(typeof Peer.prototype.likeCount === 'function', 'no likeCount')
t.absent(typeof Peer.prototype.count === 'function', 'no count')
// You may count what you can see. There is no number to mistake for a total.
})
// ── Can I tell whether I ALREADY did this? ────────────────────────────────────────────────────
//
// ⛔ The surface rendered a bare `like` button on every post — no state, and no way back. The
// daemon could answer `likesOn(target)` (who liked it) and nothing could answer "did I". So a
// reader could not tell a liked post from an unliked one, and pressing the button twice was
// indistinguishable from pressing it once.
//
// ⚠ This is cheap for a reason. A like is named by its TARGET, in MY OWN feed, so "did I like
// this" is a lookup in one log I already hold — never a scan of everyone's.
test('★ myLikes reports what I have liked, and stops reporting it when I unlike', async (t) => {
const { alice, bob, target } = await pair()
t.teardown(() => Promise.all([alice.close(), bob.close()]))
t.alike([...await bob.myLikes()], [], 'nothing liked yet')
await bob.like(target)
await bob.update()
t.alike([...await bob.myLikes()], [Peer.targetId(target)], 'liked, and identified by the target')
await bob.unlike(target)
await bob.update()
// A tombstone, so the record is still in the log — `list()` filters to OK, which is what makes
// this the right question to ask rather than "is there a record".
t.alike([...await bob.myLikes()], [], 'and the button goes back to offering a like')
})
test('⛔ myLikes is MINE — someone else liking the same post is not me liking it', async (t) => {
const { alice, bob, target } = await pair()
t.teardown(() => Promise.all([alice.close(), bob.close()]))
await alice.like(target)
await alice.update()
await bob.update()
// Bob holds Alice's feed, so her like is visible to him — and it is hers. Reading "did I like
// this" off every readable feed rather than off my own would light up the button for everyone.
t.is((await bob.likesOn(target)).length, 1, 'bob can SEE the like')
t.alike([...await bob.myLikes()], [], 'and it is not his')
})
test('liking twice is one like, so the state cannot get out of step with the button', async (t) => {
const { alice, bob, target } = await pair()
t.teardown(() => Promise.all([alice.close(), bob.close()]))
await bob.like(target)
await bob.like(target)
await bob.update()
// Idempotent because the record NAME is derived from the target — the second write is the same
// key, which is one record and not two.
t.is([...await bob.myLikes()].length, 1)
t.is((await bob.likesOn(target)).length, 1)
})
// ── reactions ─────────────────────────────────────────────────────────────────────────────────
//
// keet is the only one of the three networks where REACTION is the primitive and like is a value of
// it (`keet-core/lib/core/contract/reactions.js`: `@keet/reactions`, add/remove, deduped on the
// triple). atproto has only `app.bsky.feed.like`; ActivityPub has `Like` plus `EmojiReact`, which is
// a Mastodon/Misskey extension rather than core AS2.
//
// ⛔ So `like` is a RESERVED VALUE, not an emoji. A bridge has to be able to say "this is the thing
// atproto calls a like" without guessing, and if the value were 👍 then "did they like it or react
// with a thumb" would be undecidable in both directions.
//
// ⚠ Everything else is a `loss[]` entry when projecting to atproto, which carries no reaction but
// like. That is a known, stated loss rather than a silent one.
test('★ a reaction is the primitive; like is one value of it', async (t) => {
const { alice, bob, target } = await pair()
t.teardown(() => Promise.all([alice.close(), bob.close()]))
await bob.react(target, '🔥')
await bob.like(target)
await bob.update()
const mine = await bob.myReactions()
t.alike([...mine.get(Peer.targetId(target))].sort(), ['like', '🔥'].sort(),
'one person may hold several DISTINCT reactions on one target — keet dedups on the triple')
t.ok(await bob.likedByMe(target), 'and `like` is answerable as its own question, for a bridge')
})
test('reacting twice with the same value is one reaction', async (t) => {
const { alice, bob, target } = await pair()
t.teardown(() => Promise.all([alice.close(), bob.close()]))
await bob.react(target, '🔥')
await bob.react(target, '🔥')
await bob.update()
// Idempotent for the same reason a like is: the record NAME is derived from (target, value), so
// the second write is the same key.
t.is((await bob.reactionsOn(target)).length, 1)
})
test('removing one reaction leaves the others', async (t) => {
const { alice, bob, target } = await pair()
t.teardown(() => Promise.all([alice.close(), bob.close()]))
await bob.react(target, '🔥')
await bob.like(target)
await bob.update()
await bob.unreact(target, '🔥')
await bob.update()
const mine = await bob.myReactions()
t.alike([...mine.get(Peer.targetId(target))], ['like'], 'unreacting is per VALUE, not per target')
t.ok(await bob.likedByMe(target))
})
test('⛔ reactions carry WHO, and there is still no count', async (t) => {
const { alice, bob, target } = await pair()
t.teardown(() => Promise.all([alice.close(), bob.close()]))
await alice.react(target, '🔥')
await bob.react(target, '🔥')
await alice.update(); await bob.update()
const rows = await bob.reactionsOn(target)
t.is(rows.length, 2, 'both are visible to bob, who holds both feeds')
t.alike(rows.map((r) => r.value).sort(), ['🔥', '🔥'])
// You may count what you can SEE. Nothing produces a number that could be mistaken for a global
// total — the entries are returned and the caller decides what it is looking at.
t.ok(rows.every((r) => typeof r.author === 'string'), 'each names its author')
})
test('⚠ a reaction value is bounded — it is a label, not a payload', async (t) => {
const { alice, bob, target } = await pair()
t.teardown(() => Promise.all([alice.close(), bob.close()]))
await t.exception(bob.react(target, ''), /value/)
await t.exception(bob.react(target, 'x'.repeat(80)), /value/)
})
// ── repost ────────────────────────────────────────────────────────────────────────────────────
//
// `interaction.md` §5.2 and §5.4 settle the shape: a BODYLESS edge — "if it has bytes it gets a
// file; if it does not, the log entry is the whole thing" — projecting to `app.bsky.feed.repost`
// and AS2 `Announce`, with a tombstone to undo. Same class as like and follow, and deliberately
// NOT the same as reply, which is "the edge that is also a document".
test('★ a repost is a bodyless edge in the REPOSTER\'s feed', async (t) => {
const { alice, bob, target } = await pair()
t.teardown(() => Promise.all([alice.close(), bob.close()]))
await bob.repost(target)
await bob.update()
const rows = await bob.feed.list(REPOST)
t.is(rows.length, 1, 'in bob\'s feed')
t.is((await alice.feed.list(REPOST)).length, 0, 'and nothing written to alice\'s')
// ⚠ `.trim()` — the record serializer round-trips an empty body as "\n", so asserting `=== ''`
// tests the serializer's whitespace rather than the property. What matters is that a repost
// carries no AUTHORED text, which is what distinguishes it from a quote in both target networks.
t.is(rows[0].record.body.trim(), '', 'no body — a repost adds nothing of its own')
t.alike(rows[0].record.frontmatter.target, target, 'the subject, as components')
})
test('reposting twice is one repost, and un-reposting is a tombstone', async (t) => {
const { alice, bob, target } = await pair()
t.teardown(() => Promise.all([alice.close(), bob.close()]))
await bob.repost(target)
await bob.repost(target)
await bob.update()
t.is((await bob.myReposts()).size, 1, 'idempotent — the name is derived from the target')
await bob.unrepost(target)
await bob.update()
t.is((await bob.myReposts()).size, 0)
// Absence is ambiguous in a replicated log, so "un-reposted" has to be positive evidence.
t.is((await bob.feed.resolve(REPOST, Peer.targetId(target))).status, 'deleted')
})
test('repostsOf names WHO reposted, across the feeds I hold', async (t) => {
const { alice, bob, target } = await pair()
t.teardown(() => Promise.all([alice.close(), bob.close()]))
await bob.repost(target)
await bob.update()
const rows = await bob.repostsOf(target)
t.is(rows.length, 1)
t.is(rows[0].author, bob.feed.key.toString('hex'))
// ⛔ Still no count, for the same reason as reactions: you may count what you can SEE.
})
test('⛔ a repost is not a reaction and not a reply', async (t) => {
const { alice, bob, target } = await pair()
t.teardown(() => Promise.all([alice.close(), bob.close()]))
await bob.repost(target)
await bob.like(target)
await bob.update()
// Three different verbs with three different projections — `app.bsky.feed.repost` vs
// `app.bsky.feed.like` vs a post with `reply:{root,parent}`. Collapsing any two loses a mapping.
t.is((await bob.feed.list(REPOST)).length, 1)
t.is((await bob.feed.list(REACTION)).length, 1)
t.is((await bob.feed.list(REPLY)).length, 0)
})
// ── block ─────────────────────────────────────────────────────────────────────────────────────
//
// `interaction.md` §5.2 puts `block` with the bodyless edges and §5.4 maps it to the graph block
// verb, with a stated loss where the target network does not federate it.
//
// ⛔ What a block CAN and CANNOT do here has to be said plainly, because the honest answer is
// weaker than people expect. A feed is public and replication is the reader's to perform, so a
// block cannot stop someone reading you, holding your feed, or writing about you. It is a decision
// about what YOU render, published as a signal — `SOCIAL-MODEL.md` §6.7's "signals, not
// enforcement", one level down.
test('★ blocking hides their posts from everything I project', async (t) => {
const { alice, bob } = await pair()
t.teardown(() => Promise.all([alice.close(), bob.close()]))
await alice.feed.put(POST, 'p2', rec('a post about kittens'))
await alice.update(); await bob.update()
t.is((await bob.timeline()).length, 2, 'both her posts are there first')
await bob.block(alice.feed.key)
await bob.update()
// ⛔ ONE place decides. Filtering in the timeline and forgetting search is how a blocked person
// reappears in the one view nobody thought about.
t.alike(await bob.timeline(), [], 'gone from the timeline')
t.alike((await bob.search('kittens')).results, [], 'and from search')
t.alike(await bob.postsBy(alice.feed.key), [], 'and their page is empty to me')
})
test('a block writes nothing to THEIR feed, and is a bodyless edge', async (t) => {
const { alice, bob } = await pair()
t.teardown(() => Promise.all([alice.close(), bob.close()]))
await bob.block(alice.feed.key)
await bob.update()
const rows = await bob.feed.list(BLOCK)
t.is(rows.length, 1)
t.is(rows[0].record.body.trim(), '', 'no body — it is an edge, not a statement')
t.is((await alice.feed.list(BLOCK)).length, 0, 'and they are not told by us writing to them')
t.alike([...await bob.blocked()], [alice.feed.key.toString('hex')])
})
test('unblocking restores them, as a tombstone', async (t) => {
const { alice, bob } = await pair()
t.teardown(() => Promise.all([alice.close(), bob.close()]))
await bob.block(alice.feed.key)
await bob.update()
t.alike(await bob.timeline(), [])
await bob.unblock(alice.feed.key)
await bob.update()
t.is((await bob.timeline()).length, 1, 'back')
t.is((await bob.feed.resolve(BLOCK, Peer.nameFor(alice.feed.key))).status, 'deleted',
'and the undo is positive evidence, like every other undo here')
})
test('⛔ blocking MYSELF is refused — I would vanish from my own timeline', async (t) => {
const { bob } = await pair()
t.teardown(() => bob.close())
await bob.feed.put(POST, 'mine', rec('my post'))
await bob.update()
await t.exception(bob.block(bob.feed.key), /yourself/)
// ⚠ The property, not a count — `pair()` already put someone else's post in this timeline.
t.ok((await bob.timeline()).some((r) => r.name === 'mine'), 'my own post is still there')
})
test('⚠ a blocked feed is still HELD — a block is not an eviction', async (t) => {
const { alice, bob } = await pair()
t.teardown(() => Promise.all([alice.close(), bob.close()]))
await bob.block(alice.feed.key)
await bob.update()
// Nothing here can un-replicate what already arrived, and pretending a block deletes their bytes
// would be a promise this substrate cannot keep. It stops being RENDERED; it does not vanish.
t.ok(bob.feeds.has(alice.feed.key.toString('hex')), 'still on disk and still replicating')
})