OverviewFilesCommitsIssuesDocsReleasesPeers

favela-web / lib / threadgate.js

119 lines · javascript ref:
// threadgate — who may reply to a post (app.bsky.feed.threadgate).
//
// THE BINDING IS THE RKEY. A threadgate is a separate record whose rkey is IDENTICAL to the post's, and
// whose `post` field is the post's at:// uri. That is the whole linkage — there is no pointer from the
// post to its gate. It is also why favela needs no new index for this: given a post we already know the
// rkey, so the gate is directly addressable in the record store. (Indexing a new collection would mean
// an INGEST_VERSION bump and a re-index of every DID, for something we can simply look up.)
//
// THE EMPTY ARRAY IS MEANINGFUL, and it is the one thing here that is easy to get backwards:
//   allow ABSENT   -> anyone may reply (there is no gate)
//   allow: []      -> NOBODY may reply
// So a serializer that helpfully omits an empty array turns "nobody" into "everyone" — the exact inverse
// of what the author asked for. Every function here treats undefined and [] as different values.
//
// ⚠ WHAT WE CAN AND CANNOT ENFORCE. A threadgate is advisory metadata: enforcement lives in whoever
// accepts the reply. We honour it in OUR ui and refuse OUR writes, and bsky enforces it in its appview.
// Neither can stop a peer writing a reply record anyway — the gate is not a permission system, and
// presenting it as one would be a lie. The UI says "replies are limited", not "replies are impossible".

export const GATE_CHOICES = ['everyone', 'following', 'mentioned', 'nobody']

// A UI choice -> the `allow` value for the record. `everyone` returns undefined, meaning WRITE NO GATE
// AT ALL rather than a gate that allows everything: an empty-rule gate would read as "nobody".
export function allowFor (choice) {
  switch (choice) {
    case 'nobody': return []
    case 'following': return [{ $type: 'app.bsky.feed.threadgate#followingRule' }]
    case 'mentioned': return [{ $type: 'app.bsky.feed.threadgate#mentionRule' }]
    default: return undefined
  }
}

// The inverse, for rendering an existing gate. A gate whose rules we do not recognise is reported as
// 'limited' rather than guessed at — inventing a permissive reading of an unknown rule is how a gated
// thread ends up looking open.
export function choiceOf (record) {
  if (!record) return 'everyone'
  const allow = record.allow
  if (allow === undefined || allow === null) return 'everyone'
  if (!Array.isArray(allow)) return 'limited'
  if (allow.length === 0) return 'nobody'
  const types = allow.map((r) => String((r && r.$type) || ''))
  if (types.length === 1 && types[0].endsWith('#followingRule')) return 'following'
  if (types.length === 1 && types[0].endsWith('#mentionRule')) return 'mentioned'
  // #listRule — replies limited to members of a named list. Recognised as its own choice rather than
  // lumped into 'limited', because we CAN say which list, and because membership is resolvable when the
  // list is one this node already tracks.
  if (types.length === 1 && types[0].endsWith('#listRule')) return 'list'
  return 'limited'
}

// Human-readable, for the notice on a post.
export function describe (choice) {
  switch (choice) {
    case 'nobody': return 'replies are turned off'
    case 'following': return 'only people the author follows can reply'
    case 'mentioned': return 'only mentioned accounts can reply'
    case 'list': return 'only members of a list can reply'
    case 'limited': return 'replies are limited'
    default: return ''
  }
}

// May `viewerDid` reply, given the gate on a post by `authorDid`?
//
//   authorFollowsViewer — for #followingRule. NOTE the direction: the rule is "people the AUTHOR
//     follows", not "people who follow the author". Getting it backwards silently gates the wrong set.
//   mentionedDids       — the DIDs the post mentions, for #mentionRule.
//
// The author can always reply to their own thread, which is how every client behaves and what makes a
// self-thread possible under a restrictive gate.
export function canReply ({ viewerDid, authorDid, record, authorFollowsViewer = false, mentionedDids = [], listMembers = null } = {}) {
  if (!viewerDid) return false
  if (viewerDid === authorDid) return true
  const choice = choiceOf(record)
  switch (choice) {
    case 'everyone': return true
    case 'nobody': return false
    case 'following': return !!authorFollowsViewer
    case 'mentioned': return mentionedDids.includes(viewerDid)
    // A list gate is answerable ONLY if this node knows the list's membership. `listMembers === null`
    // means "we could not resolve it", which is NOT the same as "you are not a member" — so it fails
    // closed for our own writes, and the UI says the membership is unknown rather than implying refusal.
    case 'list': return Array.isArray(listMembers) ? listMembers.includes(viewerDid) : false
    // An unrecognised rule set is treated as CLOSED for our own writes. Refusing to post a reply that
    // may be rejected downstream is a papercut; writing one that the author asked to prevent is worse.
    default: return false
  }
}

// The list uri a #listRule gate names, or null. The caller needs it to attempt membership resolution.
export function listUriOf (record) {
  const allow = record && record.allow
  if (!Array.isArray(allow)) return null
  for (const r of allow) {
    if (r && String(r.$type || '').endsWith('#listRule') && typeof r.list === 'string') return r.list
  }
  return null
}

// The DIDs a post mentions, read from its richtext facets — the mention rule is defined against these.
export function mentionsOf (postRecord) {
  const out = []
  for (const f of (postRecord && postRecord.facets) || []) {
    for (const feat of (f && f.features) || []) {
      if (feat && String(feat.$type || '').endsWith('#mention') && typeof feat.did === 'string') out.push(feat.did)
    }
  }
  return out
}

// The record to write. rkey MUST be the post's rkey — the caller passes it, and this returns null for
// 'everyone' so the caller writes nothing at all.
export function gateRecord ({ postUri, choice, createdAt = new Date().toISOString() }) {
  const allow = allowFor(choice)
  if (allow === undefined) return null
  return { $type: 'app.bsky.feed.threadgate', post: postUri, allow, createdAt }
}