// The spaces an account has, discovered rather than typed. // // Both halves come out of one repo — the signed-in human's own — which is the whole point: // // - a space you **created** is a `space` record you wrote, so it is simply there; // - a space you were **added to** is an `addMember` record in the ADMIN's repo, which atproto gives // no way to find (it indexes forward only, and a reverse lookup would be the AppView §1 refuses). // So the first time you open one, the app writes a `join` into your own repo — and from then on // that space is discoverable the same cheap way your own are. // // A `join` is a bookmark and not a credential. It grants nothing, the fold never consults one when // deciding whose records count, and writing one into a space you hold no grant in would assert // something every materializer ignores — which is why `join.svelte.ts` only writes one for an actual // member. All this module does is read them back. // // The cost is one `listRecords` per collection plus one `getRecord` per joined space, because a join // carries the space's address and not its name. Naming the space in the join record would have been one // request cheaper and would have let this list disagree with the record it points at. // // A PRIVATE space's bookmark is the exception and the reason the collection is public at all: there is // no space record on any PDS to read, so the row is drawn from the bookmark and the fetch is skipped // (design §18). This list is how a fresh browser lists its private spaces again — opening one still // requires the corpus in this profile's IndexedDB or a fresh transfer ticket. import { FetchRepoTransport, type RepoReadTransport } from '@radial/atproto' import { COLLECTIONS, validateRecord, type JoinRecord, type SpaceRecord } from '@radial/core' import { Identities } from './identity.js' export interface OwnedSpace { uri: string /** Blank when the space record could not be read — the row says so rather than inventing a name. */ name: string description: string createdAt: string /** `created` is a space record in this repo; `joined` is a `join` pointing at somebody else's. */ kind: 'created' | 'joined' /** * A private-mode bookmark (design §18). There is no space record anywhere to read for one, so the * row carries whatever the bookmark itself says and the picker draws it differently — see the * fetch below. */ private: boolean } export type OwnedStatus = 'idle' | 'loading' | 'ready' | 'failed' export const owned = $state({ status: 'idle' as OwnedStatus, /** Whose spaces these are, so a second sign-in cannot be overtaken by the first one's answer. */ did: '', spaces: [] as OwnedSpace[], error: '', }) let shared: RepoReadTransport | undefined export interface DiscoverOptions { /** Injected by tests; the browser resolves the DID's PDS through the cached identity resolver. */ transport?: RepoReadTransport } /** Every record of one collection in one repo. Paged, and it stops if a PDS repeats a cursor. */ async function listAll( read: RepoReadTransport, did: string, collection: string, ): Promise> { const out: Array<{ uri: string; value: unknown }> = [] const seen = new Set() let cursor: string | undefined do { const page = await read.listRecords({ did, collection, limit: 100, ...(cursor ? { cursor } : {}) }) out.push(...page.records.map((entry) => ({ uri: entry.uri, value: entry.value }))) if (page.cursor && seen.has(page.cursor)) break if (page.cursor) seen.add(page.cursor) cursor = page.cursor } while (cursor) return out } /** Read-only deep-link classification: a private bookmark must never fall through to public stores. */ export async function isPrivateBookmarkedSpace( did: string, spaceUri: string, transport: RepoReadTransport, ): Promise { if (!did) return false const rows = await listAll(transport, did, COLLECTIONS.join) return rows.some((row) => { if (row.uri === '') return false const validated = validateRecord(COLLECTIONS.join, row.value) if (!validated.success) return false const join = validated.value as JoinRecord return join.space.uri === spaceUri && (join.protocol !== undefined || join.founder !== undefined) }) } /** * List the spaces this DID wrote and the ones it has joined. Runs on every mount of the picker rather * than caching a result, because a stale list of spaces is worse than a redundant read — but it keeps * the previous answer on screen while it re-reads, so nothing flashes empty. */ export async function discoverOwned(did: string, options: DiscoverOptions = {}): Promise { if (!did) return forgetOwned() if (owned.did !== did) { owned.did = did owned.spaces = [] } owned.status = 'loading' owned.error = '' try { const read = options.transport ?? (shared ??= new FetchRepoTransport(new Identities().pdsResolver)) const [spaceRows, joinRows] = await Promise.all([ listAll(read, did, COLLECTIONS.space), listAll(read, did, COLLECTIONS.join), ]) const created: OwnedSpace[] = [] for (const row of spaceRows) { // A value the lexicon does not accept is not a space, whatever collection it was filed under. const validated = validateRecord(COLLECTIONS.space, row.value) if (!validated.success) continue const value = validated.value as SpaceRecord created.push({ uri: row.uri, name: value.name, description: value.description, createdAt: value.createdAt, kind: 'created', private: false, }) } // One row per space. A join naming a space this repo also wrote is redundant — `join.svelte.ts` // does not write one — but a repo that already holds one must not produce two rows for it. const already = new Set(created.map((space) => space.uri)) const pointers: JoinRecord[] = [] for (const row of joinRows) { const validated = validateRecord(COLLECTIONS.join, row.value) if (!validated.success) continue const join = validated.value as JoinRecord if (already.has(join.space.uri)) continue already.add(join.space.uri) pointers.push(join) } const joined = await Promise.all( pointers.map(async (join): Promise => { const uri = join.space.uri // A private space's records never reach a PDS, so there is no space record to go and read — // asking for one is a request guaranteed to 404 and to produce the wrong sentence about why // ("could not read it" for a space that is exactly where it should be). The bookmark carries // everything a list needs: it is a private-mode one when it names the protocol it joined // under, and the optional label is the name its holder chose to publish (§18). if (join.protocol !== undefined || join.founder !== undefined) { return { uri, name: join.label ?? '', description: '', createdAt: join.createdAt, kind: 'joined', private: true, } } try { // The name lives in the space record, in its owner's repo — the transport validates it on the // way through, so anything that is not a space throws here rather than becoming a row. const value = (await read.getRecord(uri)).value as SpaceRecord return { uri, name: value.name, description: value.description, createdAt: value.createdAt, kind: 'joined', private: false, } } catch { // The owner's PDS is unreachable, or the record is gone. The row still stands for a space this // human joined, so it is listed by address rather than quietly dropped. return { uri, name: '', description: '', createdAt: '', kind: 'joined', private: false } } }), ) // A sign-out, or a different account, landed while this was in flight: its answer is not ours. if (owned.did !== did) return owned.spaces = [...created, ...joined].sort( (left, right) => left.name.localeCompare(right.name) || left.uri.localeCompare(right.uri), ) owned.status = 'ready' } catch (error) { if (owned.did !== did) return owned.status = 'failed' owned.error = error instanceof Error ? error.message : String(error) } } export function forgetOwned(): void { owned.status = 'idle' owned.did = '' owned.spaces = [] owned.error = '' }