OverviewFilesCommitsIssuesDocsReleasesPeers

favela-web / lib / labels.js

67 lines · javascript ref:
// favela label consumption — index VERIFIED labels by subject, resolve the active set, and decide
// render treatment. Labels are moderation judgments from labelers favela TRUSTS; signature verification
// (verifyLabel vs the labeler's did:key) happens in the feed reader BEFORE addLabel — this store only
// holds already-trusted labels. Moderation is OPT-IN and applied AT RENDER (never destroys posts).
//
// State model (atproto): a label is keyed by (src, uri, val); retraction is NEGATION (`neg:true`), not
// deletion; the latest `cts` wins (LWW). getLabels returns the currently-active (non-negated) labels.
import Hyperbee from 'hyperbee'

const SEP = '\x00'
const RANK = { ignore: 0, inform: 1, warn: 2, hide: 3 }

export class LabelStore {
  constructor (corestore, { name = 'favela-labels' } = {}) {
    this.bee = new Hyperbee(corestore.get({ name }), { keyEncoding: 'utf-8', valueEncoding: 'json' })
  }

  async ready () { await this.bee.ready(); return this }
  _key (uri, src, val) { return `${uri}${SEP}${src}${SEP}${val}` }

  // Add an already-VERIFIED label. LWW by cts: apply only if strictly newer than the stored state for
  // (uri, src, val). A neg:true label is stored (it retracts the val at render). Returns true on change.
  async addLabel (label) {
    if (!label || !label.uri || !label.src || !label.val || !label.cts) return false
    const key = this._key(label.uri, label.src, label.val)
    const prior = await this.bee.get(key)
    if (prior && prior.value && String(prior.value.cts) >= String(label.cts)) return false // not newer
    await this.bee.put(key, { src: label.src, uri: label.uri, val: label.val, cts: label.cts, neg: !!label.neg })
    return true
  }

  // Active labels on a subject uri: the (src, val) whose latest state is NOT negated. Prefix-safe
  // (the \x00 separator + \x01 ceiling bound exactly this uri — a longer uri sharing the prefix is
  // excluded). Returns [{ src, val, cts }].
  async getLabels (uri) {
    const out = []
    for await (const { value } of this.bee.createReadStream({ gte: `${uri}${SEP}`, lt: `${uri}\x01` })) {
      if (value && !value.neg) out.push({ src: value.src, val: value.val, cts: value.cts })
    }
    return out
  }

  async close () { await this.bee.close() }
}

// Decide the strongest render treatment for a set of active labels, given a policy map
// (val -> 'hide'|'warn'|'inform'|'ignore'). atproto system values ('!hide'/'!warn') are ALWAYS honored;
// unknown custom values default to 'ignore' (shown normally) unless the policy opts in. Returns
// 'ignore' | 'inform' | 'warn' | 'hide'.
export function labelTreatment (labels, policy = {}) {
  let best = 'ignore'
  for (const l of labels) {
    let t = policy[l.val]
    if (!t) t = l.val === '!hide' ? 'hide' : l.val === '!warn' ? 'warn' : 'ignore'
    if ((RANK[t] || 0) > RANK[best]) best = t
  }
  return best
}

// Combined per-post moderation decision from the two viewer-side layers: labeler labels (applied per
// policy) and a modlist MUTE (the author is on a subscribed moderation list). Returns
// { treatment, muted, labels } — the client collapses a post when treatment is hide/warn OR muted.
// Both are opt-in and applied at render; neither deletes the post.
export function postModeration (labels, muted, policy = {}) {
  return { treatment: (labels && labels.length) ? labelTreatment(labels, policy) : 'ignore', muted: !!muted, labels: (labels || []).map((l) => l.val) }
}