OverviewFilesCommitsIssuesDocsReleasesPeers

favela-web / lib / lists.js

131 lines · javascript ref:
// User lists — app.bsky.graph.list + app.bsky.graph.listitem.
//
// TWO RECORDS, and the relationship between them is the whole thing. A `list` is just a name and a
// purpose; membership lives in separate `listitem` records, each carrying `subject` (the member's DID)
// and `list` (the list's at:// uri). So a list of 500 people is 501 records, and removing someone means
// deleting THEIR listitem — not editing the list.
//
// That shape is why this needs no index for our OWN lists: we know our DID, so both collections are
// directly readable from the record store. Someone ELSE's list still needs the peer path
// (lib/roster.js resolveRoster), which is what threadgate #listRule already uses.
//
// PURPOSE IS LOAD-BEARING, not a label. `#curatelist` is a normal user list; `#modlist` is a moderation
// list, and subscribing to one MUTES OR BLOCKS everyone on it. Rendering a modlist as if it were a
// curation list, or letting one be created by accident, hands someone a footgun — so the purpose is
// explicit at creation and shown wherever a list is displayed.

export const LIST_COLLECTION = 'app.bsky.graph.list'
export const LISTITEM_COLLECTION = 'app.bsky.graph.listitem'
export const CURATE = 'app.bsky.graph.defs#curatelist'
export const MODLIST = 'app.bsky.graph.defs#modlist'
export const PURPOSES = [CURATE, MODLIST]

export function listRecord ({ name, purpose = CURATE, description = '', createdAt = new Date().toISOString() }) {
  const n = String(name || '').trim()
  if (!n) return null
  return {
    $type: LIST_COLLECTION,
    purpose: PURPOSES.includes(purpose) ? purpose : CURATE,
    name: n.slice(0, 64),
    ...(description ? { description: String(description).slice(0, 300) } : {}),
    createdAt
  }
}

export function listitemRecord ({ subject, list, createdAt = new Date().toISOString() }) {
  if (typeof subject !== 'string' || !subject.startsWith('did:')) return null
  if (typeof list !== 'string' || !list.startsWith('at://')) return null
  return { $type: LISTITEM_COLLECTION, subject, list, createdAt }
}

export function isModlist (record) {
  return !!(record && record.purpose === MODLIST)
}

export function describePurpose (record) {
  return isModlist(record)
    ? 'moderation list — subscribing to this mutes or blocks everyone on it'
    : 'curation list'
}

export const listUri = (did, rkey) => `at://${did}/${LIST_COLLECTION}/${rkey}`

// Our own lists, read straight from the record store. Returns [{ rkey, uri, record }].
export async function ownLists (store, did) {
  const out = []
  if (!store || !did) return out
  try {
    for await (const { rkey, record } of store.list(LIST_COLLECTION)) {
      if (record && record.$type === LIST_COLLECTION) out.push({ rkey, uri: listUri(did, rkey), record })
    }
  } catch { /* absent collection dir — no lists yet */ }
  out.sort((a, b) => String((a.record && a.record.name) || '').localeCompare(String((b.record && b.record.name) || '')))
  return out
}

// Members of one of OUR lists: every listitem whose `list` matches. Returns [{ rkey, subject }].
//
// This SCANS the listitem collection, because membership is not indexed by list — the same honest trade
// search makes. A few hundred listitems is nothing; a hundred thousand would want an index.
export async function membersOf (store, uri) {
  const out = []
  if (!store || !uri) return out
  try {
    for await (const { rkey, record } of store.list(LISTITEM_COLLECTION)) {
      if (record && record.list === uri && typeof record.subject === 'string') out.push({ rkey, subject: record.subject })
    }
  } catch { /* no listitems yet */ }
  return out
}

// The listitem rkey binding `subject` to `uri`, or null. Needed to REMOVE someone: the membership record
// is what gets deleted, and without its rkey you would have to scan for it every time.
export async function memberRkey (store, uri, subject) {
  for (const m of await membersOf(store, uri)) if (m.subject === subject) return m.rkey
  return null
}

// ── CONVERGING ONE IDENTITY'S SUBSCRIPTIONS ACROSS ITS DEVICES ───────────────────────────────────────
// Devices hosting the same DID are the same identity and should agree on what it subscribes to with
// nothing configured on any of them. Both helpers below exist so that decision is testable without a
// repo, a writer or a handset — the failure they prevent was only visible on a phone.

// The rkey binding a subject to the subscriptions list.
//
// ⛔ DETERMINISTIC, NOT A FRESH TID. Two devices contributing the same DID concurrently would otherwise
// write TWO records for one membership: reads dedup by subject so it looks fine, while the repo
// accumulates duplicates for ever. Same subject => same rkey => the second write is a no-op, which is
// what makes the merge idempotent rather than merely convergent-on-read.
//
// rkey charset is [A-Za-z0-9.:_~-], so hex — not base64url. Truncated: a collision across a handful of
// subscriptions is not a practical concern, and would only drop one add, which another device's merge
// re-contributes.
export function subscriptionRkey (subject, hash) {
  if (typeof subject !== 'string' || !subject) throw new Error('subject required')
  if (typeof hash !== 'function') throw new Error('hash function required')
  return 'sub' + String(hash(subject)).slice(0, 24)
}

// What THIS node still owes the shared list: its own roster, minus itself, minus what the list already
// has, deduped and order-stable.
//
// ⛔ UNION, NEVER REPLACE. Writing the list wholesale makes "who booted first" decide the identity's
// roster for every device — a handset won that race against a server on 2026-08-25 and its derived set
// became authoritative. A union is order-independent: any interleaving of devices converges on the same
// set, and there is no winner to be.
//
// Self is excluded because a hosted DID is subscribed by virtue of being hosted; listing yourself says
// nothing and would round-trip into the roster as a duplicate.
export function missingMembers ({ local = [], listed = [], selfDid = null } = {}) {
  const have = new Set(listed)
  const out = []
  const seen = new Set()
  for (const did of local) {
    if (typeof did !== 'string' || !did.startsWith('did:')) continue
    if (did === selfDid || have.has(did) || seen.has(did)) continue
    seen.add(did)
    out.push(did)
  }
  return out
}