OverviewFilesCommitsIssuesDocsReleasesPeers

favela-web / lib / communities.js

89 lines · javascript ref:
// 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