// favela community registry — resolves a VIEWER (signed-in DID) to their per-viewer author scope: the
// union of member DIDs across the communities they belong to. That scope feeds Appview.getTimeline
// ({ authors }) so two viewers in different communities see different timelines (DESIGN §7.5 — the
// per-peer/per-viewer view). Anonymous or community-less viewers get the global node timeline.
//
// A community is just a ROSTER of member DIDs. The roster SOURCE is pluggable and injected (this module
// stays pure + unit-testable, no P2P): `resolve(community) -> Promise<string[] memberDIDs>`. The server
// wires the real source(s):
// - a bsky list (app.bsky.graph.list, roster.js resolveRoster over a DidPeer) — the DISCOVERABLE /
// public community. Already proven.
// - a StateBaseV3 room (atproto-room, read-only __members__) — the INVITE-ONLY community. Same shape,
// different resolver.
// Communities are node-configured descriptors; a viewer's membership is derived (the communities whose
// roster CONTAINS the viewer's DID). A viewer-declared "entry-links" model (the viewer points at the
// communities they joined, from their own repo) is a richer future variant — same scopeFor() seam.
const DEFAULT_TTL_MS = 5 * 60 * 1000
export class Communities {
// communities: [{ id, ... }] opaque descriptors passed verbatim to resolve(). resolve(community) ->
// Promise<string[]> member DIDs. ttlMs: roster cache TTL (rosters are P2P reads; caching avoids a
// re-resolve per request). now: injectable clock (tests).
constructor ({ communities = [], resolve, ttlMs = DEFAULT_TTL_MS, now = () => Date.now() } = {}) {
if (typeof resolve !== 'function') throw new Error('Communities: resolve(community) => Promise<string[]> is required')
this._communities = Array.isArray(communities) ? communities.slice() : []
this._resolve = resolve
this._ttlMs = ttlMs
this._now = now
this._cache = new Map() // id -> { members: Set, exp }
}
get communities () { return this._communities.slice() }
add (community) { if (community && community.id != null) this._communities.push(community) }
// Cached member Set for one community. On a resolve failure, returns the last-good cached roster if
// present (stale-serve), else an empty Set — a flaky community never breaks the whole view.
async rosterOf (community) {
const id = String(community && community.id)
const hit = this._cache.get(id)
if (hit && this._now() < hit.exp) return hit.members
try {
const members = new Set((await this._resolve(community)) || [])
this._cache.set(id, { members, exp: this._now() + this._ttlMs })
return members
} catch (e) {
if (hit) return hit.members // stale-serve on failure
this._cache.set(id, { members: new Set(), exp: this._now() + this._ttlMs })
return new Set()
}
}
// The per-viewer author scope: union of member DIDs across every community whose roster contains the
// viewer's DID. Returns a Set, or null when the viewer belongs to no known community (→ caller falls
// back to the global timeline). Rosters resolve concurrently.
async scopeFor (viewerDid) {
if (!viewerDid || !this._communities.length) return null
const rosters = await Promise.all(this._communities.map((c) => this.rosterOf(c).then((m) => ({ c, m }))))
const scope = new Set()
let member = false
for (const { m } of rosters) {
if (!m.has(viewerDid)) continue
member = true
for (const did of m) scope.add(did)
}
return member && scope.size ? scope : null
}
// The communities a viewer belongs to (descriptors) — for UI / diagnostics.
async communitiesFor (viewerDid) {
if (!viewerDid) return []
const rosters = await Promise.all(this._communities.map((c) => this.rosterOf(c).then((m) => ({ c, m }))))
return rosters.filter(({ m }) => m.has(viewerDid)).map(({ c }) => c)
}
// Union of member DIDs across ALL configured communities — what the node should be indexing so that
// per-viewer scopes have posts to filter. (The node still only serves DIDs it actually replicates.)
async allMemberDids () {
const rosters = await Promise.all(this._communities.map((c) => this.rosterOf(c)))
const all = new Set()
for (const m of rosters) for (const did of m) all.add(did)
return all
}
invalidate (id) { if (id == null) this._cache.clear(); else this._cache.delete(String(id)) }
}
export default Communities