// Per-viewer mutes and block bookkeeping.
//
// ⛔ THE DISTINCTION THAT MATTERS, AND GETTING IT WRONG IS A PRIVACY LEAK.
//
// MUTE is PRIVATE. In atproto a mute is not a record — bsky keeps it as server-side state behind
// app.bsky.graph.muteActor. Nobody, including the muted account, can enumerate who muted them.
// So a mute must NEVER be written to the repo: doing so would publish "alice muted bob" to the
// whole network, permanently, in a signed record. It lives HERE, node-local, never federated.
//
// BLOCK is PUBLIC. app.bsky.graph.block IS a record in your repo and it federates by design — bsky
// renders blocks as mutually visible. The record is written by the caller through hpp; this
// module only remembers its RKEY so the block can be undone, and which subjects are blocked so
// the feed can hide them without a round-trip.
//
// Both are keyed by VIEWER, because "muted" is a property of a reader, not of a post. The node-wide
// MODLIST set in bin/favela-web.js is a different thing — an operator subscribing the whole node to a
// moderation list — and the two are unioned at read time rather than merged in storage.
//
// One Hyperbee sub, key `<kind>\x00<viewerDid>\x00<subjectDid>`. A tombstone is a real delete: an unmute
// should leave no trace, which is the whole point of it being private.
const SEP = '\x00'
const KINDS = new Set(['mute', 'block'])
export function createMuteStore (bee) {
if (!bee) throw new Error('createMuteStore requires a Hyperbee (sub)')
const key = (kind, viewer, subject) => `${kind}${SEP}${viewer}${SEP}${subject}`
const prefix = (kind, viewer) => `${kind}${SEP}${viewer}${SEP}`
async function set (kind, viewer, subject, value = {}) {
if (!KINDS.has(kind) || !viewer || !subject) return false
if (viewer === subject) return false // muting or blocking yourself is meaningless
await bee.put(key(kind, viewer, subject), { at: Date.now(), ...value })
return true
}
async function del (kind, viewer, subject) {
if (!KINDS.has(kind) || !viewer || !subject) return false
try { await bee.del(key(kind, viewer, subject)) } catch { return false }
return true
}
async function get (kind, viewer, subject) {
if (!KINDS.has(kind) || !viewer || !subject) return null
try { const r = await bee.get(key(kind, viewer, subject)); return (r && r.value) || null } catch { return null }
}
// Every subject of `kind` for this viewer, as a Set. One scan per page render, so the read path does
// not do a lookup per post — a 50-post timeline would otherwise be 50 gets.
async function subjects (kind, viewer) {
const out = new Set()
if (!KINDS.has(kind) || !viewer) return out
const p = prefix(kind, viewer)
try {
for await (const e of bee.createReadStream({ gte: p, lt: p + '' })) {
const did = String(e.key).slice(p.length)
if (did) out.add(did)
}
} catch { /* an unreadable index must not blank a timeline */ }
return out
}
return {
mute: (viewer, subject) => set('mute', viewer, subject),
unmute: (viewer, subject) => del('mute', viewer, subject),
isMuted: async (viewer, subject) => !!(await get('mute', viewer, subject)),
mutedBy: (viewer) => subjects('mute', viewer),
// The block RECORD is written through hpp by the caller; `rkey` is remembered so unblock can delete
// it. Without the rkey an unblock would have to scan the repo for a matching subject.
block: (viewer, subject, rkey) => set('block', viewer, subject, { rkey: rkey || null }),
unblock: (viewer, subject) => del('block', viewer, subject),
blockRkey: async (viewer, subject) => { const v = await get('block', viewer, subject); return (v && v.rkey) || null },
isBlocked: async (viewer, subject) => !!(await get('block', viewer, subject)),
blockedBy: (viewer) => subjects('block', viewer)
}
}