OverviewFilesCommitsIssuesDocsReleasesPeers

favela-web / lib / mutes.js

77 lines · javascript ref:
// 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)
  }
}