OverviewFilesCommitsIssuesDocsReleasesPeers

favela-web / lib / roster.js

51 lines · javascript ref:
// favela roster resolver — turn an atproto LIST into a set of member DIDs.
//
// A community roster is an `app.bsky.graph.list` record in the OWNER's repo; membership is one
// `app.bsky.graph.listitem` record per member (subject = member DID, list = the list's AT-URI). favela
// reads the owner's listitems over P2P (a DidPeer) and collects the subjects — the SAME read path it
// already uses for posts, just a different collection. No new substrate: a list is just records.
// Shapes verified against bluesky-social/atproto (list: name/purpose/createdAt + optional; listitem:
// subject:did / list:at-uri / createdAt).
//
// This is the roster analog of the post scope (scopes.js): an `app.bsky.graph.list` is bsky-visible;
// a favela-only community would use a `net.favela.*` list NSID with the SAME listitem semantics — the
// resolver is collection-parameterized so that variant is a one-line addition later.

export const LIST_COLLECTION = 'app.bsky.graph.list'
export const LISTITEM_COLLECTION = 'app.bsky.graph.listitem'

const LIST_URI_RE = /^at:\/\/([^/]+)\/app\.bsky\.graph\.list\/([^/]+)$/

// Parse an app.bsky.graph.list AT-URI -> { owner, rkey, uri }. Throws on a non-list URI.
export function parseListUri (uri) {
  const m = LIST_URI_RE.exec(String(uri || ''))
  if (!m) throw new Error(`not an app.bsky.graph.list AT-URI: ${uri}`)
  return { owner: m[1], rkey: m[2], uri: String(uri) }
}

// Resolve a roster from a list. `ownerPeer` = anything exposing getRecord(collection, rkey) and
// listRecords(collection) (a DidPeer) for the list OWNER. Returns { uri, owner, name, purpose, members }
// where members is the deduped list of subject DIDs. Only listitems whose `.list` equals THIS list URI
// count (an owner can host many lists in one repo); duplicate subjects are ignored (as the AppView does).
export async function resolveRoster (ownerPeer, listUri) {
  const { owner, rkey, uri } = parseListUri(listUri)
  const listRec = await ownerPeer.getRecord(LIST_COLLECTION, rkey)
  const meta = listRec && listRec.value ? listRec.value : null
  // ⛔ `listRecords` RETURNS { records, cursor }, NOT AN ARRAY (atproto-did-peer 0.2.10, per the
  // com.atproto.repo.listRecords lexicon). This read `for (const it of items)` against the wrapper and
  // threw `items is not iterable` — community rosters simply stopped resolving. Accept both shapes so a
  // caller passing an older peer, or a plain array in a test, still works.
  const listed = await ownerPeer.listRecords(LISTITEM_COLLECTION)
  const items = Array.isArray(listed) ? listed : ((listed && listed.records) || [])
  const members = []
  const seen = new Set()
  for (const it of items) {
    const rec = it && it.value ? it.value : null
    if (!rec || rec.list !== uri || !rec.subject) continue
    if (seen.has(rec.subject)) continue
    seen.add(rec.subject)
    members.push(rec.subject)
  }
  return { uri, owner, name: meta ? meta.name : null, purpose: meta ? meta.purpose : null, members }
}