/** * Rendering another app's objects from its published manifest — the pure part. * * Split from `foreign.ts` so it can be exercised against a live relay without a * Convex deployment: everything here takes a `query` function rather than * reaching for one. `foreign.ts` supplies the authenticated bridge; a test * supplies its own. * * **Nothing in this file knows what Linear-lite is.** No kind number, no field * name, no status vocabulary is hardcoded — every one comes off the NIP-89 * manifest at runtime. If that stops being true the demo stops proving * anything: it becomes an integration written against one app, which is the * thing the whole exercise argues against. */ import { decodeNevent, encodeNaddr, encodeNevent, pointerToAddress, referenceToPointer, type AddressPointer, type EventPointer } from '@estiva-app/protocol' import { parseProfile, type Profile, type SignedEvent } from '@estiva-app/protocol' import { MAX_FILTERS_PER_QUERY, RELAY_PAGE_CEILING } from '@estiva-app/protocol' import { attachmentsInDocument, BLOCK_DOCUMENT_FORMAT, CONTENT_FORMAT_TAG, imetaTag, parseBlockDocument, } from '@estiva-app/protocol' import { planPlaceFile, planUnlistFile, type FolderRef } from './folders.js' /* SPEC §6.4's comment strength and §6.3's ordering are the conversation package's (CON-5): one copy of each rule, read here for a file's `comments`, `conversationsOf` and every change fold. Its `isCommentOn` is stricter than the copy that lived here — a `kind:9` is never a comment (CON-20 migrated them), a reply is never a root, and `ts` is trusted only inside its own second. */ import { anchorsOf, byOrder, isCommentOn, isReply, MAX_MENTIONED_PEOPLE, mentionTagsFor, orderingMs } from '@estiva-app/conversation' /** Query the relay. Returns matching events; shape mirrors the HTTP bridge. */ export type QueryFn = (filters: Record[]) => Promise /** An unsigned Nostr event, ready for `sign.ts`. */ export interface UnsignedActionEvent { pubkey: string created_at: number kind: number tags: string[][] content: string } /** * The people named on another app's objects (FEE-1). * * A manifest says a slot holds a pubkey; it never says whose. Peek showed the * first eight characters of the key, which is not a person — it is the thing a * person is behind. The name and face come from kind:0, the same place the * message list already gets them, so the same person reads the same way in both. * * Keyed by pubkey. A key that resolves to nothing is simply absent, and the * renderer decides what an unknown person looks like. */ export type People = Record /** * Looking people up, as a seam rather than a direct call. * * The browser puts a session cache in front of this: a topic with ten reference * widgets resolves ten times, and it is the same handful of people every time. * Tests and Storybook pass a fixture instead and never touch a relay. */ export type PeopleFn = (pubkeys: string[]) => Promise /** The default: one kind:0 query, straight to the relay. */ /** * How long a *name* may be believed. * * Names change rarely, so this is generous. See {@link PROFILE_MISS_TTL_MS} for * the half that matters. */ export const PROFILE_HIT_TTL_MS = 10 * 60_000 /** * How long an *absent* profile may be believed, and it is deliberately short. * * A miss is somebody who has not finished setting up their identity — which is * to say, precisely the person whose name is about to arrive. Cached as long as * a hit, they render as `nostr:<8 chars>` until a full reload, and a consumer * that re-reads on a timer looks like its refresh is broken. That is PEE-3, * found in Peek once its panels started polling. */ export const PROFILE_MISS_TTL_MS = 60_000 export interface PeopleCacheOptions { hitTtlMs?: number missTtlMs?: number /** Injectable clock. A test needs to move time, not wait ten minutes. */ now?: () => number } /** * A profile store that outlives any one lookup. * * The store and the lookup have different lifetimes, which is why this is not * simply a wrapped {@link PeopleFn}: a consumer builds its query per call — * Peek's carries the viewer's token — while the profiles it finds are public * and worth keeping across all of them. `through` takes the lookup of the * moment and answers from one store. * * **Caller-owned**, like {@link createProjectionCache}. Peek's original lived in * a module-level `Map`, which worked but meant every test needed a reset hook * to undo the previous one. */ export interface PeopleCache { /** Wrap a lookup so it batches and expires against this store. */ through(fromRelay: PeopleFn): PeopleFn /** The common case: {@link peopleViaRelay}, cached. */ viaRelay(query: QueryFn): PeopleFn /** Forget everything. What a sign-out should call. */ clear(): void } /** A profile with nothing in it is a miss — the relay had no `kind:0`. */ const isMiss = (person: People[string]) => Object.keys(person).length === 0 export function createPeopleCache(options: PeopleCacheOptions = {}): PeopleCache { const hitTtlMs = options.hitTtlMs ?? PROFILE_HIT_TTL_MS const missTtlMs = options.missTtlMs ?? PROFILE_MISS_TTL_MS const now = options.now ?? Date.now const known = new Map() const through = (fromRelay: PeopleFn): PeopleFn => async (pubkeys) => { const at = now() /* One lookup for the whole unknown set, never one per key. A screen with ten reference widgets asks about the same handful of people, and asking separately would turn one request into N — which is the cost this exists to avoid, not a detail of it. */ const unknown = pubkeys.filter((key) => { const cached = known.get(key) return cached === undefined || cached.expiresAt <= at }) if (unknown.length > 0) { const found = await fromRelay(unknown) for (const key of unknown) { const person = found[key] ?? {} known.set(key, { person, expiresAt: at + (isMiss(person) ? missTtlMs : hitTtlMs) }) } } return Object.fromEntries(pubkeys.map((key) => [key, known.get(key)?.person ?? {}])) } return { through, viaRelay: (query) => through(peopleViaRelay(query)), clear: () => known.clear(), } } export function peopleViaRelay(query: QueryFn): PeopleFn { return async (pubkeys) => { if (pubkeys.length === 0) return {} const events = await query([ { kinds: [KIND_PROFILE], authors: pubkeys, limit: pubkeys.length }, ]) const people: People = {} // Oldest first, so a newer profile overwrites an older one. kind:0 is // replaceable and the relay should hold one per author, but ordering the // fold is cheaper than trusting that. for (const event of [...events].sort(byOrder)) { people[event.pubkey] = parseProfile(event) } return people } } /** * Every pubkey an object would put on screen. * * Slots and meta carry them as values; a `pubkey` action carries the current * holder of the field it sets, which in the sidebar is the *only* place the * project's lead appears. Missing that one is what left a face-shaped button * showing eight hex characters. */ function pubkeysIn(object: ForeignObject): string[] { const keys: string[] = [] for (const slot of [...Object.values(object.slots), ...object.meta]) { if (slot.isPubkey && slot.value) keys.push(slot.value) } for (const action of object.actions) { if (action.control === 'pubkey' && action.current) keys.push(action.current) } return keys } /** kind:0, the profile every other app publishes too. */ const KIND_PROFILE = 0 /** NIP-89 kinds. These two are protocol, not app-specific. */ const KIND_HANDLER_RECOMMENDATION = 31989 const KIND_HANDLER_INFORMATION = 31990 /** * Default kind for comments on a foreign object. * * NIP-22, and no app owns it — but the owning app gets to say otherwise. An app * whose objects live in a Folder may well treat a comment as a message in that * container instead, which is a better answer when the container is also a Peek * topic: the comment and the topic conversation become the same event rather * than two records nobody reconciles. * * So this is the fallback, and `commentKindsOf` reads the real one off the * manifest's own `comment` action. Hardcoding it here would quietly stop * finding comments the moment an app said something different. */ const KIND_COMMENT = 1111 /** * NIP-29's chat message, the one a Folder's conversation is made of — SPEC §7.8. * * Owned by no app, like a comment: the specification says how it draws and * where it opens, so no manifest's `k` tag is asked about it. * * why: https://github.com/estiva-app/estiva-docs/blob/main/decisions/a-chat-message-belongs-to-the-protocol.md */ export const KIND_MESSAGE = 9 /** * NIP-09's deletion request. Protocol, like the two above, and the one kind an * action may emit that is neither a change nor a comment nor a creation: an * action emitting it is a *deletion* (`control: 'confirm'`), and the event it * builds names the object's address, which is the NIP's shape for an * addressable target and what the relay adjudicates by author. */ const KIND_DELETION = 5 /** * Every kind an app's comments might be under — what it publishes **now**, plus * any it has published before. * * `emits.kind` is a single number, and for a while that was enough. It stops * being enough the moment an app *changes* the kind it emits, because the old * events do not move: a `kind:9` message is not replaceable at all, so a comment * written under the old kind stays under it permanently. Reading only the * declared kind would show an object's newest comments and silently drop every * one written before the change — no error, nothing empty, just a thread that * begins in the middle. * * So the owning app may also declare `emits.alsoRead`, which is the kinds it * used to publish. That is the only place the knowledge actually lives; the * alternative is Peek hardcoding one app's history, which is exactly what the * note on `KIND_COMMENT` above says not to do. * * Absent `alsoRead` this returns a single kind, so a manifest written before * the field existed behaves exactly as it did. */ export function commentKindsOf(manifest: Manifest): number[] { const declared = manifest.actions?.find((action) => action.id === 'comment') const current = declared?.emits?.kind ?? KIND_COMMENT const superseded = declared?.emits?.alsoRead ?? [] return [...new Set([current, ...superseded])] } /** * How many comments one conversation read asks for. * * **One constant for the panel and the badge, deliberately.** A count that * saturated at a different number than the view it labels would put "100" next * to a hundred and fifty visible messages, and a reader would be right to trust * neither. Both {@link resolveForeignObject} and {@link conversationCountsOf} * use this, so a file at the cap reports the cap in both places and the badge * means exactly "what opening this will show you". */ export const CONVERSATION_LIMIT = 200 /** * How many messages each file's conversation holds — one round trip for all of them. * * For the question a reader actually has in front of a list: *is this one worth * opening*. Answering it per file through {@link resolveForeignObject} would be * a resolve each, which is the cost {@link resolveFolderContents} exists to * avoid — against a relay metering reads at 300 a minute, a folder of * twenty-five would not load. * * ## One filter per file, not one filter for all of them * * The obvious shape — a single `#a` naming every address — shares one `limit` * across every file, so one busy file starves the rest and the short results * are indistinguishable from "there are no more". Per file, the relay clamps * each filter's limit independently, so each gets its own budget. The results * come back merged, which is fine: every comment names its object in its own * `a` tag, so attribution never depends on the order they arrive in. * * Chunked at `MAX_FILTERS_PER_QUERY` because a folder may hold more files than * one POST accepts, and silently counting only the first 128 is the kind of * quiet wrong answer this module keeps removing. * * ## What a zero means * * Exactly "nothing is addressed to this file", which is **not** the same as * "nobody has discussed it". A Peek topic's messages carry `h` and no `a` — * its conversation belongs to the container rather than the object — so a topic * counts zero however busy it is, until a topic becomes an addressable file * (RFC 0.4 §11.1). Ship's issues carry `a` and count correctly. A consumer * showing this must not render the zero as "no discussion". * * Files with no address are absent from the result rather than zero, so a * consumer can tell "asked and got none" from "not askable". */ export async function conversationCountsOf( files: ForeignObject[], query: QueryFn, /** Warm from the read that produced these files; see {@link ProjectionCache}. */ cache?: ProjectionCache, ): Promise> { const conversations = await conversationsOf(files, query, cache) const counts: Record = {} for (const [ref, messages] of Object.entries(conversations)) counts[ref] = messages.length return counts } /** * One message of a file's conversation, as much of it as a list needs. * * Not the event: a list deciding whether a file is worth opening, or whether it * is unread, needs when and by whom and which thread — never the body. `root` * is the id of the thread's root message, which is the message's own id when * it *is* the root; a reader applying SPEC §11.3 needs it to find the thread's * marker. */ export interface ConversationMessage { id: string /** Unix seconds, as the event carries and as NIP-RS stores. */ at: number /** Author pubkey. */ by: string root: string } /** * The root of a comment's thread — NIP-22's uppercase `E` when the comment * carries one, else the `['e', , '', 'reply']` shape §6.4's table gives * for a reply, else the message itself. */ function rootOf(event: SignedEvent): string { const upper = event.tags.find((t) => t[0] === 'E' && t[1])?.[1] if (upper) return upper const lower = event.tags.find((t) => t[0] === 'e' && t[1])?.[1] return lower ?? event.id } /** * What each file's conversation holds — one round trip for all of them. * * The read behind {@link conversationCountsOf}, returned rather than reduced * to a number, for the second question a list has once it knows a file is * worth opening: *is any of it new to me* (SPEC §11.1, FOL-16). A file's * read-state context is its address and its messages' own `a` tag names it, * so a list can judge unread per file from exactly this — each message's time, * author and thread against the file's marker — with no second read. Read * state itself is not this package's business, so nothing here knows a marker; * the consumer brings its own rule. * * Everything the count's doc says holds here: one filter per file, chunked at * `MAX_FILTERS_PER_QUERY`, attributed by every `a` tag, deduplicated across * filters, and a file with no address is absent rather than empty. Messages are * returned oldest first. * * Only what SPEC §6.4 calls a **comment** is here (CON-15). The `#a` read also * returns every message that merely *mentions* the file — a `kind:1111` rooted * on another file, a `kind:9` whose body names this one — and those are * *Mentioned in*, which §6.4 says MUST NOT be presented as the file's own * discussion. A badge counting them said "3" beside an issue with one comment. * See `@estiva-app/conversation`'s `isCommentOn` for the per-kind rule, which * this passes the owner's declared comment kinds (§7.3). * * ## `replies`: the threads under those comments too (CON-34) * * A dot judges replies by their thread (SPEC §11.3), and a reply never comes * back for `#a`: its lowercase tags name its parent comment, and the file is * only in its uppercase `A` (§6.4). Asked with `replies`, each file gets a * second filter by `#A`, and a reply is kept when its root is one of the * comments this read found for the file — the root decides (C11), so a reply * in a thread that only mentions the file, or under a root older than the * limit, is not here. The messages are then roots and replies, told apart by * `root === id`; a consumer that counts comments must not ask for them. */ export async function conversationsOf( files: ForeignObject[], query: QueryFn, /** Warm from the read that produced these files; see {@link ProjectionCache}. */ cache?: ProjectionCache, options: { replies?: boolean } = {}, ): Promise> { const addressed = files.flatMap((file) => { if (!file.address) return [] try { return [{ file, pointer: referenceToPointer(file.address) }] } catch { return [] } }) if (addressed.length === 0) return {} // One manifest per app, never per file — the comment kinds are the owning // app's declaration and every file it owns in this list shares them. const byApp = new Map() for (const { pointer } of addressed) byApp.set(manifestKeyOf(pointer), pointer) const manifests = new Map() await Promise.all( [...byApp].map(async ([key, pointer]) => { const resolved = await resolveManifest(pointer, query, cache) if (resolved) manifests.set(key, resolved) }), ) const asked = addressed.flatMap(({ file, pointer }) => { const resolved = manifests.get(manifestKeyOf(pointer)) // No manifest, no declared comment kind: a guess of 1111 would report a // confident zero for an app that uses something else. if (!resolved) return [] return [ { ref: file.ref, address: file.address as string, filter: { kinds: commentKindsOf(resolved.manifest), '#a': [file.address], limit: CONVERSATION_LIMIT, }, }, ] }) if (asked.length === 0) return {} const refOf = new Map(asked.map((a) => [a.address, a.ref])) const kindsOf = new Map(asked.map((a) => [a.address, a.filter.kinds])) const conversations: Record = {} for (const a of asked) conversations[a.ref] = [] const filters: Record[] = asked.flatMap((a) => options.replies ? [a.filter, { kinds: [KIND_COMMENT], '#A': [a.address], limit: CONVERSATION_LIMIT }] : [a.filter], ) /** The file in this list an event is a comment on (§6.4), by every `a` it carries. */ const commentedFile = (event: SignedEvent): string | undefined => event.tags .filter((t) => t[0] === 'a' && t[1] && refOf.has(t[1]) && isCommentOn(event, t[1], kindsOf.get(t[1]))) .map((t) => refOf.get(t[1])) .find((found) => found !== undefined) /** Replies, kept until every root is in: which file a reply is in is its root's. */ const replies: SignedEvent[] = [] const seen = new Set() for (let start = 0; start < filters.length; start += MAX_FILTERS_PER_QUERY) { const events = await query(filters.slice(start, start + MAX_FILTERS_PER_QUERY)) for (const event of events) { // A relay may answer one event under two filters; counting it twice would // inflate the badge above what the panel then shows. if (seen.has(event.id)) continue seen.add(event.id) if (options.replies && isReply(event)) { replies.push(event) continue } /* Every `a` tag, not the first one. The relay matches a filter against *any* of them, so an event can come back for an address that is not the one its first tag names — the same lesson `childrenFrom` already carries ("a second `a` tag still counts"). Reading `tags.find` would attribute such a comment to nobody and quietly undercount. And only the address it is a *comment* on (CON-15). The `a` tag is the index of every reference, so a message that names two files in this list came back for both; it belongs to the one whose discussion it is, and to neither when it merely mentions them. `isCommentOn` is §6.4's rule. */ const ref = commentedFile(event) if (ref !== undefined) { conversations[ref].push({ id: event.id, at: event.created_at, by: event.pubkey, root: rootOf(event) }) } } } if (replies.length > 0) { /* Each root by its own id only, never by the event its `E` names: any comment on another file could carry this thread's root there and take its replies (review, foundation#112). The ids are unique, so nothing overwrites. */ const fileOfRoot = new Map() for (const [ref, messages] of Object.entries(conversations)) { for (const m of messages) if (m.root === m.id) fileOfRoot.set(m.id, ref) } const addressOf = new Map(asked.map((a) => [a.ref, a.address])) // A reply in a file's thread names that file in its `A` (§6.4), so one that // does not is not counted there, whatever root it claims. const inList = replies.filter((event) => anchorsOf(event).some((address) => refOf.has(address))) /* A new reply in an old thread: its root is past the `#a` limit, so this read did not find it — on a busy file, exactly the reply the dot exists for. Those roots are asked by id in one request, capped at the limit so replies naming made-up roots cannot multiply the reads, and decide as any root does (C11). Listed for attribution only, not as messages. */ const missing = [...new Set(inList.map(rootOf).filter((root) => !fileOfRoot.has(root)))].slice(0, CONVERSATION_LIMIT) if (missing.length > 0) { for (const root of await query([{ ids: missing, limit: missing.length }])) { const ref = commentedFile(root) if (ref !== undefined && !fileOfRoot.has(root.id)) fileOfRoot.set(root.id, ref) } } for (const event of inList) { const root = rootOf(event) const ref = fileOfRoot.get(root) if (ref === undefined || !anchorsOf(event).includes(addressOf.get(ref) as string)) continue conversations[ref].push({ id: event.id, at: event.created_at, by: event.pubkey, root }) } } for (const messages of Object.values(conversations)) messages.sort((a, b) => a.at - b.at || (a.id < b.id ? -1 : 1)) return conversations } /** Every thread of one file's conversation, keyed by the comment it hangs from. */ export interface Threads { /** The root comments themselves, by id — the only read that reports their `kind` and tags. */ roots: Record /** Direct replies to each root, oldest first. A root with none is absent, not `[]`. */ replies: Record } /** * The replies under a file's comments — {@link conversationsOf}'s counterpart, * read by `#e` (PRO-20, from Peek's FOL-19 thread pane). * * **Asked by `#e`, not by `#a`, and that is the whole reason this exists.** A * reply's lowercase tags name its parent comment, not the file (SPEC §6.4), so * the `#a` read that brings the roots never brings a reply — whichever app * wrote it. `rootIds` are those roots, typically `conversationsOf`'s ids. * * **Every thread in one request.** The roots by `ids` and their replies by * `#e` go in one POST, because a `#e` filter takes a list: a whole pane's * replies cost the round trip of one thread's, which is what lets a comment * carry an honest reply count. The roots come back beside them because NIP-22's * lowercase `k` on a reply names the *parent's* kind, and no projection reports * it. * * The kinds are the owning app's, from its manifest (`commentKindsOf`), so a * NIP-22 `kind:1111` reply and Ship's `kind:9` ones both arrive. No manifest, * or a reference that does not decode, answers empty rather than guessing 1111. * * Only *direct* replies: a reply's lowercase `e` names its immediate parent, so * a reply to a reply hangs off that reply and is not here. The replies share * one `limit`, by default `CONVERSATION_LIMIT` — the same budget as one file's * comment read, and what Peek's pane asks for. * * A refused read is whatever `query` does with one — this adds no catch, so an * unreadable thread and an unanswered one stay distinguishable. */ export async function threadsOf( /** The file's address, or any reference `referenceToPointer` reads. */ reference: string, rootIds: readonly string[], query: QueryFn, cache?: ProjectionCache, /** How many replies, across every thread, one read asks for. */ limit: number = CONVERSATION_LIMIT, ): Promise { const empty: Threads = { roots: {}, replies: {} } if (rootIds.length === 0) return empty let pointer: AddressPointer try { pointer = referenceToPointer(reference) } catch { return empty } const resolved = await resolveManifest(pointer, query, cache) if (!resolved) return empty const kinds = commentKindsOf(resolved.manifest) const ids = [...new Set(rootIds)] const events = await query([ { ids, limit: ids.length }, { kinds, '#e': ids, limit }, ]) const wanted = new Set(ids) const roots: Record = {} const replies: Record = {} const seen = new Set() for (const event of events) { if (seen.has(event.id)) continue seen.add(event.id) if (wanted.has(event.id)) { roots[event.id] = event continue } /* The parent. A NIP-10 `reply` marker says it outright, and then only that one counts: a reply to a reply also carries the thread's `root`-marked `e`, which names a wanted id and is not its parent. Unmarked (NIP-22's single lowercase `e`, or a bare mention beside it), the `e` naming a root of *this* conversation — picking the first would file it under a quote. */ const es = event.tags.filter((tag) => tag[0] === 'e' && tag[1]) const parent = (es.find((tag) => tag[3] === 'reply') ?? es.find((tag) => wanted.has(tag[1])))?.[1] if (parent && wanted.has(parent)) (replies[parent] ??= []).push(event) } // Oldest first, ties by id so two replies in one second never swap places. for (const thread of Object.values(replies)) { thread.sort((a, b) => a.created_at - b.created_at || (a.id < b.id ? -1 : 1)) } return { roots, replies } } /** How the owning app says its records should be read. */ interface RecordsRule { changeKind: number targetTag: string fieldTag: string valueTag: string order: string[] rule: string /** * A folded field whose value means "do not show this object". * * Archiving is a field, not a deletion, so an archived record still comes * back from every query and renders perfectly well. Without this a consumer * cannot tell it apart from live work — which is how a topic whose Folder * held three archived projects ended up showing one of them. * * Peek honours the rule without knowing what the word means: the owner says * which field and which value, and this stays out of it. */ hiddenWhen?: { field: string; equals: string } } interface SlotSpec { /** * A tag on the root event, or a list of them meaning **first that resolves**. * * A list exists for the same reason `emits.alsoRead` does: an app that * renames a tag cannot rename it on the events it already published, so its * history spans both spellings permanently. Ship moving a project's title * from `title` to `name` is the case in hand — without a fallback, either * every record written before the rename renders blank or every one written * after does. * * Not to be confused with a `SlotSpec[]`, which the caller treats as "render * all of these as meta". This is one slot with several places to look. */ tag?: string | string[] field?: string fold?: string map?: string as?: string label?: string truncate?: number default?: string /** * **Child objects, found by the tag on the child that names this one.** * * The `list` slot (RFC 0.4 §13.3, PRO-2). The only slot source that does not * read the root event: every other field here answers "what does this event * say?", and this one answers "what points at it?". * * `limit` is the producer saying how many are worth fetching. **Recursion * depth is deliberately not here** — a child rendered through its own * projection may declare a `list` too, and the app at risk of the render loop * is the one drawing it, so the budget is the consumer's (`MAX_LIST_DEPTH`). * §13.4's honour-system rule cuts that way: a producer that could set the * consumer's recursion budget could hang it. */ children?: { kind: number via: string limit?: number /** * What the child's `via` tag holds — added by PRO-6. * * `address` (the default, and Ship's case) means the tag carries the * parent's full `kind:pubkey:d`. `identifier` means it carries only the * parent's `d`. * * Found by declaring a projection for a Peek Topic. A message names its * channel with `h`, and `h` holds the **bare channel uuid** — which is the * topic's `d`, not its address. Nothing in NIP-29 is going to change that, * so a `list` slot that could only compare addresses could not express the * one relationship Peek has. Defaulting to `address` keeps every manifest * written before this reading exactly as it did. */ match?: 'address' | 'identifier' /** * The field on the child whose change moves it to another parent — SPEC * §7.2, MAN-1. `project` on Ship's issues, `parent` on a bare file. * * The `via` tag is written once, by the child's author, on a root only * they may replace; a move is therefore a change event, and this names the * field it sets. The folded value wins over the tag, and an empty one is a * move to no parent. Ignored with `match: 'identifier'`, because a move's * value is an address. */ movedBy?: string /** * The child names its own parent with its `via` tag, **of any kind** — the * bare file's rule (SPEC §6.7 Nesting), declared rather than hard-coded so * an owned kind can have it: a document sits under a topic, a project or * another document (Miky, 2026-10-14). With it, `movedBy` names the field * without limiting the parent to the declaring kind, and a consumer reads * the parent off the child instead of matching the declaring kind's prefix. */ anyParent?: boolean } } /** * What invoking an action does, coarsely enough for a caller to decide whether * to ask first (RFC 0.4 §13.4). * * Closed, and the closure is the point: a value only means something if every * consumer reads it the same way. * * | | | * | --- | --- | * | `safe` | changes nothing an author would mind — invoking unprompted is fine | * | `writes` | publishes a change somebody will see | * | `destructive` | removes or supersedes something | * * **Only an explicit `safe` may be read as safe.** Absent, unrecognised, or * from a manifest published before this field existed all mean *unknown*, and * unknown has to fall on the cautious side — a consumer testing * `effect !== 'destructive'` gets `true` for a manifest that never said, which * is the wrong direction to be wrong in. That is why {@link withKnownEffect} drops * a value outside this set rather than passing it through: an unrecognised * effect is not a fourth meaning, it is a consumer that does not understand * this manifest yet. */ export type ActionEffect = 'safe' | 'writes' | 'destructive' export const ACTION_EFFECTS: readonly ActionEffect[] = ['safe', 'writes', 'destructive'] /** An action the owning app says other apps may perform. */ export interface ManifestAction { id: string label: string /** * Prose aimed at a machine, distinct from `label`, which is a button caption * (RFC 0.4 §13.4). * * "Change status" tells a person which button to press and tells a caller * choosing *between* actions nothing at all. It was taken early because * adding a field costs a line and adding one after several apps have * published manifests is a migration across every one of them — a manifest is * republished by its owner alone. The same argument `emits.alsoRead` makes one * level down. * * **Something reads it now.** Peek's launcher selects an action from a * conversation by matching on this prose (INT-5), across every manifest it can * resolve. So a `description` written for a screen is not merely unused, it is * an action a caller never picks — a failure with no error in it. * {@link actionProblems} is the check to run before signing. */ description?: string /** See {@link ActionEffect}. */ effect?: ActionEffect /** Kind(s) this applies to, as strings. */ appliesTo: string | string[] emits: { kind: number field?: string scope?: string /** * Set by an action that creates a whole new object under this one: the tag * the child carries, and what it points at. `toAddressOf: "self"` means the * child names the object the action was invoked on. * * Peek reads these two to learn a **containment relation** — "kind X holds * kind Y" — without being told which app or which kinds are involved. It is * the only thing in the manifest that says so, which is what lets the * sidebar start from a Folder and find a project with issues in it. */ setTag?: string toAddressOf?: string /** * Kinds this action *used* to emit, which consumers must still read. * * Only ever additive to a read, never to a write: `kind` is what gets * published, this is what also gets fetched. See `commentKindsOf`. */ alsoRead?: number[] /** * On a creation: the tag the new root names its Folder with — `h`, the * default, or `buzz-channel` (SPEC §7.3, MAN-1). * * `buzz-channel` is how a Ship project is written: the relay stores the * record globally, so anybody can discover the project, while its changes * and comments keep `h`. Any other value is refused when building, because * a Folder named in a tag nothing reads is no Folder. */ placement?: string /** * That objects this action writes are named in their Folder's state, so a * creation is followed by a `kind:1852` add and a deletion by a remove from * each Folder listing it (SPEC §7.3, MAN-1; FOL-48). * * Only {@link buildActionEvents} builds one, because only its caller can * say whether a Folder has state — a read, never a guess. */ listed?: boolean } input?: { type: string /** For a scalar input: the vocabulary its value must come from. */ enum?: string /** * For a scalar change: the richest content model the owner accepts for the * value — SPEC §7.3's `format`, MAN-1. `estiva-blocks-1` is a block * document (§13.3). A format on the declaration, not a new type, because * the wire value is a string either way; a plain value is still accepted, * written untagged as marker text (§13.4). */ format?: string /** * For `type: 'object'`: the fields a consumer draws, and which are required. * * **A property's name is the tag its value is written to** (PRO-4). That * rule was implicit — Ship's `add-issue` declares `{ title }` and a Ship * issue carries `["title", …]`, so it held by coincidence of naming and * nothing said so. It is stated here because a consumer cannot construct * the event without it, and RFC 0.4 §13.4 asserts the existing declaration * is already sufficient for one to try. * * **A property may instead target the event's `content`** — PRO-18. That * limit used to be recorded here as one designed around: *"nothing can * target an event's `content`"*. It is what made a conversation * undeclarable, because a message with its body in a tag is not a message, * and the empty `actions: []` in Peek's manifest says so in its own words. * * At most one property may do it. Two would be two writers of one field, * and the second silently winning is exactly the shape of bug this * vocabulary exists to make impossible. * * **A property targeting `content` may declare a `format`** — MAN-1, the * same field a scalar input declares. Meaningless on a property written to * a tag, which is a scalar, and ignored there. */ properties?: Record required?: string[] } } /** * An action resolved into something a renderer can draw without re-reading the * manifest. * * The interpretation happens here rather than in the component on purpose: the * widget should not have to know that `input.enum` names a vocabulary, or that * `as: "pubkey"` means "a person". Those are manifest semantics, and keeping * them on this side is what lets the React component stay a dumb renderer that * would work for any app. */ /** One field of an object-creating action's form. */ export interface ActionFormField { /** Also the tag its value is written to — see {@link ManifestAction.input}. */ name: string type: string required: boolean /** * Written to the event's `content` rather than to a tag — PRO-18. * * Absent for every field that is a tag, which is nearly all of them. A * consumer may use it to draw prose instead of a single line; it does not * have to, and one that ignores it still publishes the right event. */ target?: 'content' /** * That the field is prose the owner accepts as a block document — MAN-1. * * Set only for a content field declaring a format this version writes, so a * consumer that sees it may draw a block editor and pass * `contentFormat: 'estiva-blocks-1'` to {@link buildActionEvent}. A consumer * that draws a text box instead still publishes a correct event (§7.3). */ format?: ProseFormat /** When the property names a vocabulary, its entries, already looked up. */ options?: { value: string; label: string; colour?: string }[] } /** * The content formats a declaration may name and this version writes — only * SPEC §13.3's block document. An unknown format is not surfaced, so no * consumer draws an editor for a model nothing here can tag correctly. */ export type ProseFormat = typeof BLOCK_DOCUMENT_FORMAT /** The declared format, when it is one this version writes. */ function proseFormatOf(declared: unknown): ProseFormat | undefined { return declared === BLOCK_DOCUMENT_FORMAT ? BLOCK_DOCUMENT_FORMAT : undefined } export interface ResolvedAction { id: string label: string /** See {@link ManifestAction.description}. Carried through, never rendered here. */ description?: string /** * See {@link ActionEffect}. Absent when the manifest did not say, or said * something this version does not recognise — both meaning *unknown*. */ effect?: ActionEffect /** * The control a consumer draws. `confirm` is a deletion (FOL-33): one button * that asks first and takes no value, because the action emits NIP-09's * `kind:5` and there is nothing to type. Added in 0.25.0 — a consumer that * switches on this and falls through to a text input must not draw one for * it. */ control: 'select' | 'pubkey' | 'text' | 'form' | 'confirm' /** For `select`: the declared vocabulary, already looked up. */ options?: { value: string; label: string; colour?: string }[] /** * For `form`: the fields to draw, in declaration order, with any vocabulary * already looked up — the same service `options` performs for a `select`. */ fields?: ActionFormField[] /** * For `form`: that the new object hangs under the one the action was invoked * on (`emits.toAddressOf: 'self'`). * * Surfaced because it is the other half of "needs a form **and a parent**", * which is what made these unrenderable: a consumer drawing only the * properties would publish an orphan. */ createsUnder?: string /** * For a `text` change: that the value is prose the owner accepts as a block * document (MAN-1). See {@link ActionFormField.format} — the same rule for a * field of a form. */ format?: ProseFormat /** * That building this action needs to know the Folder's state: whether it has * any, for a creation, or which Folders name the object, for a deletion * (`emits.listed`, MAN-1). Pass it to {@link buildActionEvents}. */ listed?: true /** The value this field holds right now, so a control can show it. */ current?: string field?: string /** * The app that declared this action, **when it is not the object's own** — * MAN-8. * * Ship declares `add-project` on a Folder, and a Folder is Peek's kind: the * object is drawn by one manifest and the action comes from another. Only a * creation is ever borrowed like this (see {@link creatorsOn}), so this is * set only on a `form`. Absent on every action the owning app declares. * * A consumer acting on it must build with the manifest named here, not the * object's — `buildActionEvents` finds the action by id in the manifest it is * handed, and the owner's has no `add-project`. {@link resolveActingManifest} * is that lookup. `appName` is the declaring app's `name`, for a consumer * that says whose action it is. */ declaredBy?: { address: string; appName?: string } } /** How the owning app asks one of its kinds to be drawn (RFC 0.4 §13.3). */ interface Projection { widget: string | string[] slots: Record /** * What one of these is called, lower case and singular — "issue", * "project". Resolved onto {@link ForeignObject.noun}; see there. */ noun?: string } /** * The projection's `noun`, when it is one: a non-empty string, trimmed. * * A manifest is another app's self-description and nothing validates it on * the way in, so `"noun": 5` is publishable. Passed through, it reaches a * consumer typed as a string and breaks there — the first `.charAt` throws * while a menu draws. Anything else reads as "this app does not say" (§7.9). */ function nounOf(projection: Projection): { noun?: string } { const noun = wordOf(projection.noun) return noun ? { noun } : {} } const WORD_MAX = 32 /** * A manifest's word, trimmed — or undefined unless it is a short name: at most * {@link WORD_MAX} letters, digits, spaces, hyphens and apostrophes. * * A consumer drops a noun into its own sentences ("Delete this issue?"), and a * bare file's noun and name come from whichever manifest declares the aspect * newest (PRO-24 review). Capped, a hostile manifest can put a wrong word * there, but not a sentence of its own that reads as the consumer's. */ function wordOf(value: unknown): string | undefined { const word = typeof value === 'string' ? value.trim() : '' return word.length <= WORD_MAX && /^[\p{L}\p{N}][\p{L}\p{N}\p{M} '’-]*$/u.test(word) ? word : undefined } interface Manifest { name?: string about?: string actions?: ManifestAction[] records?: RecordsRule projections?: Record vocabularies?: Record /** * Which aspect of *every* file this app renders — RFC 0.5 §10.7's one * protocol change, and the field that tells a generic app from a specialized * one that happens to draw a card. * * A specialized app owns kinds and says nothing here. A generic app declares * the one aspect it renders for everyone else's files: `conversation` (Peek) * or `document` (Leaf, which writes the title, summary and text of any file * whose owner shares them, SPEC §7.8). It may own kinds as well, as Leaf owns * documents: the `k` tags say what it owns, this says what it renders. * See {@link ASPECTS}. * * What it is read for today: the bare file (§6.7) has no owner and so no * `web` template of its own, and the app that renders its conversation is * where a link to it should land. Nothing in this package consults * `document` yet; Leaf reads its own role. */ aspect?: string /** * What this app calls a file nobody owns when it opens one, lower case and * singular: Peek's is "topic" (PRO-24). Read only beside `aspect`, from the * same manifest a bare file's link comes from, and resolved onto that bare * file's {@link ForeignObject.noun}. * * The opener's word and not the protocol's, Miky's call on 2026-10-02: a * company running its own conversation app calls these whatever its people * call them, and the specification never has to pick. */ fileNoun?: string } /** * The aspects a generic app may declare — RFC 0.5 §10.2's three parts of a * file, minus `properties`, which is what a *type* adds and so what a * specialized app owns rather than something a generic one renders. */ export const ASPECTS = ['conversation', 'document'] as const export type Aspect = (typeof ASPECTS)[number] /** Actions declared for this kind, resolved against the vocabularies. */ function resolveActions( manifest: Manifest, kind: number, folded: Record, ): ResolvedAction[] { const applies = (action: ManifestAction) => (Array.isArray(action.appliesTo) ? action.appliesTo : [action.appliesTo]).includes(String(kind)) const out: ResolvedAction[] = [] for (const action of manifest.actions ?? []) { if (!applies(action)) continue /* Three shapes now, where there were two. A field-setting change and a comment each render as one control. An action that creates a whole new object was skipped, because it "needs a form and a parent" — and that was true right up until something drew one. It is the single most useful cross-app action there is, so the manifest could describe it and no app could offer it (PRO-4). */ const isChange = !!action.emits.field const isComment = action.emits.scope === 'address' const isCreation = isCreationAction(action) // Four, since 0.25.0. Decided by the kind before the other three, so a // manifest that put `scope: 'address'` on its deletion — the target *is* // an address — is not mistaken for a comment. const isDeletion = action.emits.kind === KIND_DELETION if (!isDeletion && !isChange && !isComment && !isCreation) continue if (isDeletion) { out.push({ id: action.id, label: action.label, ...(action.description ? { description: action.description } : {}), ...(action.effect ? { effect: action.effect } : {}), control: 'confirm', ...(action.emits.listed === true ? { listed: true as const } : {}), }) continue } if (isCreation) { const properties = action.input!.properties! const required = new Set(action.input!.required ?? []) out.push({ id: action.id, label: action.label, ...(action.description ? { description: action.description } : {}), ...(action.effect ? { effect: action.effect } : {}), control: 'form', fields: Object.entries(properties).map(([name, spec]) => ({ name, type: spec.type ?? 'string', required: required.has(name), /* Where the value is written, when it is not a tag — PRO-18. Carried rather than acted on here. A consumer that ignores it still publishes a correct event, because `buildCreationEvent` is what places the value; what this buys a consumer is knowing the field is a body rather than a label, which is the difference between drawing a one-line input and drawing prose. */ ...(spec.target ? { target: spec.target } : {}), // Prose only where the value is `content` — a tag is a scalar (§7.3). ...(spec.target === 'content' && proseFormatOf(spec.format) ? { format: proseFormatOf(spec.format) } : {}), ...(spec.enum && manifest.vocabularies?.[spec.enum] ? { options: manifest.vocabularies[spec.enum].map((v) => ({ value: v.value, label: v.label, colour: v.colour, })), } : {}), })), // The parent is not a property and never appears in `properties`; it // comes from `emits`, and a form that omitted it would publish an // orphan the owning app cannot show anywhere. ...(action.emits.toAddressOf ? { createsUnder: action.emits.toAddressOf } : {}), ...(action.emits.listed === true ? { listed: true as const } : {}), }) continue } const vocab = action.input?.enum ? manifest.vocabularies?.[action.input.enum] : undefined const control = vocab ? 'select' : action.input?.type === 'pubkey' ? 'pubkey' : 'text' // A change only: a comment's body is §13.2 marker text, and a format on // one is nothing this version writes (`buildActionEvent` refuses it). const format = control === 'text' && isChange ? proseFormatOf(action.input?.format) : undefined out.push({ id: action.id, label: action.label, ...(action.description ? { description: action.description } : {}), ...(action.effect ? { effect: action.effect } : {}), control, ...(format ? { format } : {}), options: vocab?.map((v) => ({ value: v.value, label: v.label, colour: v.colour })), current: action.emits.field ? folded[action.emits.field]?.value : undefined, field: action.emits.field, }) } return out } /** * NIP-29 group metadata — the relay's own record for a channel. * * Named here because {@link folderOf} keys on it, and a bare `39000` in that * one comparison would read as an app's kind rather than as the relay's. */ export const KIND_CHANNEL_METADATA = 39000 const tagValue = (e: SignedEvent, name: string) => e.tags.find((t) => t[0] === name)?.[1] /** * Which Folder an object belongs to — both spellings, in one place. * * **This lived in Peek and had to move** (PRO-18). Its comment there said so: * reading only `h` made five of Ship's fifteen projects unactionable, because * a record published globally names its Folder with `buzz-channel` instead, * and the fix "does belong one layer down in the runtime, where every consumer * would get it rather than each discovering it". This is that layer. Both tags * are the relay's and NIP-29's, so knowing them is not knowing what any app * is. * * **0.13.0 had a third case as manifest vocabulary and 0.14.0 withdrew it.** A * manifest could declare `records.folder: "identifier"`, meaning *my objects * are containers, so the Folder is the object's own `d`*. The withdrawal was * right and stands: its only instance was a Peek topic, RFC 0.5 §1 retires * that shape, and a vocabulary field whose only instance is going away is one * every future producer has to read and none can use. * * **What the withdrawal took with it was a working feature, and nothing * noticed because nothing had declared the field yet** (INT-9). A channel * record names no Folder by either tag above, so every write aimed at one is * refused for having nowhere to go — which is why Peek can declare no action on * a topic, four days after PRO-18 concluded a conversation had become * declarable. Measured on production 2026-09-08: **0 of 40 `kind:39000` events * carry `h` or `buzz-channel`, and 40 of 40 carry `d`.** * * So the third case comes back **as protocol rather than as vocabulary**, which * is the distinction 0.14.0 was actually about. A `kind:39000` is NIP-29 group * metadata, relay-signed, and its `d` *is* the channel id — buzz's `NOSTR.md` * §"Group metadata" states it, `channel_info_from_event` reads it, and the * relay's `h_grammar` is the same uuid. Nobody declares that and no producer * can opt out of it, exactly like `h` and `buzz-channel` above. It asks nothing * of any manifest, so it cannot be the field that every future producer reads * and none can use. * * **It is the last of the three to fire, and it goes quiet on its own.** FOL-3 * makes a topic a file that carries an `h`; from that day the first clause * answers and this one is unreachable. A shape retired by a model change should * decay into dead code, not into a wrong answer — which is what the `??` order * buys and what the "still resolves after FOL-3" test pins. * * Narrow on purpose: **`kind:39000` and nothing else.** Not "any addressable * kind", which would hand every `30000`–`39999` record its own `d` as a Folder * and put a consumer's write in a channel that may not exist. An addressable * kind that is not the relay's channel record still answers `null` below, and a * test holds that line. * * Returns `null` when nothing says — which is a real answer. An object with no * Folder has nowhere for a write to go, and guessing at one publishes into * somebody else's channel. */ export function folderOf(root: SignedEvent): string | null { if (root.kind === KIND_CHANNEL_METADATA) { return tagValue(root, 'h') ?? tagValue(root, 'buzz-channel') ?? tagValue(root, 'd') ?? null } return tagValue(root, 'h') ?? tagValue(root, 'buzz-channel') ?? null } /** * {@link ForeignObject.folder}: `h`, then `buzz-channel` (LTX-4) — where a * change or a comment on the file goes. Not {@link folderOf}'s `d` fallback for * a `kind:39000`: a Folder drawn as a file kept no `folder` before, and * consumers gate "start inside" and membership on it. */ function writeFolderOf(root: SignedEvent): string | undefined { return tagValue(root, 'h') ?? tagValue(root, 'buzz-channel') } /** * Does the event carry this tag with this value — on **any** of them? * * `tagValue` reads the first tag with a given name, which is the wrong test for * deciding whether an event would have matched a filter: a relay's `#a` matches * if *any* `a` tag equals the wanted value, and an event may legitimately carry * several. Used where a filter's own predicate is re-applied to a merged * response (SHI-13), so that "did this come back because of that filter?" is * answered the way the relay answered it. */ const hasTagValue = (e: SignedEvent, name: string, value: string) => e.tags.some((t) => t[0] === name && t[1] === value) /** * A manifest event's `content`, or null when it is not parseable JSON. * * Deliberately not a validator. A manifest is another app's declaration and * this layer's whole posture is to render what it is given — a field this * version does not recognise is a newer app, not a broken one, and dropping * unknown structure would make the layer refuse the future. * * `effect` is the one exception, and only because misreading it is unsafe * rather than merely wrong. See {@link ActionEffect}. */ function parseManifest(event: SignedEvent): Manifest | null { try { const manifest = JSON.parse(event.content) as Manifest if (manifest?.actions) manifest.actions = manifest.actions.map(withKnownEffect) return manifest } catch { return null } } /** * An action whose `effect` this version understands, or one with none. * * A value outside {@link ACTION_EFFECTS} is dropped rather than carried, * because every way of reading an unrecognised effect is a claim nobody made. * Absent already means "unknown, be careful"; leaving `"nuke"` in place would * let a consumer's `effect !== 'destructive'` answer *true* about an action * whose own manifest was trying to warn it. */ function withKnownEffect(action: ManifestAction): ManifestAction { if (action.effect === undefined) return action if (ACTION_EFFECTS.includes(action.effect)) return action const { effect: _dropped, ...rest } = action return rest } const asArray = (value: string | string[]) => (Array.isArray(value) ? value : [value]) /** * Which manifest wins when several claim the same kind. * * A `#k` query is NIP-89's discovery mechanism and it returns every handler for * a kind, ranked by nothing. So the object's **author's** kind:31989 * recommendation is consulted first: the person who created the object is the * one entitled to say which app renders it, and that is a trust anchor Peek * already has rather than a value someone has to paste into a config file. * * The `#k` fallback exists so an object whose author never published a * recommendation still renders, rather than failing closed on a missing * preference. When it fires, `viaRecommendation` is false and the caller can * say so in the UI — "we guessed" and "we were told" should not look identical. */ /** * NIP-89's `web` tag: how to open one of these objects in the app that owns it. * * `["web", "https://host/#/o/", "naddr"]` — the consumer substitutes the * entity it is holding. Read off the *event* rather than the manifest content, * because that is where NIP-89 puts it. * * Returns undefined when no usable template was published, which is an ordinary * state rather than an error: an app may render objects it has nowhere to open. */ function webTemplate(event: SignedEvent, entity: string): string | undefined { for (const tag of event.tags) { if (tag[0] !== 'web' || !tag[1]) continue // The third element names the entity type; NIP-89 permits omitting it, in // which case the template applies to whatever we are holding. if (tag[2] && tag[2] !== entity) continue if (!tag[1].includes('')) continue return tag[1] } return undefined } /** What {@link resolveManifest} answers with. */ export interface ResolvedManifest { manifest: Manifest address: string viaRecommendation: boolean /** * NIP-89 `web` template, `` not yet substituted. * * The owning app's, for every kind that has an owner. For the bare file it * is the template of the app that renders its *conversation* — the one * place a link to an ownerless file can honestly land (FOL-17). Absent when * no app has declared that aspect. */ webTemplate?: string } /** * The bare file — `kind:30840`, SPEC §6.7, decided in RFC 0.5 §10.7. * * A file **no app owns**: a `d`, a `title`, the `h` of the Folder it lives in, at * most one `a` naming the file it sits under, and a §13.3 block document (or * nothing) for content. A Peek topic is one. Typed kinds — a project, an issue * — add properties on top of this; the bare file adds none, which is exactly * why no app may claim it: NIP-89 ownership is keyed by kind, and an app that * owned the bare file would own every subject nobody has built an app for. * * So its projection lives **here, in the runtime, and not on the relay.** A * consumer resolving a `30840` gets this manifest before NIP-89 is consulted * at all — no `kind:31990` anywhere can override it. That is the test the * ticket names: remove every published manifest and a bare file still * resolves, lists its comments, and names its parent. * * The one thing the relay *is* asked is where to open it (FOL-17). An * ownerless file has no `web` template of its own, so the link goes to the * app that has declared it renders every file's conversation — Peek — read * off its manifest's `aspect` field by {@link resolveAspectApp}. With no such * manifest published the file still resolves; it just has nowhere to open. * * Its conversation is `kind:1111` anchored at its address, the shape a Ship * issue's comments already have, so `commentKindsOf` needs no special case. * Its changes are the ecosystem's `kind:1851` with the same fold rule Ship * declares, so a rename or a re-parent by somebody other than the author * folds the way an issue's `project` field does — a root tag seeds the value, * a change event overrides it. * * What a person reads on it comes from that app too (PRO-24). Its `appName` is * the opener's name, because that is where its link goes — "Open in File" told * nobody anything. Its `noun` is the opener's `fileNoun`, so Peek's topic says * "topic". `name` below is "File" only for the file nothing can open, where a * consumer has no app to print. */ export const KIND_BARE_FILE = 30840 /** * The tag a move carries naming its new parent — SPEC §7.2 (FOL-45). * * A move is a change to the field a list declares as `movedBy`, and its new * parent is the change's `value`, which no relay indexes. So a move also * carries `["A", ]`, after `ts`, and a list reads * `{ kinds: [changeKind], "#A": [parent] }` to find the children moved in. * * Uppercase because a lowercase `a` is the change's target to every reader — * and Folder listings match *every* `a` on a change, so a second one would * fold the move into the parent itself. Only on a change, never read without a * `kinds` filter, and only ever a hint: the child's own fold decides. */ export const MOVED_TO_TAG = 'A' /** * The field that archives a file, whatever its kind — FOL-46. * * **One concept for every file.** A Ship project, a Ship issue, a Peek topic, a * bare file and a Folder are all archived the same way: a change setting * `archived` to `'true'` on the file's address, and an empty value to restore * it. That is the shape Ship has written for projects since SPEC §6.5, so * nothing on the relay needs migrating. It is a change, never a deletion, so it * is reversible, attributed, and open to anyone who may write in the Folder — * not only the author. */ export const ARCHIVED_FIELD = 'archived' /** * The field, and the root tag seeding it, that holds a file's one-line summary, * whatever its kind — SPEC §6.12. * * Plain text, one or two sentences, beside the title: what a card, a row or a * picker shows under it. Changed by a `kind:1851` like the title, last write * wins, and a value that is blank after trimming means no summary. Every file * kind's `subtitle` reads it ({@link defaultSubtitle}), so no manifest has to. * * why: https://github.com/estiva-app/estiva-docs/blob/main/decisions/a-files-summary-is-its-own-field.md */ export const SUMMARY_FIELD = 'summary' /** * Where a `ResolvedManifest.address` would name the manifest event, this names * the section of the specification the manifest is built from. Not an address, * and deliberately not shaped like one — a consumer that tried to fetch it * should fail loudly rather than find something. */ export const BARE_FILE_MANIFEST_ADDRESS = 'spec:6.7' const BARE_FILE_MANIFEST: Manifest = { name: 'File', about: 'A file no app owns. Its conversation is what every file has; its type adds nothing.', records: { changeKind: 1851, targetTag: 'a', fieldTag: 'field', valueTag: 'value', order: ['ts', 'created_at', 'id'], rule: 'last-write-wins-per-field', hiddenWhen: { field: 'archived', equals: 'true' }, }, projections: { [String(KIND_BARE_FILE)]: { widget: 'card', slots: { title: { tag: 'title', fold: 'title' }, /* No `body`: a topic is a conversation and has no text (SPEC §6.7, 2026-10-19), so nothing draws one and no document app lists it. why: https://github.com/estiva-app/estiva-docs/blob/main/decisions/a-card-changes-what-a-files-app-shares.md */ // A bare file may hold bare files — a sub-topic under a topic. Children // of *other* kinds are found the other way round, by their own `a` // (`parentRefOf` below), which is FOL-4's generic direction; this slot // is the one the existing `list` machinery can draw today. // // `movedBy` is SPEC §6.7's `parent` field, declared rather than // hard-coded since MAN-1, so a bare file moves by the rule every other // kind does — the move action below sets the same field. list: { children: { kind: KIND_BARE_FILE, via: 'a', limit: 200, movedBy: 'parent', anyParent: true } }, }, }, /* A comment is drawn from here for the same reason a bare file is: no app owns `kind:1111` (NIP-22, and SPEC §6.4 — every app writes it and none claims it with a `k` tag), so a link to one resolves through no manifest on the relay. Before FOL-38 `resolveForeignEvent` on a comment answered null, and a pasted thread link stayed a plain link. Drawn the way Peek draws a `kind:9` — the author as the title, the text as the body — so a consumer that already has a `message` widget draws a comment with it. Its noun is the protocol's own word, unlike a bare file's (PRO-24, Miky 2026-10-02): NIP-22 and SPEC §6.4 already call it a comment, so there is no app whose word it would be. */ [String(KIND_COMMENT)]: { widget: ['message', 'card'], noun: 'comment', slots: { title: { field: 'pubkey', as: 'pubkey' }, body: { field: 'content' }, }, }, /* A chat message, for the comment's reason: no app owns `kind:9` (SPEC §7.8). Peek's manifest claimed it until this release, and drew it exactly so — the author as the title, because the text carries structure and is no title; the text as the body, untruncated. why: https://github.com/estiva-app/estiva-docs/blob/main/decisions/a-chat-message-belongs-to-the-protocol.md */ [String(KIND_MESSAGE)]: { widget: ['message', 'card'], noun: 'message', slots: { title: { field: 'pubkey', as: 'pubkey' }, body: { field: 'content' }, }, }, }, /* The manifest is the only place a consumer learns of any of these actions (FOL-33; `move` from FOL-4). No `describe`: Leaf 1 added one and LTX took it out again, since a topic has no text (SPEC §6.7). A Peek page offering Rename and Delete on a bare file and nothing on a Ship issue does so because *this* list says `rename` and `delete` and Ship's does not — never because the kind is 30840. The rule is the one FOL-31 set for `comment`: a control is drawn from a declaration. `rename` is a `kind:1851` change to the `title` field, not a re-publish of the `30840`. The title slot already folds `title`, so a rename by anyone in the Folder lands for every reader the way a re-parent does; a re-publish could only ever be the author's, because a `30840` from another pubkey is a different address and not a refusal. `delete` is NIP-09's `kind:5` naming the address — the relay decides who may (the author, or the owner of an authoring agent; SPEC §6.5), so a consumer offers it and reports the answer rather than judging first. */ actions: [ { id: 'comment', label: 'Comment', description: 'Say something about this file. The comment lands in the Folder the file lives in, ' + 'and is what every reader of the file sees under it.', effect: 'writes', appliesTo: [String(KIND_BARE_FILE)], emits: { kind: KIND_COMMENT, scope: 'address' }, input: { type: 'string' }, }, { id: 'rename', label: 'Rename', description: 'Give this file a new title. A change event, so anyone in the Folder may, and every app ' + 'reading the file folds it into the title.', effect: 'writes', appliesTo: [String(KIND_BARE_FILE)], emits: { kind: 1851, field: 'title' }, input: { type: 'string' }, }, /* `summarize` sets the file's one-line summary (SPEC §6.12): plain text, so its input declares no `format` (§7.3), and a consumer finds it by `emits.field`, not by this id. An empty value clears it, the summary the file was created with included. */ { id: 'summarize', label: 'Summary', description: "Set this file's summary: one or two sentences of plain text, about 240 characters, shown under its " + 'title wherever it is met in one line. A change event, so anyone in the Folder may; an empty value clears it.', effect: 'writes', appliesTo: [String(KIND_BARE_FILE)], emits: { kind: 1851, field: SUMMARY_FIELD }, input: { type: 'string' }, }, /* `move` is the `parent` field SPEC §6.7 already defines, declared so a consumer draws the control from here as it draws Rename (FOL-4). The value is the new parent's address, or empty for the top of the Folder. A move never changes `h`: nesting organises and never grants access, so a file moved under another stays readable by exactly the people it was. */ { id: 'move', label: 'Move', description: "Put this file under another file in the same Folder, or back at the top. A change event, so anyone in " + 'the Folder may; who can read the file does not change.', effect: 'writes', appliesTo: [String(KIND_BARE_FILE)], emits: { kind: 1851, field: 'parent' }, input: { type: 'string' }, }, /* `archive` is the one field every file may carry (FOL-46): `true` hides the file and everything under it from every list, an empty value brings it back. A change, so anyone in the Folder may, and the archiving change's `content` is the resolution summary a person gave (`ActionEventArgs.note`). */ { id: 'archive', label: 'Archive', description: 'Take this file and everything under it out of every list once nobody needs to see it, or bring it ' + 'back: `true` archives, an empty value restores. Nothing is deleted, and a link still opens it.', effect: 'writes', appliesTo: [String(KIND_BARE_FILE)], emits: { kind: 1851, field: ARCHIVED_FIELD }, input: { type: 'string', enum: ARCHIVED_FIELD }, }, { id: 'delete', label: 'Delete', description: 'Ask the relay to delete this file. The relay decides — only its author, or the owner of ' + 'an agent that wrote it, may — and its comments stay as separate events.', effect: 'destructive', appliesTo: [String(KIND_BARE_FILE)], /* `listed`, as `create-file` on a Folder is (INT-12): a file put in a Folder's state by a declaration is taken out of it by one. Without it a consumer following declarations deleted the file and left its row in every Folder that listed it — FOL-48's failure. */ emits: { kind: KIND_DELETION, listed: true }, }, ], vocabularies: { // Ship's `ARCHIVED_STATES`, value for value: one field, one vocabulary. [ARCHIVED_FIELD]: [ { value: '', label: 'Active', colour: 'neutral' }, { value: 'true', label: 'Archived', colour: 'muted' }, ], }, } function bareFileManifest(): ResolvedManifest { return { manifest: BARE_FILE_MANIFEST, address: BARE_FILE_MANIFEST_ADDRESS, viaRecommendation: false } } /** * What a Folder's built-in actions are declared by — SPEC §3.2, as * {@link BARE_FILE_MANIFEST_ADDRESS} is for the bare file. The * `declaredBy.address` of an action no app declared. * * why: https://github.com/estiva-app/estiva-docs/blob/main/decisions/folders-belong-to-the-protocol.md */ export const FOLDER_ACTIONS_ADDRESS = 'spec:3.2' /** * A Folder's built-in actions, offered beside the ones apps declare on it * (INT-12). * * A Folder is owned by no app (SPEC §3.2), so what every app may do to one * cannot sit on any `kind:31990` — a manifest claiming `39000` is what §3.2 * forbids, and a creation declared there reaches a consumer only through that * claim. Putting a new file in a Folder is the first: a bare file with the * Folder in `h` (§6.7 — never `buzz-channel`, so a private Folder's titles stay * its own), then the `kind:1852` `add` that lists it (§3.3), which `listed` * makes the runtime's job as it is for Ship's `add-project`. * * It travels the cross-app path ({@link borrowedFrom}) rather than the owner's, * so it carries `declaredBy` naming this section, and * {@link resolveActingManifest} hands it back by that address. The projection * is the bare file's own, so the created object is drawn the way every other * bare file is. Each consumer offers it only if it may sign `30840` and `1852` * (§3.3). * * Starting a conversation is the second, moved here from Peek's manifest: a * `kind:9` carrying the Folder in `h` and nothing else — never an `a` naming * the `39000`, which §3.2 reads as a mention. A consumer offers it only on a * Folder whose relay-signed `30890` it has read, since a group without one is * a DM or a channel, and only if it may sign `kind:9`. */ const FOLDER_CREATOR: ResolvedManifest = { manifest: { name: 'Folder', about: 'What every app may do in a Folder, whichever app made it.', projections: { [String(KIND_BARE_FILE)]: BARE_FILE_MANIFEST.projections![String(KIND_BARE_FILE)], [String(KIND_MESSAGE)]: BARE_FILE_MANIFEST.projections![String(KIND_MESSAGE)], }, actions: [ { id: 'create-file', label: 'New file', description: 'Put a new file in this Folder: a title and, if you have one, a short summary. Everyone who can read the Folder can read ' + 'it, and the Folder lists it straight away. The app opening files calls it by its own word — Peek says topic.', effect: 'writes', appliesTo: String(KIND_CHANNEL_METADATA), emits: { kind: KIND_BARE_FILE, listed: true }, // `summary` is the root tag that seeds the new file's summary (SPEC // §3.2, §6.12); left empty, it is omitted (§7.3). input: { type: 'object', properties: { title: { type: 'string' }, [SUMMARY_FIELD]: { type: 'string' } }, required: ['title'], }, }, /* `toAddressOf: 'self'` and no `setTag`: the message hangs under the Folder it was started in, and names it only with the `h` every created event gets. One property, the body, written to `content` — a `kind:9` has an author and a text and nothing else. */ { id: 'start-a-conversation', label: 'Start a conversation', description: 'Start a new conversation in this Folder, as a root message that people can then reply to. ' + 'Use when something is worth its own thread rather than an answer inside an existing one — ' + 'this cannot reply to a message. Everyone who can read the Folder reads it.', effect: 'writes', appliesTo: String(KIND_CHANNEL_METADATA), emits: { kind: KIND_MESSAGE, toAddressOf: 'self' }, input: { type: 'object', properties: { message: { type: 'string', target: 'content' } }, required: ['message'], }, }, ], }, address: FOLDER_ACTIONS_ADDRESS, viaRecommendation: false, } /** * How a Folder draws — SPEC §3.2, answered here before NIP-89 is asked, as the * bare file's is. * * No app owns a Folder, so no `kind:31990` may claim `39000`; until this * release Peek's did, and every app learned a Folder's name from it. The title * is the channel record's `name`, the subtitle its `about` (the built-in * subtitle, {@link defaultSubtitle}), and the noun "folder". * * **No `list` slot.** What a Folder holds is what its relay-signed `30890` * names, which no `children` declaration can say, and it is read by * {@link resolveFolderContents} — only the files this viewer resolves, never a * count of the rest. A card asks for them as its `children` with * {@link ArchiveReadOptions.folderFiles}; a chip does not, and pays nothing. * Peek's claim listed fifty chat messages instead, and every Folder chip paid * for them. A Folder is never inside another (SPEC §3.1), so no listing needs a * Folder's children drawn under its row. * * No actions of its own either: every app's way into a Folder is * {@link FOLDER_CREATOR}'s, offered beside the others with `declaredBy` * naming this section, so a consumer tells them apart as it always has. * * why: https://github.com/estiva-app/estiva-docs/blob/main/decisions/folders-belong-to-the-protocol.md */ const FOLDER_MANIFEST: Manifest = { name: 'Folder', about: 'A Folder no app owns: its name, its one-liner, and the files its state lists.', projections: { [String(KIND_CHANNEL_METADATA)]: { widget: 'card', noun: 'folder', // The cut Peek's claim made, so no card grows when the claim goes. slots: { title: { tag: 'name' }, subtitle: { tag: 'about', truncate: 120 } }, }, }, } /** * The Folder's manifest, named by the app that opens it — the bare file's rule * ({@link bareFileOpenedBy}): its link goes to the app declaring the * conversation aspect, by its `naddr` template, so "Open in Peek" says where. * With no such app the Folder still draws, with nowhere to open. */ function folderManifest(opener: ResolvedManifest | null): ResolvedManifest { const name = opener?.webTemplate ? wordOf(opener.manifest.name) : undefined return { manifest: name ? { ...FOLDER_MANIFEST, name } : FOLDER_MANIFEST, address: FOLDER_ACTIONS_ADDRESS, viaRecommendation: false, ...(opener?.webTemplate ? { webTemplate: opener.webTemplate } : {}), } } /** * The built-in manifest, named by the app that opens it — PRO-24. * * Only the name and the bare file's noun are the opener's. The address stays * the specification's, and every rule — fold, slots, actions — stays this * runtime's, so a published manifest still cannot change how a bare file * behaves; it can only say what it is called where it opens. A comment takes * the name (it opens in the same app) and not the noun, which is the file's. * * why: https://github.com/estiva-app/estiva-docs/blob/main/decisions/a-file-is-labelled-by-its-type-not-its-app.md */ function bareFileOpenedBy(opener: Manifest, template: string): ResolvedManifest { const name = wordOf(opener.name) ?? BARE_FILE_MANIFEST.name const fileNoun = wordOf(opener.fileNoun) const projections = BARE_FILE_MANIFEST.projections! const bare = String(KIND_BARE_FILE) return { ...bareFileManifest(), manifest: { ...BARE_FILE_MANIFEST, name, ...(fileNoun ? { projections: { ...projections, [bare]: { ...projections[bare], noun: fileNoun } } } : {}), }, webTemplate: template, } } /** * How many handler events a sweep of the relay asks for. * * Every published manifest, in one filter: there are a handful per workspace, * and the two readers that need all of them at once — a folder read by * containment, and the bare file's opener below — are the two that cannot ask * by kind. */ const HANDLER_SWEEP_LIMIT = 50 /** * The app that renders one aspect of every file, by its manifest's `aspect`. * * NIP-89 discovery is by kind, and a generic app owns none — so it cannot be * found with `#k`, and there is no author to ask for a recommendation, because * the question is not "who owns this" but "who draws its conversation". The * only way to find it is to read every manifest and look, which is the same * sweep a folder read by containment already does. * * Newest wins when several declare the same aspect, for the reason the `#k` * fallback gives: there is no principled ranking without a recommendation. * SPEC §6.7 reserves the file author's `kind:31989` for `30840` as that * recommendation, and it is still not read; the day two conversation apps * exist is the day it needs to be. * * Memoised under a key of its own rather than per pointer: the answer does * not depend on whose file is being opened, so one entry serves every bare * file the cache ever sees. */ async function resolveAspectApp( aspect: Aspect, query: QueryFn, cache?: ProjectionCache, // Which `web` template to read off the opener: a file opens by its `naddr`, // a comment in it by its `nevent`. An app may publish one string for both, // as Peek does, or two. entity: 'naddr' | 'nevent' = 'naddr', ): Promise { return throughCache(cache, `aspect:${aspect}:${entity}`, async () => { const handlers = await query([{ kinds: [KIND_HANDLER_INFORMATION], limit: HANDLER_SWEEP_LIMIT }]) for (const candidate of [...handlers].sort((a, b) => b.created_at - a.created_at)) { const manifest = parseManifest(candidate) if (manifest?.aspect !== aspect) continue const template = webTemplate(candidate, entity) // Declaring the aspect and publishing nowhere to open a file is a manifest // this consumer has no use for yet; keep looking rather than answer with // an app that cannot be linked to. if (!template) continue return { manifest, address: `${candidate.kind}:${candidate.pubkey}:${tagValue(candidate, 'd') ?? ''}`, viaRecommendation: false, webTemplate: template, } } return null }) } /** * The memo, then a shared read, then the network — in that order. A cache * without `share` (hand-written, or older) keeps the old behaviour exactly: * look up, read, remember. */ async function throughCache( cache: ProjectionCache | undefined, key: string, read: () => Promise, ): Promise { const now = Date.now() const memo = cache?.lookup(key, now) // Each key is written by one reader, so what it holds is that reader's `T`. if (memo) return memo.value as T if (!cache) return read() if (cache.share) return cache.share(key, now, read) as Promise const answer = await read() cache.remember(key, answer, now) return answer } /** * What a {@link ProjectionCache} entry holds: one app's manifest, or none — or, * for {@link creatorsOn}, every manifest declaring a creation on one kind * (MAN-8). Still only manifests, which is the whole of the cache's promise. */ type CachedManifests = ResolvedManifest | null | readonly ResolvedManifest[] /** * A memo for the half of a resolve that does not change between refreshes. * * Resolving one reference costs four round trips, and **two of them are NIP-89 * discovery** — the author's `kind:31989` recommendation, then the `kind:31990` * manifest itself. A consumer that re-resolves on a timer pays for both every * time, and they answer the same thing until an app republishes its manifest. * Measured on Ship, where a reference widget re-resolves on the poll: 4 * requests per reference per tick, identical on the second resolve, against a * relay that meters reads at 300 a minute. * * **Owned by the caller, not this module.** A module-level cache would be * invisible global state shared by every consumer in the process, impossible to * scope to a screen and awkward to reset in a test. A caller that wants no * caching passes nothing and gets exactly the old behaviour. * * Deliberately only the manifest. The object, its changes, its comments and its * children are the parts a refresh exists to notice, and caching those is how * a live widget becomes a screenshot. */ export interface ProjectionCache { /** Forget everything. Worth calling after publishing a manifest. */ clear(): void /** @internal */ lookup(key: string, now: number): { value: CachedManifests } | undefined /** @internal */ remember(key: string, value: CachedManifests, now: number): void /** * @internal Run `read` for `key` unless a read for it is already running, in * which case join that one; remember the answer as `remember` would. Optional * so a hand-written cache still satisfies the interface — without it, a burst * of cold resolves asks once each, which is the old behaviour. */ share?(key: string, now: number, read: () => Promise): Promise } /** * How long a manifest may be believed without asking again. * * Five minutes is a compromise with one real cost: republish a manifest and * consumers keep drawing the old projection for up to that long. That is * recoverable and self-correcting, where the alternative — asking twice per * reference per tick, for ever — is neither. */ export const MANIFEST_TTL_MS = 5 * 60_000 /** * ## One read per manifest, however many resolves ask at once (PER-8) * * The memo only helps a resolve that starts after another has *finished*. A * consumer that resolves a set together — Peek's Screener does * `Promise.all(files.map(resolveObject))` every minute — starts every one of * them against the same cold entry, so each fetched the recommendation and the * manifest itself, and did it again every time the entry expired. Measured on * production: 133 of an idle tab's 385 requests in five minutes were manifest * lookups, against a meter that refused 74 of them. * * So a read in flight is shared: the second asker for a key joins the first * one's promise. A failure is shared the same way and **not** remembered — the * burst that caused it costs one request instead of one each, and the next * resolve asks afresh. Remembering it would blank every reference to that app * for the whole window over what may have been one refused request. */ export function createProjectionCache(ttlMs: number = MANIFEST_TTL_MS): ProjectionCache { const entries = new Map() const flights = new Map>() // Bumped by `clear`, so a read that was in flight when a manifest was // republished does not write its stale answer back afterwards. let generation = 0 return { clear: () => { generation += 1 entries.clear() flights.clear() }, share(key, now, read) { const running = flights.get(key) if (running) return running const began = generation const flight = read().then( (value) => { if (flights.get(key) === flight) flights.delete(key) if (began === generation) entries.set(key, { at: now, value }) return value }, (error: unknown) => { if (flights.get(key) === flight) flights.delete(key) throw error }, ) flights.set(key, flight) return flight }, lookup(key, now) { const found = entries.get(key) if (!found) return undefined // `>=`, not `>`: a TTL of 0 must mean "never believe it", and an entry // exactly at the boundary has expired rather than being on its last tick. if (now - found.at >= ttlMs) { entries.delete(key) return undefined } return { value: found.value } }, remember(key, value, now) { entries.set(key, { at: now, value }) }, } } /** * What a batch read groups its manifest lookups by. * * `kind:pubkey` for an owned kind — the author's recommendation is part of the * answer, so two authors may resolve to two apps. The bare file has no owner * and no recommendation to consult, so its key is the kind alone: one lookup * for every topic in a folder, whoever started them. */ function manifestKeyOf(pointer: AddressPointer): string { return pointer.kind === KIND_BARE_FILE ? String(KIND_BARE_FILE) : `${pointer.kind}:${pointer.pubkey}` } /** * A negative answer is cached too. * * "No app claims this kind" costs the same two round trips as a hit and is just * as stable. Caching only successes would leave the expensive case — a * reference nothing can draw — paying full price on every tick for ever. */ export async function resolveManifest( pointer: AddressPointer, query: QueryFn, cache?: ProjectionCache, ): Promise { // Before the cache, not only before the network: a bare file's manifest is // a constant, and memoising it per author would be one entry per person who // ever started a topic. The projection costs zero round trips; the one it // pays is for *where to open the file*, which no built-in constant can // answer — it is whichever app has declared it renders the conversation — // and that answer is memoised once for every bare file (`resolveAspectApp`). // A comment resolves through the built-in manifest as a bare file does, and // opens in the same app — the one that renders every file's conversation // is the one that can show a thread in it (FOL-38). // A chat message is the comment's case exactly (SPEC §7.8), and a Folder has // its own built-in that opens in the same app (§3.2): neither is asked of a // `k` tag, so a manifest still claiming one is not read for it. if (pointer.kind === KIND_BARE_FILE || pointer.kind === KIND_COMMENT || pointer.kind === KIND_MESSAGE) { const opener = await resolveAspectApp('conversation', query, cache, pointer.kind === KIND_BARE_FILE ? 'naddr' : 'nevent') return opener?.webTemplate ? bareFileOpenedBy(opener.manifest, opener.webTemplate) : bareFileManifest() } // A refused sweep costs a Folder its link, never its name: Peek's `#k` claim // was a read of its own, and the Folder still drew when the sweep failed. // Not remembered (`throughCache` keeps no failure), so the next resolve asks. if (pointer.kind === KIND_CHANNEL_METADATA) { return folderManifest(await resolveAspectApp('conversation', query, cache, 'naddr').catch(() => null)) } return throughCache(cache, `${pointer.kind}:${pointer.pubkey}`, () => resolveManifestUncached(pointer, query)) } async function resolveManifestUncached( pointer: AddressPointer, query: QueryFn, ): Promise { const parse = parseManifest const addressOf = (event: SignedEvent) => `${event.kind}:${event.pubkey}:${tagValue(event, 'd') ?? ''}` const recommended = await query( [ { kinds: [KIND_HANDLER_RECOMMENDATION], authors: [pointer.pubkey], '#d': [String(pointer.kind)], limit: 1, }, ], ) const manifestAddr = recommended[0]?.tags.find((t) => t[0] === 'a')?.[1] if (manifestAddr) { const [kind, pubkey, ...rest] = manifestAddr.split(':') const found = await query([ { kinds: [Number(kind)], authors: [pubkey], '#d': [rest.join(':')], limit: 1 }, ]) const manifest = found[0] ? parse(found[0]) : null if (manifest) { return { manifest, address: manifestAddr, viaRecommendation: true, webTemplate: webTemplate(found[0], 'naddr'), } } } const claimed = await query([ { kinds: [KIND_HANDLER_INFORMATION], '#k': [String(pointer.kind)], limit: 20 }, ]) // Newest wins among unrecommended candidates. Arbitrary, and honest about it: // there is no principled ranking without a recommendation, which is exactly // why the recommendation exists. const newest = [...claimed].sort((a, b) => b.created_at - a.created_at) for (const candidate of newest) { const manifest = parse(candidate) if (manifest?.projections?.[String(pointer.kind)]) { return { manifest, address: addressOf(candidate), viaRecommendation: false, webTemplate: webTemplate(candidate, 'naddr'), } } } return null } /** * Every app that declares a way to **create one of its own kinds on this one** * — MAN-8. * * {@link resolveManifest} answers with one manifest per kind, its owner's, and * the object's actions came from it alone. So an action one app declares on a * kind another app owns was on the relay and unreachable: Ship's `add-project` * says `appliesTo: "39000"`, a Folder is Peek's, and a Folder resolved with * Peek's `start-a-conversation` and nothing else. Measured on production * 2026-09-30 with Peek's own code. * * **Only a creation, and only of a kind the declaring app projects.** That is * the whole of what one app may say about another's kind: "one of mine can be * made here". A field it sets, a comment, a deletion are the owner's to * declare — the owner's `records` rule folds them, and a stranger declaring * `archived` on a Folder would be offering a change nobody folds as it says. * A creation needs nothing from the owner but an object to be made on, and * what it makes is drawn by the app that declared it. * * One read for every kind, the sweep {@link resolveAspectApp} already makes, * because nothing can ask for it by kind: a manifest's `k` tags name what it * draws, and Ship does not draw a Folder. Newest first, and **one manifest per * created kind**: an older manifest declaring the same kind on the same target * is a stale copy of the same claim, and offering both is two "New project" * rows. The owner is filtered out later ({@link borrowedFrom}), because who the * owner is depends on the object's author and this list does not. */ async function creatorsOn(kind: number, query: QueryFn, cache?: ProjectionCache): Promise { return throughCache(cache, `creators:${kind}`, async () => { const handlers = await query([{ kinds: [KIND_HANDLER_INFORMATION], limit: HANDLER_SWEEP_LIMIT }]) const creators: ResolvedManifest[] = [] const claimed = new Set() for (const candidate of [...handlers].sort((a, b) => b.created_at - a.created_at)) { const manifest = parseManifest(candidate) if (!manifest) continue const makes = (manifest.actions ?? []).filter( (action) => isCreationAction(action) && asArray(action.appliesTo).includes(String(kind)) && !!manifest.projections?.[String(action.emits.kind)] && !claimed.has(action.emits.kind), ) if (makes.length === 0) continue for (const action of makes) claimed.add(action.emits.kind) creators.push({ // Only the creations: this manifest is what a consumer acts with, and // nothing it was not offered for must be buildable with it. manifest: { ...manifest, actions: makes }, address: `${candidate.kind}:${candidate.pubkey}:${tagValue(candidate, 'd') ?? ''}`, viaRecommendation: false, webTemplate: webTemplate(candidate, 'naddr'), }) } return creators }) } /** * {@link creatorsOn} for a read, where a failed sweep is no borrowed actions. * * The sweep is an addition to an object, not part of it: a relay that refuses * it (the meter is 300 reads a minute) must not take down a card whose own * read succeeded. Not remembered — `throughCache` never keeps a failure — so * the next resolve asks again. {@link resolveActingManifest} does not use * this: a write that cannot find its manifest should say so, not act with none. */ function creatorsOrNone(kind: number, query: QueryFn, cache?: ProjectionCache): Promise { return creatorsOn(kind, query, cache).catch(() => []) } /** * {@link creatorsOn}, less the owner and less anything the owner makes itself. * * The owner's own actions are already on the object. And a kind the owner * creates here is the owner's to offer, so another manifest's claim to create * it — a staging copy of the same app, most likely — is dropped rather than * drawn as a second row. * * **A Folder's built-in creations come first and are never dropped** * ({@link FOLDER_CREATOR}, INT-12): no app owns a Folder (SPEC §3.2), so no * app's claim to create the same kind there outranks the specification's — * a manifest declaring a bare file on a Folder is the second row, not this. * The Folder's owner is the specification's too ({@link FOLDER_MANIFEST}), and * declares no actions, so these are offered once. */ function borrowedFrom(kind: number, owner: ResolvedManifest, creators: readonly ResolvedManifest[]): ResolvedManifest[] { const builtIn = kind === KIND_CHANNEL_METADATA ? [FOLDER_CREATOR] : [] const builtInMakes = new Set(builtIn.flatMap((creator) => (creator.manifest.actions ?? []).map((a) => a.emits.kind))) const ownerMakes = new Set( (owner.manifest.actions ?? []) .filter((action) => isCreationAction(action) && asArray(action.appliesTo).includes(String(kind))) .map((action) => action.emits.kind), ) return [ ...builtIn, ...creators.flatMap((creator) => { if (creator.address === owner.address) return [] const actions = (creator.manifest.actions ?? []).filter( (action) => !ownerMakes.has(action.emits.kind) && !builtInMakes.has(action.emits.kind), ) return actions.length ? [{ ...creator, manifest: { ...creator.manifest, actions } }] : [] }), ] } /** Every borrowed creation, resolved as the owner's actions are, with its declaring app named. */ function borrowedActions(kind: number, owner: ResolvedManifest, creators: readonly ResolvedManifest[]): ResolvedAction[] { return borrowedFrom(kind, owner, creators).flatMap((creator) => resolveActions(creator.manifest, kind, {}).map((action) => ({ ...action, declaredBy: { address: creator.address, ...(creator.manifest.name ? { appName: creator.manifest.name } : {}) }, })), ) } /** * The manifest to build an action with — the object's own, or the one named by * the action's {@link ResolvedAction.declaredBy} (MAN-8). * * `declaredBy` omitted, or naming the owner, is exactly {@link resolveManifest}. * Otherwise the answer is that manifest **only if resolving the object would * have offered the action from it** — the same sweep, the same filtering — and * null if not. So a caller cannot act on a Folder with an arbitrary manifest by * naming its address; it can only act with one this module found declaring a * creation there. The manifest returned holds only those creations. */ export async function resolveActingManifest( pointer: AddressPointer, declaredBy: string | undefined, query: QueryFn, cache?: ProjectionCache, ): Promise { // A Folder's built-in needs no sweep, so a refused one must not cost it // (INT-12). Asked before the owner, whose address is the same section and // whose manifest only draws: every Folder action is the creator's. if (pointer.kind === KIND_CHANNEL_METADATA && declaredBy === FOLDER_ACTIONS_ADDRESS) return FOLDER_CREATOR const owner = await resolveManifest(pointer, query, cache) if (!owner || !declaredBy || declaredBy === owner.address) return owner const creators = await creatorsOn(pointer.kind, query, cache) return borrowedFrom(pointer.kind, owner, creators).find((creator) => creator.address === declaredBy) ?? null } /** Replay change events into current field values. Last write wins per field. */ /** * The fold rule a manifest declares, or one that folds nothing — PRO-6. * * `records` is optional. An app with no change events needs no rule for * turning them into current truth, and Peek is that app: a topic's name is a * tag the relay wrote and a message is immutable. * * Both resolvers used to require it and return null for the whole projection, * so an app that declared none rendered as *nothing* — which §13.3 spends * several paragraphs establishing is the worst available outcome, because a * blank card reads as "that app is broken". It was never caught because Ship * is the only app that had ever published a manifest, and Ship folds. Nothing * in RFC 0.4 §13.1 makes `records` mandatory — but SPEC §7 did, and that is the * document a stranger implements from. It said an app **MUST** publish a * manifest whose content carries `records` and `projections`, so the code was * right and the specification was wrong, in a way that would have made anyone * following the text declare change events they do not have. SPEC §7.1 now says * `records` is OPTIONAL and states the consumer's obligation as a MUST: render * the projection, treat every `fold` slot as absent, fall through to its * `default`, and do not refuse (PRO-12). * * The citation is corrected in place rather than deleted because the two * documents disagreeing is the thing worth remembering: reasoning from the RFC * alone reached the right behaviour and the wrong justification. * * The substitute rule below is only ever used to *fold*, never to query, and * that distinction is load-bearing. The first attempt used `changeKind: -1` as * a sentinel and let the queries run: the fake relay in the tests accepted it, * and production refused the entire filter with `invalid type: integer -1, * expected a 16-bit unsigned number`. A kind is `u16` on the wire, so there is * no out-of-band value to reach for — the query has to be skipped instead of * being made unmatchable. Callers therefore branch on `manifest.records` for * the filters and use this only for the fold, which runs over an empty array. */ function foldRuleOf(manifest: Manifest): RecordsRule { return ( manifest.records ?? { // Never sent to a relay. See above. changeKind: 0, targetTag: 'a', fieldTag: 'field', valueTag: 'value', order: ['created_at', 'id'], rule: 'last-write-wins-per-field', } ) } /** * SPEC §13.4's content format — **re-exported from `@estiva-app/protocol`.** * * These were defined here first, because the projection layer is what needed * them: a slot has to tell a consumer which model its value is in. Reading a * tag off an event turned out to be a question about the *event* rather than * about the slot, and two folds outside this layer now need it — Ship's and the * agent's, one fold in two repositories held to a single recorded state. Making * either depend on the projection layer to read a tag is the wrong direction, * and a second copy of `'estiva-blocks-1'` is what the protocol package exists * to prevent. * * So the definitions moved down and this re-exports them. **Nothing this * package published has changed** — same names, same values, same behaviour. */ export { type ContentFormat, CONTENT_FORMAT_TAG, BLOCK_DOCUMENT_FORMAT, contentFormatOf, } from '@estiva-app/protocol' // Re-exporting does not bind the names locally, and this file folds with them. import { contentFormatOf, type ContentFormat } from '@estiva-app/protocol' /** * The one slot in SPEC §7.2's closed set that means "structured content". * * Stays here: a *slot* is this layer's subject, and the protocol package has no * opinion about which of them carries a body. * * Named rather than inlined because the runtime keys behaviour on it twice — * whether a value carries a content format, and whether it may be truncated — * and a consumer reads `slots.body` to find it. */ export const BODY_SLOT = 'body' /** The slot that is a file's summary, one line of plain text (SPEC §7.2). */ const SUBTITLE_SLOT = 'subtitle' /** * Does this change sit in the Folder its target is filed in — SPEC §6.2. * * The relay checks a change only against its own `h`: the signer must be a * member of *that* Folder. Nothing on the relay ties it to the target's Folder, * so before this a member of any Folder could rename, move, archive or rewrite * a file in a Folder they cannot read, and every reader who could see the * change folded it in. Probed on production 2026-10-13: QA-2's `description` * change to QA-1's page, signed with another Folder's `h`, was accepted. So * the reader ignores it, for every kind (Miky, 2026-10-13). * * The target's Folder is any placement its root carries — `h`, or Ship's * `buzz-channel` (§7.3), whichever the root has; about half of Ship's projects * carry one and half the other — or, for a `kind:39000`, the Folder itself. * A change's own Folder is read the same way. Measured before this shipped: * of 2,790 changes whose roots were readable, none would be dropped but two * duplicated project changes and four Folder archives `folderArchiveOf` * already ignored. * * A change's Folder is its `h` alone: that is the tag the relay checks the * signer's membership against, so a `buzz-channel` on a change proves nothing. * * Without the root there is nothing to compare, so every change counts: the * caller that has only an address (an archived parent read by address) is no * worse than before. Nor does a root that names no Folder (an early topic, * from when `h` was SHOULD) — it has no Folder to be outside of. * * Built once per root, and asked once per change. */ function targetFolders(root: SignedEvent | undefined): ReadonlySet | undefined { if (!root) return undefined const folders = new Set(PLACEMENTS.flatMap((name) => root.tags.filter((t) => t[0] === name && t[1]).map((t) => t[1]!))) if (root.kind === KIND_CHANNEL_METADATA) { const own = tagValue(root, 'd') if (own) folders.add(own) } return folders.size ? folders : undefined } function inTargetFolder(change: SignedEvent, folders: ReadonlySet | undefined): boolean { if (!folders) return true // The first `h` with a value, as the relay reads it: an empty `["h"]` ahead // of a real one must not make this read a different Folder from the one the // relay checked the signer against. const own = change.tags.find((t) => t[0] === 'h' && t[1])?.[1] return own !== undefined && folders.has(own) } /** * Fold `changes` under `rule`, ignoring any not in `root`'s Folder * ({@link targetFolders}). `root` is required so every caller decides: pass * `undefined` only where the root genuinely is not at hand, and the newest * version where several are. */ function foldChanges(changes: SignedEvent[], rule: RecordsRule, root: SignedEvent | undefined) { const fields: Record = {} const folders = targetFolders(root) for (const change of [...changes].filter((c) => inTargetFolder(c, folders)).sort(byOrder)) { const field = tagValue(change, rule.fieldTag) const value = tagValue(change, rule.valueTag) if (!field || value === undefined) continue // a partial change sets nothing // A summary is new, so it takes neither of §6.2's exceptions: a change the // reader cannot check against the root's Folder is not read (SPEC §6.12). if (field === SUMMARY_FIELD && !folders) continue // The format travels with the *winning* change, not with the object. SPEC // §13.4: "an app MUST read the tag from the event it took the value from, // never from the object's root" — a description created as marker text and // later edited into blocks is a root with no tag and a change with one. fields[field] = { value, by: change.pubkey, at: change.created_at, format: contentFormatOf(change), } } return fields } /** * One object's fold out of a pooled answer: its app's change kind, naming it as * the **first** target tag, as `archiveOf` reads it. Empty when the app declares * no change events — `foldRuleOf`'s substitute is never asked for. */ function foldOf(events: SignedEvent[], address: string, manifest: Manifest, root: SignedEvent | undefined) { const records = foldRuleOf(manifest) return foldChanges( manifest.records ? events.filter((e) => e.kind === records.changeKind && tagValue(e, records.targetTag) === address) : [], records, root, ) } /** * How an `archived` change is read when the file's kind declares no rule of its * own — a Folder, whose `kind:39000` no app owns, and a bare file. SPEC §7.1's * `kind:1851` shape, the one every Estiva app writes. */ const ARCHIVE_RULE: RecordsRule = { changeKind: 1851, targetTag: 'a', fieldTag: 'field', valueTag: 'value', order: ['ts', 'created_at', 'id'], rule: 'last-write-wins-per-field', } /** The rule an `archived` change on this file is folded by: its app's, or SPEC's. */ function archiveRuleOf(manifest: Pick | undefined): RecordsRule { return manifest?.records ?? ARCHIVE_RULE } /** * The note Ship wrote on every archive before FOL-46, when the note was the * activity feed's wording rather than anything a person typed. Twelve are on * production (measured 2026-09-29), and none of them is a resolution. */ const PLACEHOLDER_ARCHIVE_NOTE = 'archived' /** A file that is archived: who did it, when, and what they said about it. */ export interface Archive { /** Who wrote the archiving change. */ by: string /** Its `created_at`, in seconds. */ at: number /** * The resolution summary, when the person archiving gave one — the archiving * change's `content`. One event, so the summary can never land without the * archive or outlive the unarchive that replaces it. */ resolution?: string /** * Present when the file is not archived itself but is hidden **with** an * archived ancestor: that ancestor's address — the project an issue belongs * to, the file a sub-topic sits under, the record a channel belongs to. * * Computed when reading and never written to the file (FOL-46): unarchiving * the ancestor restores exactly what was there before, and archiving a * project with two hundred issues is one event rather than two hundred. */ with?: string } /** * Whether a file — or a Folder — is archived, itself or with an ancestor. * * Reads the {@link Archive} a resolver put on it. A resolver leaves archived * files out of a Folder's contents, so this matters where a file is reached * directly: a link, a mention, a page opened by address. */ export function isArchived(file: { archived?: Archive } | null | undefined): boolean { return !!file?.archived } /** * The file's archive, if the last `archived` change archived it. * * `changes` may hold any events; only this rule's change kind, naming this * address, setting this field, is read. Last write wins, by the same order as * every other field. * * **A Folder is the exception** (SPEC §3.2): on a `kind:39000` address only a * change in the Folder's own `h` by one of `folderAdmins` — the owners and * admins on its relay-signed `kind:39001` — counts. A caller that has not read * the admin list passes none, and then no change counts: a Folder shown live * when it is archived is recoverable, one any member could hide is not. */ function archiveOf( changes: SignedEvent[], address: string, rule: RecordsRule, /** * The target's newest root version (SPEC §6.2): an archive signed in another * Folder is ignored, as every fold ignores it. Required so every caller * decides; `undefined` only where only the address is held. */ root: SignedEvent | undefined, folderAdmins?: ReadonlySet, ): Archive | undefined { const [kind, , identifier] = address.split(':') if (Number(kind) === KIND_CHANNEL) { if (!folderAdmins?.size) return undefined changes = changes.filter((change) => tagValue(change, 'h') === identifier && folderAdmins.has(change.pubkey)) } const folders = targetFolders(root) let last: SignedEvent | undefined for (const change of changes) { // The target is the first target tag, as every other fold reads it: a // change archiving one file must not archive another it also mentions. if (change.kind !== rule.changeKind || tagValue(change, rule.targetTag) !== address) continue if (!inTargetFolder(change, folders)) continue if (tagValue(change, rule.fieldTag) !== ARCHIVED_FIELD || tagValue(change, rule.valueTag) === undefined) continue if (!last || byOrder(last, change) < 0) last = change } if (!last || tagValue(last, rule.valueTag) !== 'true') return undefined const note = last.content.trim() return { by: last.pubkey, at: last.created_at, ...(note && note !== PLACEHOLDER_ARCHIVE_NOTE ? { resolution: last.content } : {}), } } /** * The first archived ancestor of `address`, walking `parentOf`, with its archive. * * SPEC §6.5's rule for a file archived with something above it, for an app * that folds its own files: the closest archived ancestor, with `with` its * address, so every app names the same thing to unarchive (PEEKM-2). A file * read through {@link resolveForeignObject}'s `archivedWith` is marked by the * same rule. `archiveAt` gives a file's own archive only. * * Stops at a cycle rather than looping: `parentRef` is written by people, and * two files each moved under the other is a state the relay accepts. */ export function archivedAncestorOf( address: string, parentOf: (address: string) => string | undefined, archiveAt: (address: string) => Archive | undefined, ): Archive | undefined { const seen = new Set([address]) for (let parent = parentOf(address); parent && !seen.has(parent); parent = parentOf(parent)) { seen.add(parent) const archive = archiveAt(parent) if (archive) return { ...archive, with: archive.with ?? parent } } return undefined } function truncate(text: string, limit?: number) { if (!limit || text.length <= limit) return text return `${text.slice(0, limit).trimEnd()}…` } /** * Which layout to draw, from a declared type or an ordered chain of them. * * RFC 0.4 §13.3. A widget is a layout *hint* rather than a semantic, so an * unknown one can degrade honestly: `["message", "card"]` means *render me as a * message if you know it, else as a card*, and a chain must terminate in a type * the spec closes. * * **It lives here rather than in each consumer** because two implementations * would disagree the first time a chain had three entries, and the whole point * of the chain is that producers and consumers upgrade at different times. The * consumer supplies what it implements; the runtime does the walking. * * `fallback` is what to draw when the chain runs out — never "nothing". §13.3's * argument is that a blank object is indistinguishable from one the reader may * not be allowed to see, and reports "that app is broken" about an app doing * exactly what it was told. */ /** * The widget types RFC 0.4 §13.3 closes. A chain MUST end in one of these. * * Exported because both halves need the same list and they must not drift: a * producer checks its chain terminates here, a consumer's fallback is drawn * from here. Two copies would disagree the first time the set grew, and the * disagreement would show up as an object rendering blank in one app only. */ export const CLOSED_WIDGETS = ['card', 'row', 'table', 'stat'] as const /** * Why a widget declaration is not publishable, or null when it is — PRO-3. * * **The producer half of the fallback chain.** `pickWidget` below makes a * consumer safe against a chain it does not fully understand; this stops the * unrenderable chain being published in the first place. Both are needed and * they fail differently: without the consumer half an unknown widget renders * blank, and without this one a *conformant* consumer renders blank through no * fault of its own, having done exactly what it was told. * * A chain that does not terminate in a closed type is the PEE-10 failure with * a longer fuse — every consumer that has not heard of `profile` walks * `["profile"]` to the end and has nothing left to draw. * * Returns a sentence rather than a boolean because this is read by a person * publishing a manifest, and "invalid widget" tells them nothing about which * one or what to do. */ export function widgetChainProblem(declared: unknown): string | null { if (typeof declared === 'string') { return (CLOSED_WIDGETS as readonly string[]).includes(declared) ? null : `"${declared}" is not one of ${CLOSED_WIDGETS.join(', ')}. A widget outside the closed set must be declared as a chain ending in one of them — ["${declared}", "card"].` } if (!Array.isArray(declared) || declared.length === 0) { return 'a widget must be a type or a non-empty ordered chain of them.' } if (declared.some((entry) => typeof entry !== 'string' || entry === '')) { return 'every entry in a widget chain must be a non-empty string.' } const last = declared[declared.length - 1] if (!(CLOSED_WIDGETS as readonly string[]).includes(last)) { return `["${declared.join('", "')}"] ends in "${last}", which no consumer is required to implement. A chain must end in one of ${CLOSED_WIDGETS.join(', ')} so there is always something left to draw.` } return null } /** * The shortest `description` that could plausibly distinguish one action from * another. * * A proxy, and a weak one — forty characters of nonsense passes. It is here * because the failure it does catch is the common one: a label pasted into the * description field, or a two-word caption where a sentence was wanted. Lifted * from Ship, which has enforced exactly this privately since PRO-5. */ export const MIN_ACTION_DESCRIPTION = 40 /** * Why an action is not worth publishing yet, or an empty list when it is. * * **The producer half of action selection**, and the same division of labour * {@link widgetChainProblem} draws for widgets: a consumer must stay safe * against a declaration it did not expect, *and* the unusable declaration * should not be signed in the first place. Both are needed and they fail * differently — a consumer alone cannot tell a bad description from a bad * match, and a producer alone cannot know what a consumer required. * * The failure is silent, which is the whole reason this exists. Peek's launcher * matches an action from a conversation on `description` (INT-5) and gates on * `effect`; an app whose description restates its label is not rejected * anywhere — its actions are simply never the one chosen, and nothing tells * anybody. There is no error to find. * * **Advisory, never fatal, and nothing in resolution calls it.** A weak * description is a worse match, not an invalid manifest — refusing to render an * app over its prose would be the objection that rules out iframes, wearing a * different hat. It is exported so a producer can run it in its own suite, the * way Ship does. * * Returns sentences rather than codes, for {@link widgetChainProblem}'s reason: * this is read by a person publishing a manifest, and "invalid description" * tells them neither which action nor what to write instead. * * A list rather than the first problem, because an action carries three * independent declarations and fixing them one round-trip at a time is a poor * trade for a producer. At most one is reported per declaration: a description * that restates its label is told that, not also that it is short. */ export function actionProblems(declared: unknown): string[] { if (typeof declared !== 'object' || declared === null || Array.isArray(declared)) { return ['an action must be an object declaring at least id, label and description.'] } const action = declared as { id?: unknown; label?: unknown; description?: unknown; effect?: unknown } const id = typeof action.id === 'string' && action.id.trim() ? action.id.trim() : null const label = typeof action.label === 'string' ? action.label.trim() : '' const description = typeof action.description === 'string' ? action.description.trim() : '' const name = id ? `"${id}"` : 'an action with no id' const problems: string[] = [] if (!id) problems.push('an action must declare an id — it is what a caller names when it picks one.') if (!description) { problems.push( `${name} declares no description. A caller choosing between actions reads that prose and nothing else, so an action without one is one it can never pick. Say what the action makes, for whom, and when it applies.`, ) } else if (label && description.toLowerCase() === label.toLowerCase()) { problems.push( `${name} gives its label ("${label}") as its description. The label is a button caption the caller already has; the description is what tells it this action rather than another.`, ) } else if (description.length < MIN_ACTION_DESCRIPTION) { problems.push( `${name} describes itself in ${description.length} characters ("${description}"). That is a caption, not prose a caller can match on — under ${MIN_ACTION_DESCRIPTION} is nearly always a label in disguise.`, ) } if (action.effect === undefined || action.effect === null) { problems.push( `${name} declares no effect, which every consumer must read as *unknown* — so a consumer that only offers safe actions will not offer this one, and one that avoids destructive actions may. Declare ${ACTION_EFFECTS.join(', ')}.`, ) } else if (typeof action.effect !== 'string' || !(ACTION_EFFECTS as readonly string[]).includes(action.effect)) { problems.push( `${name} declares effect ${JSON.stringify(action.effect)}, which is not one of ${ACTION_EFFECTS.join(', ')}. An unrecognised value is dropped rather than passed through, so this reads as if the field were absent — a typo here is invisible.`, ) } /* Where a property is written — PRO-18. Both of these are producer mistakes a consumer cannot report at the moment they matter: `buildCreationEvent` refuses two content fields, but that is a person pressing a button and getting a sentence, long after the manifest was signed. This is the check that runs before signing. */ const properties = (action as { input?: { properties?: Record } }).input?.properties if (properties && typeof properties === 'object') { const targeted: string[] = [] for (const [property, spec] of Object.entries(properties)) { const target = (spec as { target?: unknown } | null)?.target if (target === undefined) continue if (target !== 'content') { problems.push( `${name} declares "${property}" targeting ${JSON.stringify(target)}, and the only target is "content". An unrecognised one is ignored, so the value goes to a tag named "${property}" instead — which publishes, and is not where the owner meant it.`, ) continue } targeted.push(property) } if (targeted.length > 1) { problems.push( `${name} writes ${targeted.length} properties to content (${targeted.join(', ')}), and an event has one. A consumer refuses the whole action rather than choosing between them.`, ) } } /* MAN-1's three declarations, each of which fails quietly on the consumer's side: an unknown placement or format is refused or ignored at the button, and a `listed` on a change is simply never acted on. */ const emits = (action as { emits?: { kind?: unknown; field?: unknown; placement?: unknown; listed?: unknown } }).emits const input = (action as { input?: { type?: unknown; format?: unknown } }).input const creates = input?.type === 'object' && !!properties if (emits?.placement !== undefined && !(creates && PLACEMENTS.includes(emits.placement as string))) { problems.push( creates ? `${name} places its object with ${JSON.stringify(emits.placement)}, and a Folder is named by ${PLACEMENTS.join(' or ')} — a consumer refuses the action.` : `${name} declares a placement and creates nothing; only a new root names its Folder, so it is ignored.`, ) } if (emits?.listed !== undefined && emits.listed !== true) { problems.push(`${name} declares listed ${JSON.stringify(emits.listed)}; the only value is true.`) } else if (emits?.listed === true && !creates && emits.kind !== KIND_DELETION) { problems.push(`${name} declares listed on an action that neither creates nor deletes, so no Folder command follows it.`) } const formats: [string, unknown, boolean][] = [ // A scalar format means something on a change only: a comment's body is // marker text, and a deletion takes no value. ...(input?.format !== undefined ? [['its input', input.format, !creates && !!emits?.field && emits.kind !== KIND_DELETION] as [string, unknown, boolean]] : []), ...Object.entries(properties && typeof properties === 'object' ? properties : {}) .filter(([, spec]) => (spec as { format?: unknown } | null)?.format !== undefined) .map(([property, spec]): [string, unknown, boolean] => [ `"${property}"`, (spec as { format?: unknown }).format, (spec as { target?: unknown }).target === 'content', ]), ] for (const [where, format, applies] of formats) { if (!applies) { problems.push(`${name} declares a format on ${where}, which is neither a creation's content nor a change's value, so it is ignored.`) } else if (!proseFormatOf(format)) { problems.push(`${name} declares format ${JSON.stringify(format)} on ${where}, and the only format is ${BLOCK_DOCUMENT_FORMAT}. A consumer draws it as plain text.`) } } return problems } export function pickWidget( declared: string | string[], implemented: readonly T[], fallback: T, ): T { for (const candidate of Array.isArray(declared) ? declared : [declared]) { if ((implemented as readonly string[]).includes(candidate)) return candidate as T } return fallback } /** A slot, resolved to something a renderer can put on screen. */ export interface ResolvedSlot { label?: string value: string /** Set when the value came from a vocabulary — the consumer picks the colour. */ colour?: string /** True when the value is a pubkey and should be shown as a person. */ isPubkey?: boolean /** * The content model this value is written in — SPEC §13.4. * * Set only for a slot whose source can carry a body (`{field: "content"}`, or * a `fold` a change event has actually set). **Undefined means the value is * not a body at all** — a tag, a seed, a default — not that it is marker * text. `'marker'` is what "a body with no declared format" resolves to, and * that is permanent rather than a migration state. * * A consumer rendering `slots.body` must branch on this. Parsing a block * document as marker text, or the reverse, is forbidden outright by §13. */ format?: ContentFormat /** * The underlying field this slot reads, when it has a name. * * Carried so a renderer can tell that a slot and an action are two views of * *one* value — a status label beside a status picker is the same fact twice * (PEEK-18). Matching on the field is what makes that check work for any app: * the slot key (`status`) and the action's label ("Change status") are both * free-form, but a `ResolvedAction.field` and this always name the same thing * because the manifest wrote them both. * * Undefined for a slot with no named source — `{field: "content"}` reads the * event body, which no action can set. */ field?: string } /** * A slot's value before any vocabulary mapping or truncation. * * Split out because "what does this object's status *say*" and "what should a * reader see" are different questions: the display value is a vocabulary label * ("In Progress"), and anything deciding on the value — the active-issue filter * below — has to compare the underlying one (`in_progress`). */ /** The first of these tags the event actually carries. */ function firstTag(root: SignedEvent, tag: string | string[] | undefined): string | undefined { if (!tag) return undefined for (const name of Array.isArray(tag) ? tag : [tag]) { const value = tagValue(root, name) if (value !== undefined && value !== '') return value } return undefined } /** * A slot's value, and the content model it is written in when it has one. * * **A `body` slot always has a format; nothing else ever does.** * * The slot name is what decides, because nothing in the data can. A folded * `status` arrives through the same `value` tag a folded description does, and * — this is the part I got wrong first — a project's description arrives * through a *root tag*, exactly like its title. Ship writes * `["description", …]` and leaves `content` empty on purpose, so a reader * holding only tags has it without fetching content (`events.ts`). Eleven of * fifteen production projects are that shape. * * So "a tag is a scalar and never a body" was false, and it was false in the * common case. The rule is the slot, not the source: * * - a change event → the format that change declared, else `marker` * - a root tag, or the root's `content` → `contentFormatOf(root)`, because both * are the same event and §13.4's tag describes that event's body wherever the * event keeps it * - a `default` → `marker`; it is a literal in the manifest, so it is plain * text by construction * * The one exception is `{field: "content"}`, which reports a format whatever * slot it was declared into. That is what keeps the PRO-8 guard below * reachable: `{"subtitle": {"field": "content", "truncate": 120}}` is a * mis-declaration, and it must not also escape the truncation rule. * * Everything else reports nothing, and `undefined` means *not a body* rather * than *marker*. A consumer reading `slots.body` never needs a fallback. */ interface RawSlotValue { value: string format?: ContentFormat } function rawSlotValue( spec: SlotSpec, root: SignedEvent, folded: Record, isBody: boolean, ): RawSlotValue | undefined { // `fold` first, and a spec may carry both: a field that starts as a tag on // the root event and is then overridden by changes (a project's lead is the // case in hand). Reading the tag first would render the value the object was // created with forever — which is exactly what someone sees right after // reassigning it from here. if (spec.fold) { const change = folded[spec.fold] if (change) return { value: change.value, format: isBody ? (change.format ?? 'marker') : undefined } /* Nobody has changed this field, so the object's own creation value stands. §7.2 rule 2 covers seeding from a tag; `field: "content"` seeds from the event body, and the two compose — tag first, then content. **Needed because a description is where an app puts its body and `content` is where the body goes.** Ship's issue description is `fields.description?.value ?? event.content` and its project description puts a `description` tag between the two (`fold.ts`), and neither could be declared before this. The nearest expressible declarations were both wrong in the way §7.2 rule 1 already warns about: `{field: "content"}` alone renders the value the object was created with for ever, and `{fold: "description"}` alone renders blank for every object nobody has edited — which is most of them, and blank reads as "that app is broken" (PEE-10). The seed tag reports no format: a tag is a scalar. Content does, because it is the event's body whatever slot it was declared into — the same rule the direct `field: "content"` branch below follows, and what keeps the truncation guard reachable. */ const seedTag = firstTag(root, spec.tag) if (seedTag !== undefined) return { value: seedTag, format: isBody ? contentFormatOf(root) : undefined } if (spec.field === 'content' && root.content !== '') { return { value: root.content, format: contentFormatOf(root) } } if (spec.default === undefined) return undefined // A default is a literal in the manifest, not a value any event carries, so // it is plain text by construction. Reported as `marker` rather than as // nothing so that a `body` always has a format — see below. return { value: spec.default, format: isBody ? 'marker' : undefined } } if (spec.tag) { const value = firstTag(root, spec.tag) if (value === undefined) return undefined return { value, format: isBody ? contentFormatOf(root) : undefined } } if (spec.field === 'content') return { value: root.content, format: contentFormatOf(root) } /* `pubkey` — the event's author — added by PRO-6. Found by trying to declare a projection for a Peek Message. §13.3 makes `title` the one required slot, and a `kind:9` has no title: it has an author, a body and a time. The body cannot be the title, because message content is structured for 41% of production events and truncating structure is exactly what PRO-8 removed from Ship's manifest. Which leaves the author, and until now no slot source could name it. It is a genuine top-level event field, so it belongs in `field` rather than in a new source. Paired with `as: "pubkey"` it renders as a person, which is what a message wants as its title everywhere it appears. */ if (spec.field === 'pubkey') return { value: root.pubkey } return undefined } function resolveSlot( spec: SlotSpec, root: SignedEvent, folded: Record, manifest: Manifest, name?: string, ): ResolvedSlot | null { const source = rawSlotValue(spec, root, folded, name === BODY_SLOT) if (source === undefined || source.value === '') return null let raw = source.value /* `subtitle` is plain text on one line (SPEC §7.2, §6.12): line breaks fold to spaces, and a value that is blank once trimmed is no subtitle — a summary cleared with " " reads as cleared, and the seed does not show through it. Left to each consumer, `slots.subtitle &&` drew a blank one as an empty line (Peek's ForeignObjectWidget). A body-shaped value declared into the slot is left alone; the truncation guard below is what speaks to that. */ if (name === SUBTITLE_SLOT && source.format !== 'blocks' && source.format !== 'unknown') { raw = raw.replace(/\s+/g, ' ').trim() if (raw === '') return null } /* PRO-8's rule, now enforceable rather than only written down. `truncate` is a plain-text operation. Ship's manifest carried a comment saying so and a type that forbade the pairing, but nothing stopped another app publishing `{"field": "content", "truncate": 120}` — and the consumer would happily slice 120 characters out of a JSON block document and render the fragment. That is the PRO-8 defect exactly: output that is wrong and cannot tell that it is wrong. Marker text is still truncated. It degrades honestly — a cut `**bold` is visibly a cut, and 548 published messages are written in it. */ const structured = source.format === 'blocks' || source.format === 'unknown' let value = structured ? raw : truncate(raw, spec.truncate) let colour: string | undefined if (spec.map) { const entry = manifest.vocabularies?.[spec.map]?.find((v) => v.value === raw) // A value outside the declared vocabulary is shown as-is rather than // dropped. Another app may have written it, and silently rendering nothing // would hide exactly the corruption the honour system permits. value = entry?.label ?? raw colour = entry?.colour ?? 'muted' } return { label: spec.label, value, colour, isPubkey: spec.as === 'pubkey', field: spec.fold ?? (Array.isArray(spec.tag) ? spec.tag[0] : spec.tag), // Present only on a slot that can carry a body. Absent is the honest shape // for a title read from a tag — and it keeps the key out of every existing // consumer's deep comparisons, which is not the reason but is a real cost // avoided: `format: undefined` is an own property to `deepStrictEqual`. ...(source.format ? { format: source.format } : {}), } } /* `truncate` is the clamp SPEC §6.12 makes a reader's job: the relay does not hold a writer to about 240 characters, and with no manifest declaring the slot nothing else would cut a 1,000-character summary down to a line. A consumer still clamps it to its own room; this is the backstop. */ const SUMMARY_TRUNCATE = 280 const SUMMARY_SUBTITLE: SlotSpec = { tag: SUMMARY_FIELD, fold: SUMMARY_FIELD, truncate: SUMMARY_TRUNCATE } const FOLDER_SUBTITLE: SlotSpec = { tag: 'about' } /** * Where a kind's `subtitle` comes from whatever its manifest says, or * `undefined` where the manifest decides — SPEC §7.2, §6.12, §3.2. * * - **A Folder** (`kind:39000`): the `about` of its channel record, NIP-29's * one-liner. A `summary` change aimed at one is not read. * - **Every other addressable kind is a file**, and its subtitle is its * summary: the `summary` fold over the root tag of that name. Built in, so an * app's files — an outside company's included — show their summary on every * card with nothing in its manifest, and no manifest can point the line at * another field (Miky, 2026-10-15, kickoff). * - NIP-29's other `39001`–`39009` records, and regular events (a message, a * comment), keep what their manifest declares. */ function defaultSubtitle(kind: number): SlotSpec | undefined { if (kind === KIND_CHANNEL_METADATA) return FOLDER_SUBTITLE if (kind >= 39000 && kind <= 39009) return undefined return isAddressableKind(kind) ? SUMMARY_SUBTITLE : undefined } /** * A projection's slots with `subtitle` taken from {@link defaultSubtitle}. * * A declaration naming the same source keeps its `label` and a `truncate` * tighter than the built-in one (Peek's Folder projection truncates `about` at * 120); one naming any other source, or an array, is replaced. */ function withDefaultSubtitle( slots: Record, kind: number, ): Record { const builtIn = defaultSubtitle(kind) if (!builtIn) return slots const declared = slots[SUBTITLE_SLOT] const same = declared !== undefined && !Array.isArray(declared) && declared.tag === builtIn.tag && declared.fold === builtIn.fold && declared.field === undefined && declared.default === undefined && declared.map === undefined && declared.as === undefined && declared.children === undefined if (!same) return { ...slots, [SUBTITLE_SLOT]: builtIn } // Same source: its `label` and a tighter `truncate` stand, never a looser one. const truncate = declared.truncate === undefined || builtIn.truncate === undefined ? (declared.truncate ?? builtIn.truncate) : Math.min(declared.truncate, builtIn.truncate) return { ...slots, [SUBTITLE_SLOT]: { ...builtIn, label: declared.label, truncate } } } /** * Every slot a projection declares, resolved. * * Single slots are named (`title`, `subtitle`, `status`); array specs collect * into `meta`. Shared so the sidebar and the inline widget resolve a projection * identically — two loops would drift the first time a slot type is added. */ function resolveSlots( projection: { slots: Record }, root: SignedEvent, folded: Record, manifest: Manifest, ): { slots: Record; meta: ResolvedSlot[] } { const slots: Record = {} const meta: ResolvedSlot[] = [] for (const [name, spec] of Object.entries(withDefaultSubtitle(projection.slots, root.kind))) { if (Array.isArray(spec)) { for (const one of spec) { // An array spec collects into `meta`, so the name it was declared under // is not the slot's meaning — nothing in an array is a body. const value = resolveSlot(one, root, folded, manifest) if (value) meta.push(value) } } else { const value = resolveSlot(spec, root, folded, manifest, name) if (value) slots[name] = value } } return { slots, meta } } /** Everything the frontend needs to draw the widget. */ export interface ForeignObject { /** * A stable unique handle for this object, whatever kind of thing it is. * * **Added by PRO-7, and it exists because not every object has an address.** * A replaceable record is identified by `kind:pubkey:d`; a regular event — * Peek's `kind:9` message is the case in hand — has no `d` at all and is * identified only by its event id. Before this, `address` was required and * doubled as the React key, the error-map key and the `data-` attribute, so * a non-addressable object could not be represented at all. * * Use this for identity. Use `naddr` only where an *address* is genuinely * required, which in practice means acting on an object. */ ref: string /** * The address, `kind:pubkey:d` — **only for an addressable object.** * * Undefined for a regular event. See `ref`. */ address?: string /** * The address as `naddr1…`, **only for an addressable object.** * * Carried alongside `address` because acting on an object is addressed by * naddr (`act.ts`), and the sidebar builds its objects rather than being * handed a reference somebody pasted. Without it every control in a * sidebar card would have to re-encode what the server already knows. * * Its absence is meaningful rather than a gap: an action emits a change * carrying an `a` tag, and there is nothing for that tag to point at on a * non-addressable object. A consumer that has no `naddr` correctly offers no * actions. */ naddr?: string /** The event id — set for every object, and the only handle a regular event has. */ eventId: string /** * Child objects from a `list` slot, each resolved through its own projection. * * Empty rather than absent when the slot is declared and nothing matched, so * a renderer can tell "this holds nothing" from "this holds no list". */ children?: ForeignObject[] /** * Whether the owning app declares a child list **and** says how to draw it. * * For a consumer deciding whether to offer an expander. `children` answers a * different question: it is absent until something has actually fetched them, * so a folder listing — which deliberately does not, see * {@link resolveFolderContents} — cannot use it to tell "has issues" from * "nobody asked yet". Without this the only options were a disclosure on * every row, including a Peek topic that can never have one, or none at all. * * Note what it is *not*: a promise that children exist. A project with no * issues still declares the list, and expanding it is the honest empty answer * rather than a control that was wrong to offer. */ listsChildren?: boolean /** * The file this one names as its parent, when its app declares the relation. * * **For a listing that draws nesting.** A folder read by containment lists an * app's records by `h`, and Ship writes `h` on an issue as well as on its * project — so the project and its issues arrive as peers. Drawing the * project's children under it then shows every issue twice: once nested and * once at the top level. A consumer cannot tell which files are children, * because the tag that says so is named by the *parent kind's* projection and * never reaches the consumer. * * So this is that tag, resolved: the address in the child's own `via` tag. * It is set whether or not the parent is in the same listing — whether to * nest, hide, or label "in " is the consumer's decision, and the two * cases genuinely differ. * * Absent for a file whose app declares no such relation, and for a `match: * 'identifier'` list, where the child names only the parent's `d` and an * address cannot be built from it without inventing a pubkey. */ parentRef?: string /** * The folded field that re-parents this object, when its owner declares one * — SPEC §7.2's `movedBy`: `parent` on a bare file (§6.7), `project` on an * issue whose app declares it (MAN-1). Hard-coded to the bare file before * 0.36.0. * * **For a consumer that draws field-setting actions as property rows.** The * action setting this field is a move, and its value is an address: drawn as * a text box under a card it asks a person to type `30840::`. It * is the tree's control, offered where the tree is — the same step aside the * title's rename takes, matched on the field for the same reason. */ parentField?: string /** * The kind a move may name — the kind whose projection declares the * `movedBy` (MAN-1): `30850` for a Ship issue, so a move control offers * projects and not other issues. Absent with `parentField` on a bare file, * or a kind declared with `anyParent` (a document), which may sit under any * kind (§6.7). {@link nestingOf}'s `moveTargetsOf` * reads it. */ parentKind?: number kind: number /** * The layout hint the owner declared — **a type or an ordered chain of them.** * * `["message", "card"]` means *render me as a message if you know it, else as * a card*, and a chain MUST terminate in a type RFC 0.4 §13.3 closes * (`card`/`row`/`table`/`stat`). A bare string is the older form and is a * chain of one. * * **This was typed `string` until PRO-7**, while Peek's own published manifest * declares `["message","card"]` — so the type said one thing and the wire said * another, and a consumer writing `widget === 'card'` compared a string to an * array and silently drew nothing. Found by Ship, the second consumer, the * first time anything typechecked a renderer against a real chain. That is * the answer to PRO-1's "what did the second consumer force to change". */ widget: string | string[] appName?: string /** * What the owning app calls one of these — "issue", "project" — read off * its projection's `noun`, lower case and singular (PEE-41). * * For a consumer that names the object in its own words: a menu's "Delete * issue", a confirm's "Delete this project?". Without it Peek called a Ship * issue a topic, because the only way to know better was to check the kind * number, which is the check a manifest exists to replace. Absent when the * owner declares none. A bare file's is the `fileNoun` of the app that * opens it (PRO-24), and absent when that app declares none. */ noun?: string /** Named single slots: title, subtitle, status. */ slots: Record /** Repeating slots, e.g. `meta`. */ meta: ResolvedSlot[] comments: { id: string; author: string; body: string; createdAt: number }[] /** * The Folder this object lives in: the root's `h`, or `buzz-channel` on a * record that names its Folder that way, as Ship's projects do * ({@link folderOf}, SPEC §7.3). * * Needed to *write*: a change event carries it as `h` (SPEC §6.10), and * without it the relay rejects the write outright. Reading it off the object * rather than asking the user is the difference between an action that works * and a form with a "Folder id" box in it. Until LTX-4 it read `h` only, so a * Ship project had none and no other app could save or comment on it. */ folder?: string /** * The channel that gates who can read the root itself: its `h`, and absent * for a record stored globally (a `buzz-channel` project, whose name anyone * can read). Not where writes go — that is {@link folder}. A move into a * private Folder reads this (`planMoveFile`), because a listing never changes * who can read a file (SPEC §3.1). */ scopedTo?: string /** * Where to open this object in the app that owns it, from NIP-89's `web` tag. * * The return leg of the roundtrip. Without it a reference is a read-only * snapshot: Peek can render a Linear-lite issue and change its status, and * getting back to the issue itself means alt-tabbing and hunting for a row. * * Absent when the owning app published no template — the widget simply does * not offer the link, rather than guessing a URL. */ openUrl?: string /** What the owning app says we may do to this object. */ actions: ResolvedAction[] /** * Names and faces for every pubkey this object shows (FEE-1). * * Carried on the object rather than looked up by the renderer so a widget is * still a pure render of what it was handed — the same property that lets * Storybook draw a real person from a fixture. Empty when nothing published a * profile, which the renderer treats as "someone we cannot name" rather than * falling back to the key. */ people?: People /** False when no author recommendation existed and a handler was guessed. */ viaRecommendation: boolean /** * Set when the manifest resolved but the object itself did not. * * Almost always a permission problem rather than a missing object, and worth * distinguishing because the relay makes them look identical. Objects and * change events are channel-scoped, so a reader who is not a member of the * Folder gets zero rows — no error, just silence. The manifest and the * recommendation are global kinds and resolve fine without membership, so the * failure lands late and presents as a broken reference. Telling the user * "you may not have access to this Folder" is a far better guess than * rendering nothing. */ unreachable?: boolean /** * Present when the file is archived, itself or with an ancestor (FOL-46). * * A Folder's contents leave archived files out, so this is set where a file * is reached by address: its page draws an Archived banner with the * resolution and an Unarchive control rather than drawing it as live work. * See {@link Archive} and {@link isArchived}. */ archived?: Archive /** * When this file last changed, in seconds: the newest of its root's * `created_at` and every folded field's winning change (5ebf2756). * * For a card that lists some of a file's children rather than all of them — * a project's most recently changed issues. Its comments are not changes to * it. A regular event, which nothing changes by address, has its own * `created_at`. Absent on an unreachable stub, which has no root to read. */ changedAt?: number } /** The newest of a root's `created_at` and its folded fields' changes. */ function changedAtOf(root: SignedEvent, folded: Record): number { return Math.max(root.created_at, ...Object.values(folded).map((f) => f.at ?? 0)) } /** * One resolved object, from its root event and the changes that have landed on * it. Shared by the inline widget and the sidebar so both draw the same shape * from the same rules — the difference between them is what they *fetch*, not * how they render. */ function buildObject(args: { root: SignedEvent pointer: AddressPointer manifest: Manifest projection: Projection folded: Record viaRecommendation: boolean webTemplate?: string comments?: ForeignObject['comments'] archived?: Archive }): ForeignObject { const { root, pointer, manifest, projection, folded } = args const naddr = encodeNaddr(pointer) const { slots, meta } = resolveSlots(projection, root, folded, manifest) const objectAddress = pointerToAddress(pointer) return { // An addressable object's `ref` is its address: stable across the author // replacing the event, which the event id is not. ref: objectAddress, address: objectAddress, naddr, eventId: root.id, kind: pointer.kind, widget: projection.widget, appName: manifest.name, ...nounOf(projection), slots, meta, comments: args.comments ?? [], listsChildren: listsChildrenOf(projection, manifest), parentRef: parentRefOf(manifest, pointer.kind, root, folded), ...parentFieldsOf(manifest, pointer.kind), folder: writeFolderOf(root), scopedTo: tagValue(root, 'h'), // Substituted here rather than in the component: `` is a NIP-89 // detail, and the widget's job is to draw a link, not to know the spec. openUrl: args.webTemplate?.replace('', naddr), actions: actionsOfFile(manifest, projection, root, folded), viaRecommendation: args.viaRecommendation, ...(args.archived ? { archived: args.archived } : {}), changedAt: changedAtOf(root, folded), } } /** * An addressable file's actions, each with what its field holds now, however * it got there. * * `resolveActions` reads the fold alone, which is right for a field that only * ever exists as a change — but a project's lead is seeded by a tag on the * root event. Without this, the assign control on a project nobody has * reassigned reads "Assign to me" while a lead is plainly set, and the one * control that is supposed to both report and set the field (PEEK-18) reports * nothing. */ function actionsOfFile( manifest: Manifest, projection: Projection, root: SignedEvent, folded: Record, ): ResolvedAction[] { const held: Record = {} // The built-in subtitle too, or a summary seeded at creation shows on the // card while `summarize` offers a blank to edit (SPEC §6.12). for (const spec of Object.values(withDefaultSubtitle(projection.slots, root.kind)).flat()) { // `tag` may name several spellings (see `SlotSpec`); the field this slot // *is* keyed on is the first, which is the one the app writes today. The // rest are only there to keep older records rendering. const field = spec.fold ?? (Array.isArray(spec.tag) ? spec.tag[0] : spec.tag) const raw = field ? rawSlotValue(spec, root, folded, false)?.value : undefined if (field && raw !== undefined) held[field] = raw } return resolveActions(manifest, root.kind, folded).map((action) => action.current === undefined && action.field ? { ...action, current: held[action.field] } : action, ) } /** * Resolve a `nostr:naddr…` into a renderable widget. * * Returns `null` rather than throwing when the object cannot be rendered — an * unresolvable reference in a chat message should degrade to plain text, not * break the message around it. */ /** * Resolve one event by id — `nevent1…`, or a bare 64-hex id. * * The counterpart to `resolveForeignObject`, and the reason it has to exist: * **not every object has an address.** A `kind:9` message carries no `d`, so * `(kind, pubkey, d)` cannot be built for it and every resolver keyed on an * address is blind to it. Measured on production during PRO-6; PRO-11 is this. * * ## What it does *not* do, and why the function is short * * A regular event is immutable and has no folded state, so there is no `records` * rule to apply, no change events to fetch, and no "current value" that differs * from what is on the event. It also cannot be the target of an `a` tag, so it * has no comments addressed to it and **no actions** — a change names its target * by address, and there is nothing here to name. That absence is the model being * honest rather than a gap to fill later. * * ## Two round trips, and the order depends on the reference * * A manifest is found by kind. An `nevent` *may* carry its kind, and when it * does the manifest and the event can be fetched together. When it does not — * a bare id, which is what a pasted `e` tag gives you — the event has to be * read first to learn what kind it is. Both paths are supported because both * arrive in practice, and a resolver that required the richer form would refuse * references other clients legitimately produce. */ export async function resolveForeignEvent( reference: string, query: QueryFn, /** Defaults to asking the relay. The browser passes a cached lookup. */ lookupPeople?: PeopleFn, /** See {@link ProjectionCache}. Omitting it is exactly the old behaviour. */ cache?: ProjectionCache, ): Promise { let pointer: EventPointer try { pointer = /^[0-9a-f]{64}$/i.test(reference.replace(/^nostr:/i, '')) ? { id: reference.replace(/^nostr:/i, '').toLowerCase(), relays: [] } : decodeNevent(reference) } catch { return null } const [root] = await query([{ ids: [pointer.id], limit: 1 }]) if (!root) { // Nothing to draw and nothing to say about it: unlike an addressable // object, there is no manifest resolved yet that could name the app or // offer a way in. `unreachable` needs a projection to be a useful state. return null } // Whoever signed it is the app's own author, which is what a `#k` lookup // needs when no recommendation exists. The pointer's `author` is a hint and // may disagree with the event; the event wins, because it is the thing. const resolved = await resolveManifest( { kind: root.kind, pubkey: root.pubkey, identifier: '', relays: pointer.relays }, query, cache, ) if (!resolved) return null const projection = resolved.manifest.projections?.[String(root.kind)] if (!projection) return null const object = buildChildObject({ root, manifest: resolved.manifest, projection, records: foldRuleOf(resolved.manifest), webTemplate: resolved.webTemplate, viaRecommendation: resolved.viaRecommendation, // The reference as given, so "open this in the app that owns it" points at // the event rather than at nothing. A bare id is upgraded to an `nevent` // carrying what we now know, which is more than the caller had. nevent: encodeNevent({ id: root.id, relays: pointer.relays, pubkey: root.pubkey, kind: root.kind }), }) const people = await (lookupPeople ?? peopleViaRelay(query))(pubkeysIn(object)) return { ...object, people } } export async function resolveForeignObject( naddr: string, query: QueryFn, /** Defaults to asking the relay. The browser passes a cached lookup. */ lookupPeople?: PeopleFn, /** * How many `list` levels have already been followed. Callers outside this * module leave it at 0; it is the budget that stops a child's own `list` * recursing forever. See `MAX_LIST_DEPTH`. */ depth = 0, /** * Optional memo for the NIP-89 discovery half. See {@link ProjectionCache} — * omitting it is exactly the old behaviour. */ cache?: ProjectionCache, options: ArchiveReadOptions = {}, ): Promise { const pointer = addressPointerOf(naddr) if (!pointer) return null const resolved = await resolveManifest(pointer, query, cache) if (!resolved) return null const plan = planObject(naddr, pointer, resolved, depth) if (!plan) return null // Beside the object's own read, not after it: the sweep is cached, and cold // it would otherwise add a round trip to every resolve that pays for it. // A Folder card's files too, for the same reason. const [answered, creators, files] = await Promise.all([ query(plan.filters), creatorsOrNone(pointer.kind, query, cache), options.folderFiles && pointer.kind === KIND_CHANNEL_METADATA ? folderFilesOf(pointer.identifier, query, lookupPeople, cache) : undefined, ]) const followUp = childChangeFilters(plan, answered) const childChanges = followUp.length ? await queryChunked(followUp, query) : [] const childOwners = await childOwnersOf(plan, answered, childChanges, query, cache) const assembled = assembleObject(plan, answered, childChanges, childOwners) if (assembled.object.unreachable) return assembled.object if (options.archivedWith) await markArchivedParents([{ object: assembled.object, manifest: resolved.manifest }], query, cache) return withFiles( withBorrowed( withPeople(assembled, await (lookupPeople ?? peopleViaRelay(query))(peopleOf(assembled))), borrowedActions(pointer.kind, resolved, creators), ), files, ) } /** * The object's own actions, then the ones other apps declare on it (MAN-8). * * The object's only, not its children's: a child is drawn inside its parent's * card, and a creation offered on every issue in a project's list is a form * nobody opens from there. An unreachable object gets none either — it has no * Folder to create anything in. */ function withBorrowed(object: ForeignObject, borrowed: ResolvedAction[]): ForeignObject { return borrowed.length ? { ...object, actions: [...object.actions, ...borrowed] } : object } /** * Many addressed objects in one round trip — PER-10. * * {@link resolveForeignObject} costs one request per object once the manifest * is cached, and a consumer holding a set re-reads the whole set on every * refresh: Peek's Screener asked once per followed file, every minute, and an * idle Desk spent 144 of 194 requests in five minutes on exactly that, against * a relay that meters 300 a minute per person across every tab. This reads the * same objects with the same filters, **all of them in one POST** — chunked at * `MAX_FILTERS_PER_QUERY` the way {@link conversationsOf} is, since each object * asks up to four filters — then one people lookup for the whole set. * * So a refresh is `ceil(filters / 128)` requests and a people lookup, where it * was one request per object and a lookup each: two for any set of up to 32 * objects, whatever their number. * * ## Same filters, same objects * * Each object's filters are exactly the ones {@link resolveForeignObject} * sends, so each keeps its own `limit` — one busy file cannot starve another's * comments, which is why this is not one filter naming every address. The * answers come back pooled, and every object is picked back out of the pool on * its filters' own criteria, the rule `childrenFrom` already follows for the * children that share a request with their parent. The result is keyed by the * reference as given, and each object is what `resolveForeignObject` returns * for it. * * A reference that is not an address, or whose app has no manifest or no * projection for its kind, is `null` — as it is from the single form. An event * reference (`nevent`, a bare id) is not an address and is `null` here; that * is {@link resolveForeignEvent}'s. * * **A failed read rejects the whole call**, as the single form throws. There is * no partial answer to give: the objects arrive pooled, so a chunk that failed * could have held any of them. */ export async function resolveForeignObjects( references: readonly string[], query: QueryFn, /** Defaults to asking the relay. The browser passes a cached lookup. */ lookupPeople?: PeopleFn, /** See {@link ProjectionCache}. Omitting it still reads each app's manifest once per call. */ cache?: ProjectionCache, options: ArchiveReadOptions = {}, ): Promise> { const results: Record = {} const asked: { reference: string; pointer: AddressPointer }[] = [] for (const reference of new Set(references)) { const pointer = addressPointerOf(reference) results[reference] = null if (pointer) asked.push({ reference, pointer }) } if (asked.length === 0) return results // One manifest per app, never per object — the reason `conversationsOf` // groups the same way. A cache would share these too, but a caller without // one should not pay per object for what every object of an app shares. const byApp = new Map() for (const { pointer } of asked) byApp.set(manifestKeyOf(pointer), pointer) const manifests = new Map() await Promise.all( [...byApp].map(async ([key, pointer]) => { const resolved = await resolveManifest(pointer, query, cache) if (resolved) manifests.set(key, resolved) }), ) const plans = asked.flatMap(({ reference, pointer }) => { const resolved = manifests.get(manifestKeyOf(pointer)) const plan = resolved && planObject(reference, pointer, resolved, 0) return plan ? [{ reference, plan }] : [] }) if (plans.length === 0) return results // Two spellings of one object — its `naddr` and its plain address — are two // answers (each keeps its own `openUrl`) and one set of filters. const filters: Record[] = [] const planned = new Set() for (const { plan } of plans) { if (planned.has(plan.address)) continue planned.add(plan.address) filters.push(...plan.filters) } // Who else creates on each kind in the set (MAN-8): one sweep per kind, not // per object, read beside the objects themselves. const kinds = [...new Set(plans.map(({ plan }) => plan.pointer.kind))] // And each Folder card's files, once per Folder however it is spelled. const folders = options.folderFiles ? [ ...new Set( plans.filter(({ plan }) => plan.pointer.kind === KIND_CHANNEL_METADATA).map(({ plan }) => plan.pointer.identifier), ), ] : [] const [events, creatorLists, fileLists] = await Promise.all([ queryChunked(filters, query), Promise.all(kinds.map((kind) => creatorsOrNone(kind, query, cache))), Promise.all(folders.map((folder) => folderFilesOf(folder, query, lookupPeople, cache))), ]) const creatorsByKind = new Map(kinds.map((kind, i) => [kind, creatorLists[i]])) const filesByFolder = new Map(folders.map((folder, i) => [folder, fileLists[i]])) // Every card's children's changes in one more read for the whole set, not // one per card (MAN-7) — and none at all when no card has a fold to read. const followUp: Record[] = [] const followed = new Set() for (const { plan } of plans) { if (followed.has(plan.address)) continue followed.add(plan.address) followUp.push(...childChangeFilters(plan, events)) } const childChanges = followUp.length ? await queryChunked(followUp, query) : [] const ownerLists = await Promise.all(plans.map(({ plan }) => childOwnersOf(plan, events, childChanges, query, cache))) const assembled = plans.map(({ reference, plan }, i) => ({ reference, plan, built: assembleObject(plan, events, childChanges, ownerLists[i]), })) if (options.archivedWith) { await markArchivedParents(assembled.map(({ plan, built }) => ({ object: built.object, manifest: plan.resolved.manifest })), query, cache) } const wanted = [...new Set(assembled.flatMap(({ built }) => peopleOf(built)))] const reachable = assembled.some(({ built }) => !built.object.unreachable) const people = reachable ? await (lookupPeople ?? peopleViaRelay(query))(wanted) : {} for (const { reference, plan, built } of assembled) { if (built.object.unreachable) { results[reference] = built.object continue } // Each object carries the people it names, as the single form's does — // not the whole set's, which would make an object's shape depend on what // it happened to be resolved beside. const own: People = {} for (const key of peopleOf(built)) if (key in people) own[key] = people[key] results[reference] = withFiles( withBorrowed( withPeople(built, own), borrowedActions(plan.pointer.kind, plan.resolved, creatorsByKind.get(plan.pointer.kind) ?? []), ), plan.pointer.kind === KIND_CHANNEL_METADATA ? filesByFolder.get(plan.pointer.identifier) : undefined, ) } return results } /** * Every object in a set of events the caller already holds, projected — PER-21. * **No requests.** * * {@link resolveForeignObjects} reads each object's own filters, which is the * right price for a sidebar holding a few dozen and the wrong one for a reader * that wants a whole app's workspace: interop's own measurement put 110 issues * at 20 POSTs and a megabyte (FOL-50), and estiva-agent re-reads its workspace * on every command. A reader like that can fetch the roots and the app's change * kind in bulk — a few paged queries — and hand them here. * * Each root of a kind the manifest projects becomes exactly the object * `resolveForeignObject` builds for it: the same fold (`records`, §6.2–6.3), * the same archive read, the same slots, meta, parent and actions. What is * deliberately absent is everything that would need another read: * * - **`comments` is `[]`.** A comment is a separate event in a Folder, and * which comments the caller fetched says nothing about which it did not. * Read conversations with {@link conversationsOf} and {@link threadsOf}. * - **No `children`.** A `list` slot is containment, and the children are * other objects in the same answer: nest them by `parentRef`. * - **No `people`, and no borrowed actions** (MAN-8) — both are reads. * * - **No archive inherited from a parent.** `archived` is the object's own * change; `resolveForeignObjects`'s `archivedWith` reads the parents' too, * and here the parent is simply another object in the answer, whose * `archived` the consumer can read by `parentRef`. * * Two versions of one root (a replay, two relays) collapse by NIP-01's rule, * as the relay's own replacement does: later `created_at`, then the lower id. * Objects come back in no particular order; sort them as the consumer draws. * * Linear in the events: the changes are grouped by target once, so a * workspace of hundreds of roots does not rescan thousands of changes per root. */ export function projectEvents( events: readonly SignedEvent[], /** * The manifest each root is read through, **per author** — what * {@link resolveManifest} answered for that root's `kind:pubkey`, from the * caller's own resolution (a {@link ProjectionCache} keeps it to one read per * author). Per author, not one for all, because that is the trust anchor * `resolveForeignObject` keeps: an object is drawn by the app *its author* * recommended, and `viaRecommendation` says whether they did. A root this * answers `undefined` for, or whose kind its manifest does not project, is * not an object here. */ manifestFor: (pointer: AddressPointer) => ResolvedManifest | undefined, ): ForeignObject[] { const roots = new Map() for (const event of events) { const identifier = tagValue(event, 'd') if (identifier === undefined) continue const pointer: AddressPointer = { kind: event.kind, pubkey: event.pubkey, identifier, relays: [] } const resolved = manifestFor(pointer) if (!resolved?.manifest.projections?.[String(event.kind)]) continue const address = `${event.kind}:${event.pubkey}:${identifier}` const held = roots.get(address)?.root if (!held || event.created_at > held.created_at || (event.created_at === held.created_at && event.id < held.id)) { roots.set(address, { root: event, pointer, resolved }) } } // Each helper still applies its own kind and target test to what it is // handed; grouping by target once only narrows what it has to look at. const grouped = new Map>() const byTarget = (targetTag: string) => { let byAddress = grouped.get(targetTag) if (byAddress) return byAddress byAddress = new Map() for (const event of events) { const target = tagValue(event, targetTag) if (target === undefined || !roots.has(target)) continue const list = byAddress.get(target) if (list) list.push(event) else byAddress.set(target, [event]) } grouped.set(targetTag, byAddress) return byAddress } return [...roots].map(([address, { root, pointer, resolved }]) => { const { manifest } = resolved const archiveRule = archiveRuleOf(manifest) return buildObject({ root, pointer, manifest, projection: manifest.projections![String(root.kind)], folded: foldOf(byTarget(foldRuleOf(manifest).targetTag).get(address) ?? [], address, manifest, root), viaRecommendation: resolved.viaRecommendation, webTemplate: resolved.webTemplate, archived: archiveOf(byTarget(archiveRule.targetTag).get(address) ?? [], address, archiveRule, root), }) }) } /** * The objects at a set of addresses, as their roots and changes alone draw * them — PEE-21. * * For a reader that needs what a file *is* now (its folded title, status, * parent, own archive) and not what is said about it: {@link projectEvents} * over each address's root and change filters — the same two * {@link resolveForeignObjects} sends — without the comments, children and * people that make up most of that call's bytes. `[` reads a search answer's * hits through this on every pause in typing. * * An address whose root the reader is not handed is simply absent, so a file * in a Folder they cannot read never comes back. A failed read rejects. */ export async function resolveForeignRoots( references: readonly string[], query: QueryFn, cache?: ProjectionCache, ): Promise { const pointers = new Map() for (const reference of references) { const pointer = addressPointerOf(reference) if (pointer) pointers.set(`${pointer.kind}:${pointer.pubkey}:${pointer.identifier}`, pointer) } if (pointers.size === 0) return [] const manifests = new Map() const byApp = new Map() for (const pointer of pointers.values()) byApp.set(manifestKeyOf(pointer), pointer) await Promise.all( [...byApp].map(async ([key, pointer]) => { const resolved = await resolveManifest(pointer, query, cache) if (resolved) manifests.set(key, resolved) }), ) const filters: Record[] = [] for (const [address, pointer] of pointers) { const resolved = manifests.get(manifestKeyOf(pointer)) if (!resolved?.manifest.projections?.[String(pointer.kind)]) continue // A manifest without `records` folds by a placeholder kind 0, never sent. const changeKinds = new Set( [foldRuleOf(resolved.manifest).changeKind, archiveRuleOf(resolved.manifest).changeKind].filter((k) => k !== 0), ) filters.push( { kinds: [pointer.kind], authors: [pointer.pubkey], '#d': [pointer.identifier], limit: 1 }, { kinds: [...changeKinds], '#a': [address], limit: 500 }, ) } if (filters.length === 0) return [] const events = await queryChunked(filters, query) return projectEvents(events, (pointer) => manifests.get(manifestKeyOf(pointer))) } /** * The parent each file sits under, and nothing else — FOL-50. * * {@link resolveForeignObjects} answers this on the way to a whole object, and * for a caller that needs only the parent that is the wrong price: it reads * every object's comments and children too. Peek's Folder dots place an * issue's talk by its project, and on production on 2026-09-30 the 110 issues * the dots name cost 20 POSTs and a megabyte that way. This asks for the roots, * and for the changes only of a kind whose app declares a `movedBy` — a filter * per 100 roots and one per movable file, so a POST per ~120 files: one for the * set the dots held on production. * * Folded by the rule the card and the listing use (`parentRefOf`), so a moved * issue is under the project it was moved to here as everywhere else, and a * file is answered only when its app draws its kind — as it is by the object * readers. * * Keyed by the reference as given: the parent's address, or `null` for a file * at the top. **Absent** when there is nothing to tell — not an address, no * manifest, or a root this reader cannot see — because "at the top" and * "unknown" are different sentences. * * **A failed read rejects the whole call**, as {@link resolveForeignObjects} * does. */ export async function resolveParents( references: readonly string[], query: QueryFn, /** See {@link ProjectionCache}. Omitting it still reads each app's manifest once per call. */ cache?: ProjectionCache, ): Promise> { const results: Record = {} const asked: { reference: string; pointer: AddressPointer }[] = [] for (const reference of new Set(references)) { const pointer = addressPointerOf(reference) if (pointer) asked.push({ reference, pointer }) } if (asked.length === 0) return results const byApp = new Map() for (const { pointer } of asked) byApp.set(manifestKeyOf(pointer), pointer) const manifests = new Map() await Promise.all( [...byApp].map(async ([key, pointer]) => { const resolved = await resolveManifest(pointer, query, cache) if (resolved) manifests.set(key, resolved.manifest) }), ) // The roots by author and kind, the one filter shape that names many; the // moves by the app's change shape, only where the kind can be moved at all. const roots = new Map }>() const moves = new Map }>() for (const { pointer } of asked) { const manifest = manifests.get(manifestKeyOf(pointer)) if (!manifest?.projections?.[String(pointer.kind)]) continue const group = `${pointer.kind}:${pointer.pubkey}` const held = roots.get(group) ?? { kind: pointer.kind, pubkey: pointer.pubkey, identifiers: new Set() } held.identifiers.add(pointer.identifier) roots.set(group, held) if (!manifest.records || !movedByOf(manifest, pointer.kind)) continue const shape = `${manifest.records.changeKind}:${manifest.records.targetTag}` const movable = moves.get(shape) ?? { rule: manifest.records, addresses: new Set() } movable.addresses.add(pointerToAddress(pointer)) moves.set(shape, movable) } const filters: Record[] = [] for (const { kind, pubkey, identifiers } of roots.values()) { const all = [...identifiers] for (let start = 0; start < all.length; start += PARENT_ROOT_GROUP) { const group = all.slice(start, start + PARENT_ROOT_GROUP) filters.push({ kinds: [kind], authors: [pubkey], '#d': group, limit: group.length }) } } // One filter per file, with the object reader's own limit: a group sharing a // page could push an old move out of it behind newer statuses and edits, and // this would then disagree with `parentRef`. for (const { rule, addresses } of moves.values()) { for (const address of addresses) filters.push({ kinds: [rule.changeKind], [`#${rule.targetTag}`]: [address], limit: 500 }) } if (filters.length === 0) return results const events = await queryChunked(filters, query) for (const { reference, pointer } of asked) { const manifest = manifests.get(manifestKeyOf(pointer)) if (!manifest?.projections?.[String(pointer.kind)]) continue const root = events .filter((e) => e.kind === pointer.kind && e.pubkey === pointer.pubkey && tagValue(e, 'd') === pointer.identifier) .sort(byOrder) .at(-1) if (!root) continue const folded = foldOf(events, pointerToAddress(pointer), manifest, root) results[reference] = parentRefOf(manifest, pointer.kind, root, folded) ?? null } return results } /** What {@link resolveForeignObject} and {@link resolveForeignObjects} may read beyond the file itself. */ export interface ArchiveReadOptions { /** * Also read whether each file's parent is archived, and mark the file * archived **with** it (`archived.with`) — FOL-46. For the page a link opens, * which draws the banner; off by default because it is one more request * whenever a file has a parent, and a refresh tick that resolves an issue * card would pay it every time (SHI-13's one request per tick). */ archivedWith?: boolean /** * For a Folder's card: also list the files in it this reader can open, as * `children` — SPEC §3.2. What its relay-signed `30890` lists, read as * {@link resolveFolderContents} reads it, so a file this reader cannot * resolve is absent and nothing counts the rest. Off by default, so a Folder * chip in a sentence pays nothing for a list it does not draw; ignored for * anything that is not a Folder. * * A Folder with no `30890` (a DM has none) and one whose listing cannot be * read still draw, without `children`: no list, rather than an empty one * that says it holds nothing. * * The resolve returns once the files have, so read a card's files in a call * of their own: what else is on the page should not wait for them. * * **It costs what a Folder's page costs**, almost all of it its files' * change history (a description edit is a whole `kind:1851`, and no relay * can filter one by its `field`). Estiva HQ on 2026-10-16, 24 files: 11 * requests and 7.6 MB cold, 3 and 7.6 MB warm, ~2 s — where the chip is 1 * request and nothing warm. Read it on open and focus, not on a refresh tick. * * why: https://github.com/estiva-app/estiva-docs/blob/main/decisions/folders-belong-to-the-protocol.md */ folderFiles?: boolean } /** * The files a Folder's card lists ({@link ArchiveReadOptions.folderFiles}): * what its relay-signed `30890` names, never what files claim by naming the * Folder themselves. `undefined` — no list, not an empty one — for a Folder * with no listing (a DM is one) and when the listing could not be read. */ async function folderFilesOf( folder: string, query: QueryFn, lookupPeople: PeopleFn | undefined, cache: ProjectionCache | undefined, ): Promise { try { const contents = await resolveFolderContents(folder, query, lookupPeople, cache, { listedOnly: true }) return contents.hasState ? contents.files : undefined } catch { return undefined } } function withFiles(object: ForeignObject, files: ForeignObject[] | undefined): ForeignObject { return files ? { ...object, children: files } : object } /** * Mark each object that is hidden with an archived parent — FOL-46. * * An issue whose project is archived is archived with it, and its page says so * rather than drawing live work. The cascade is computed here and never written * to the issue, so unarchiving the project restores it exactly. * * **The whole parent chain, the closest archived file named** (SPEC §6.5, * PEEKM-2): a page under a page under an archived page is archived with the * one nearest it, as a Folder listing reads it ({@link archivedAncestorOf}), so * every app closes the same box and names the same thing to unarchive. One * read per level for the whole set, and only while some file still has a * parent, so a topic page or a project page pays nothing. * * Each ancestor is read with its root, so its archive is folded by SPEC §6.2's * `h` rule like any other change, and its own parent is read by its own kind: * an issue's moved `project`, a page's `parent`. An ancestor whose root this * reader cannot see ends the walk there, as not archived. Stops at a cycle. * * Each ancestor's rule is its own app's: the child's when that app also draws * the ancestor's kind — a project is Ship's as its issue is — otherwise the * ancestor's manifest, read through `cache`. */ async function markArchivedParents( entries: { object: ForeignObject; manifest?: Manifest }[], query: QueryFn, cache?: ProjectionCache, ): Promise { await walkArchivedAbove( entries .filter(({ object: o }) => !o.unreachable && !o.archived && o.parentRef && plainAddress(o.parentRef)) .map(({ object, manifest }) => ({ manifest, at: plainAddress(object.parentRef!)!, seen: new Set([plainAddress(object.address ?? '') ?? '']), mark: (archive: Archive) => { object.archived = archive }, })), query, cache, ) } // An `naddr` parent names the same file as its plain address (FEE-2), and // only the plain one is what a change's target tag and `with` carry. function plainAddress(reference: string) { const pointer = addressPointerOf(reference) return pointer ? `${pointer.kind}:${pointer.pubkey}:${pointer.identifier}` : undefined } /** * {@link markArchivedParents}'s walk, from `at` upwards: `mark` is called with * the closest archived file's archive, `with` its address, and not at all when * none is. `manifest` is the app of the file below `at`, `seen` what is below. */ async function walkArchivedAbove( starts: { at: string; manifest?: Manifest; seen: Set; mark: (archive: Archive) => void }[], query: QueryFn, cache?: ProjectionCache, ): Promise { type Walk = (typeof starts)[number] let walking: Walk[] = starts while (walking.length) { // Each child's ancestor is read by that child's app when it draws the // ancestor's kind — a project is Ship's as its issue is — and by the // ancestor's own manifest otherwise, so one file in a batch never sets the // rule another file's ancestor is read by. const drawnBy: Manifest[] = [] const keyed = walking.map((walk) => { const own = walk.manifest?.projections?.[String(addressPointerOf(walk.at)!.kind)] ? walk.manifest : undefined if (own && !drawnBy.includes(own)) drawnBy.push(own) return { walk, key: `${walk.at}|${own ? drawnBy.indexOf(own) : ''}`, own } }) // A level whose manifest cannot be read ends the walk, its own archive // uncounted (SPEC §6.5): which change archives it is the manifest's to say. // A bare file's is a constant, so a refused opener sweep never ends it. const theirOwn = new Map>() const resolved = await Promise.all( [...new Map(keyed.map((entry) => [entry.key, entry])).values()].map(async ({ key, walk, own }) => { const pointer = addressPointerOf(walk.at)! if (!own && !theirOwn.has(walk.at)) { theirOwn.set( walk.at, resolveManifest(pointer, query, cache).then( (r) => r?.manifest, () => (pointer.kind === KIND_BARE_FILE ? bareFileManifest().manifest : undefined), ), ) } return { key, address: walk.at, pointer, manifest: own ?? (await theirOwn.get(walk.at)) } }), ) const ancestors = resolved.filter((a): a is typeof a & { manifest: Manifest } => !!a.manifest) const rules = ancestors.map(({ manifest }) => archiveRuleOf(manifest)) const addresses = [...new Set(ancestors.map(({ address }) => address))] const roots = new Map() for (const address of addresses) { const pointer = addressPointerOf(address)! const group = `${pointer.kind}:${pointer.pubkey}` const filter = roots.get(group) ?? { kinds: [pointer.kind], authors: [pointer.pubkey], '#d': [] } filter['#d'].push(pointer.identifier) roots.set(group, filter) } const filters = [ ...[...roots.values()].flatMap((filter) => { const groups: Record[] = [] for (let start = 0; start < filter['#d'].length; start += PARENT_ROOT_GROUP) { groups.push({ ...filter, '#d': filter['#d'].slice(start, start + PARENT_ROOT_GROUP) }) } return groups }), ...archiveFilters(addresses, rules), ] const events = await queryChunked(filters, query) const read = new Map() for (const [index, { key, address, pointer, manifest }] of ancestors.entries()) { const root = events .filter((e) => e.kind === pointer.kind && e.pubkey === pointer.pubkey && tagValue(e, 'd') === pointer.identifier) .sort(byOrder) .at(-1) // Not readable here: the walk ends, and nothing above it is claimed. if (!root) continue const parent = manifest?.projections?.[String(pointer.kind)] ? parentRefOf(manifest, pointer.kind, root, foldOf(events, address, manifest, root)) : undefined read.set(key, { archive: archiveOf(events, address, rules[index]!, root), parent, manifest }) } const next: Walk[] = [] for (const { walk, key } of keyed) { const found = read.get(key) if (!found) continue if (found.archive) { walk.mark({ ...found.archive, with: walk.at }) continue } walk.seen.add(walk.at) const parent = found.parent && plainAddress(found.parent) if (parent && !walk.seen.has(parent)) { next.push({ ...walk, manifest: found.manifest, at: parent }) } } walking = next } } /** * Addresses per filter when reading archive changes. A filter's `limit` caps * every change naming its addresses — titles, statuses, moves — not only the * archives, and a relay answers newest first, so a small group keeps the * archive change inside the page. */ const ARCHIVE_READ_GROUP = 25 /** * Identifiers per root filter in {@link resolveParents}. A root read answers * one event per identifier, so it needs no room kept for a page — only a `#d` * list short enough for a relay to take. */ const PARENT_ROOT_GROUP = 100 /** * The filters asking for every change naming these addresses, once per distinct * change shape among `rules` — SPEC's alone unless an app writes its own. */ function archiveFilters( addresses: readonly string[], rules: readonly RecordsRule[] = [ARCHIVE_RULE], groupSize = ARCHIVE_READ_GROUP, ): Record[] { const shapes = new Map(rules.map((rule) => [`${rule.changeKind}:${rule.targetTag}`, rule])) const filters: Record[] = [] for (const rule of shapes.values()) { for (let start = 0; start < addresses.length; start += groupSize) { const group = addresses.slice(start, start + groupSize) filters.push({ kinds: [rule.changeKind], [`#${rule.targetTag}`]: group, limit: RELAY_PAGE_CEILING }) } } return filters } /** Every change naming these addresses, in each of `rules`' shapes, in as few requests as the relay allows. */ async function readArchiveChanges( addresses: readonly string[], query: QueryFn, rules?: readonly RecordsRule[], ): Promise { return queryChunked(archiveFilters(addresses, rules), query) } /** `filters` in as few POSTs as the relay takes, answers pooled. */ async function queryChunked(filters: Record[], query: QueryFn): Promise { const chunks: Record[][] = [] for (let start = 0; start < filters.length; start += MAX_FILTERS_PER_QUERY) { chunks.push(filters.slice(start, start + MAX_FILTERS_PER_QUERY)) } return (await Promise.all(chunks.map((chunk) => query(chunk)))).flat() } /** An `naddr` or a `::`, or null for anything else. */ function addressPointerOf(reference: string): AddressPointer | null { try { // Either form: a body carries `naddr1…`, an `a` tag carries the plain // address, and both name the same object (FEE-2). return referenceToPointer(reference) } catch { return null } } /** * What one addressed object is read with, and what assembling it needs after. * * Split from the read so {@link resolveForeignObjects} can send many objects' * filters in one request and assemble each from the pooled answer, while * {@link resolveForeignObject} sends one object's and assembles it from its * own. Both go through here, so the two cannot drift apart. */ interface ObjectPlan { naddr: string pointer: AddressPointer address: string resolved: ResolvedManifest projection: Projection records: RecordsRule commentKinds: number[] childFilter: ReturnType filters: Record[] openUrl?: string } /** Null when the owning app has no projection for the kind. */ function planObject( naddr: string, pointer: AddressPointer, resolved: ResolvedManifest, depth: number, ): ObjectPlan | null { const { manifest } = resolved const address = pointerToAddress(pointer) // Substituted here rather than in the component: `` is a NIP-89 // detail, and the widget's job is to draw a link, not to know the spec. const openUrl = resolved.webTemplate?.replace('', naddr.replace(/^nostr:/, '')) const commentKinds = commentKindsOf(manifest) const projection = manifest.projections?.[String(pointer.kind)] if (!projection) return null const records = foldRuleOf(manifest) /* One round trip for the root, its changes, its comments **and its children**. `#a` on both the change and the comment kind, because both point at the object by *address* rather than by event id — which is what makes them survive the author replacing the root event. The children used to be a second round trip, issued after the root came back. They never needed to be: the child filter is built from the manifest and the *pointer*, and an addressable event's `d` is `pointer.identifier` by definition — it is what we just queried by. So once the manifest is known, nothing about the child filter depends on the root's contents (SHI-13). That is the difference between two requests per reference per refresh and one, and with the manifest memoised it is the whole cost of a tick. */ const childFilter = childFilterFor({ projection, manifest, pointer, depth }) const filters = [ { kinds: [pointer.kind], authors: [pointer.pubkey], '#d': [pointer.identifier], limit: 1 }, // Only when the app actually declares a change kind — see `foldRuleOf`. ...(manifest.records ? [{ kinds: [manifest.records.changeKind], '#a': [address], limit: 500 }] : []), // Every file may be archived, including one whose app folds nothing // (FOL-46). SPEC's change kind, when the app has not named its own. ...(manifest.records ? [] : [{ kinds: [ARCHIVE_RULE.changeKind], '#a': [address], limit: 500 }]), { kinds: commentKinds, '#a': [address], limit: CONVERSATION_LIMIT }, ...(childFilter ? [childFilter.filter] : []), ...(childFilter?.movedIn ? [childFilter.movedIn] : []), ] return { naddr, pointer, address, resolved, projection, records, commentKinds, childFilter, filters, openUrl } } /** An object and its children, before the people lookup that needs both. */ interface AssembledObject { object: ForeignObject children?: ForeignObject[] } /** * Build a planned object out of whatever the relay answered. * * **Every event is matched on the criteria of the filter that asked for it**, * because the answer may hold more than this object's own events: the root * shares its request with its children (SHI-13), and in * {@link resolveForeignObjects} with every other object in the set. So the root * is matched on its author as well as its kind and `d` — two apps' objects may * share both — and a fold runs only when the manifest declares change events, * since the substitute rule's `changeKind` is a placeholder that was never * asked for (`foldRuleOf`). * * Deduplicated by id first. The relay concatenates what each filter matched * rather than merging it (measured, see `conversationsOf`), so an event two * filters match — a comment naming two files in the set, a `kind:9` that is a * Topic's child and its comment — would otherwise be drawn twice. */ function assembleObject( plan: ObjectPlan, answered: SignedEvent[], childChanges: SignedEvent[] = [], childOwners: Set = new Set(), ): AssembledObject { const { naddr, pointer, address, resolved, projection, commentKinds, childFilter, openUrl } = plan const { manifest, viaRecommendation } = resolved const seen = new Set() const events = answered.filter((e) => !seen.has(e.id) && !!seen.add(e.id)) const root = events.find( (e) => e.kind === pointer.kind && e.pubkey === pointer.pubkey && tagValue(e, 'd') === pointer.identifier, ) if (!root) { // We know which app owns this and how it would be drawn; we just cannot see // the object. Say so rather than returning null and looking like a typo. return { object: { ref: address, address, naddr: naddr.replace(/^nostr:/, ''), // Nothing was read, so there is no event to name. The reference is the // address; that is all this case ever knows. eventId: '', kind: pointer.kind, widget: projection.widget, appName: manifest.name, ...nounOf(projection), slots: {}, meta: [], comments: [], actions: [], viaRecommendation, // Offered even here. "You cannot see this object" is exactly when // somebody wants to open it in the app that can. openUrl, unreachable: true, }, } } const folded = foldOf(events, address, manifest, root) /* Matched on the comment filter's own criteria — the kind **and** the `a` tag. Kind alone was safe while this was its own query: the relay only returned what the comment filter asked for. Now that the children ride in the same request (SHI-13), a child sharing the comment kind would arrive here too and be counted as a comment on its own parent. Peek is exactly that shape: its Topic declares `kind:9` messages as children and `kind:9` as its comment kind, so every message in the Folder would have become a comment on the Topic — a widget silently showing a conversation twice. Then narrowed to §6.4's *comment* strength (CON-15): the `a` tag is also the index of a mention, and a mention is not this object's discussion. See `isCommentOn` (`@estiva-app/conversation`) for the per-kind rule. */ const comments = events .filter((e) => commentKinds.includes(e.kind) && isCommentOn(e, address, commentKinds)) .sort(byOrder) .map((e) => ({ id: e.id, author: e.pubkey, body: e.content, createdAt: e.created_at })) const object = buildObject({ root, pointer, manifest, projection, folded, viaRecommendation, webTemplate: resolved.webTemplate, comments, archived: archiveOf(events, address, archiveRuleOf(manifest), root), }) /* The `list` slot — PRO-7. PRO-2 built this down the *folder* path only, where containment is discovered from a Folder's contents. An object reached by address never resolved it: `rawSlotValue` returns undefined for a `children` spec, so the slot was silently dropped. Measured on production before this: 5 of 5 real Peek Topics rendered a title, a subtitle and no messages — which is exactly what a quiet topic looks like, so nothing reported a problem. A child is resolved through *its own* projection, which is what makes "a card with its children underneath" compose rather than being a special case. A child may be a regular event with no address of its own (Peek's messages are), so this builds by event rather than by pointer. */ const children = childrenFrom({ events, childFilter, manifest, webTemplate: resolved.webTemplate, viaRecommendation, childChanges, childOwners, }) return { object, ...(children ? { children } : {}) } } /** * Who an assembled object names — asked after the object, because who to ask * about is not known until it is built. * * Folded into the same resolve rather than left to the renderer so the * widget's skeleton covers the wait and a key is never briefly on screen. * Children are included so one lookup covers the whole tree — a message list * is mostly other people, and a second trip per child would show a column of * keys while it ran. */ function peopleOf({ object, children }: AssembledObject): string[] { return [...new Set([object, ...(children ?? [])].flatMap(pubkeysIn))] } function withPeople({ object, children }: AssembledObject, people: People): ForeignObject { return { ...object, people, ...(children ? { children: children.map((c) => ({ ...c, people })) } : {}) } } /** * The filter for a projection's `list` slot, or nothing. * * Split out from fetching so it can be built **before** the root event is in * hand and merged into the object's own round trip (SHI-13). Everything it * needs is in the manifest and the pointer: an addressable event's `d` *is* * `pointer.identifier`, since that is what the root filter matches on, so * reading it back off the root taught us nothing we did not already know. * * Returns undefined when no `list` is declared — distinct from a declared list * that matches nothing, which a renderer must be able to tell apart. The two * other "declared but not renderable" cases are folded in here too, and both * come back as `[]` from {@link childrenFrom}: a depth budget already spent, * and a child kind the manifest never says how to draw. */ /** * The child list a projection declares, shape only. * * Split out so {@link childFilterFor} and {@link ForeignObject.listsChildren} * read the *same* declaration. They answer different questions — "what should I * fetch" and "is a disclosure control honest" — and a consumer that offered an * expander where nothing could ever be fetched would be the second kind of lie * this module keeps eliminating. */ function declaredChildSpec(projection: { slots: Record }): { kind: number; via: string; limit?: number; match?: string; movedBy?: string; anyParent?: boolean } | undefined { const spec = projection.slots.list return !Array.isArray(spec) ? spec?.children : undefined } /** Whether the owning app also says how to *draw* that kind. */ function drawsKind(manifest: Manifest, kind: number): boolean { return !!manifest.projections?.[String(kind)] } /** * The parent this file names, per whichever projection claims it as a child. * * Read off the *parent kind's* declaration rather than the child's, because * that is where the relation lives: Ship's project says * `list: { children: { kind: 30851, via: 'a' } }`, and nothing on the issue's * own projection mentions a project at all. * * An issue carries several `a` tags in general, so the value is matched on the * parent kind's prefix rather than taken positionally — the same reason * `conversationCountsOf` reads every `a` tag instead of the first. */ function parentRefOf( manifest: Manifest, kind: number, root: SignedEvent, folded: Record = {}, ): string | undefined { /* A bare file names its own parent — one `a` tag, any kind — and that is the other direction from everything below, where the *parent's* projection says which tag on a child points at it. Both are needed: the declared direction lets Ship's project list its issues without an issue knowing what a project is; this one lets a topic sit under a project, an issue, or a kind nobody has met, without that kind's owner declaring bare files as children. Re-parenting is a change to the declared `movedBy` field, seeded by the root tag — `parent` on a bare file, `project` on a Ship issue (SPEC §7.2). */ const movedBy = movedByOf(manifest, kind) if (movedBy) { // A folded empty value is a move to the top, not an absence: falling back // to the root tag would put the file straight back under the parent it // was moved out of (FOL-4). // A value that is not an address of the declared parent kind names nothing // this object can sit under, so it is a move to nowhere rather than a ref a // consumer might try to fetch. const moved = folded[movedBy.field] if (moved) { const prefix = movedBy.parentKind === undefined ? /^\d+:[0-9a-f]{64}:/ : new RegExp(`^${movedBy.parentKind}:[0-9a-f]{64}:`) return prefix.test(moved.value) ? moved.value : undefined } } const own = ownParentTagOf(manifest, kind) if (own) { return root.tags.find((t) => t[0] === own && t[1] && /^\d+:[0-9a-f]{64}:/.test(t[1]))?.[1] } for (const [parentKind, projection] of Object.entries(manifest.projections ?? {})) { const children = declaredChildSpec(projection as { slots: Record }) if (!children || children.kind !== kind) continue /* `match: 'identifier'` names the parent's `d` and not its address. Building one would mean assuming the parent shares the child's pubkey, which is the kind of guess this module refuses elsewhere — so it stays absent. */ if (children.match === 'identifier') continue const found = root.tags .filter((t) => t[0] === children.via && t[1]) .map((t) => t[1]) .find((value) => value.startsWith(`${parentKind}:`)) if (found) return found } return undefined } /** * The field that moves a `kind` object to another parent, and the kind of * parent it names — SPEC §7.2's `movedBy`, read off whichever projection lists * `kind` as its children (MAN-1). * * `parentKind` is absent for a bare file and for any kind a list declares with * `anyParent` (a document): each may sit under a file of any kind (§6.7), so * the list naming it is not a limit on where it may go. * Absent too for `match: 'identifier'`, where `movedBy` is ignored because a * move's value is an address and that list compares identifiers. */ function movedByOf(manifest: Manifest, kind: number): { field: string; parentKind?: number } | undefined { for (const [parentKind, projection] of Object.entries(manifest.projections ?? {})) { const children = declaredChildSpec(projection as { slots: Record }) if (!children || children.kind !== kind || !children.movedBy || children.match === 'identifier') continue // Any list declaring `anyParent` for the kind lifts the limit, so this and // `ownParentTagOf` never disagree about where the kind may sit. return ownParentTagOf(manifest, kind) !== undefined ? { field: children.movedBy } : { field: children.movedBy, parentKind: Number(parentKind) } } return undefined } /** * The tag a `kind` object names its own parent with, of any kind — `a` on a * bare file (SPEC §6.7), and the `via` of any list declaring the kind with * `anyParent` (a document's, 2026-10-14). Undefined for every other kind, * whose parent is found from the parent's declared list instead. */ function ownParentTagOf(manifest: Manifest, kind: number): string | undefined { if (kind === KIND_BARE_FILE) return 'a' for (const projection of Object.values(manifest.projections ?? {})) { const children = declaredChildSpec(projection as { slots: Record }) // Strictly `true`: nothing validates a published manifest, and "false" is truthy. if (children?.kind === kind && children.anyParent === true && children.match !== 'identifier') return children.via } return undefined } /** {@link movedByOf} as the two `ForeignObject` fields, both builders' shape. */ function parentFieldsOf(manifest: Manifest, kind: number): { parentField?: string; parentKind?: number } { const moved = movedByOf(manifest, kind) if (!moved) return {} return moved.parentKind === undefined ? { parentField: moved.field } : { parentField: moved.field, parentKind: moved.parentKind } } /** Both halves of the question a disclosure control asks. */ function listsChildrenOf( projection: { slots: Record }, manifest: Manifest, ): boolean { const children = declaredChildSpec(projection) return !!children && drawsKind(manifest, children.kind) } function childFilterFor(args: { projection: Projection manifest: Manifest pointer: AddressPointer depth: number }): | { filter: Record; kind: number; via: string; parent: string; movedBy?: string; movedIn?: Record } | null | undefined { const { projection, manifest, pointer, depth } = args const children = declaredChildSpec(projection) if (!children) return undefined // The consumer's budget, not the manifest's — see MAX_LIST_DEPTH. if (depth >= MAX_LIST_DEPTH) return null // A declared list whose child kind has no projection is not renderable, and // an empty list is the honest answer: the objects exist, this app has not // said how to draw them. if (!drawsKind(manifest, children.kind)) return null const parent = children.match === 'identifier' ? pointer.identifier : pointerToAddress({ ...pointer, relays: [] }) const movedBy = children.movedBy && children.match !== 'identifier' ? children.movedBy : undefined return { filter: { kinds: [children.kind], [`#${children.via}`]: [parent], limit: children.limit ?? 100 }, kind: children.kind, via: children.via, parent, // This list's own, not whichever projection declares one for the kind — and // none on an identifier list, where a move's address never names the parent. ...(movedBy ? { movedBy } : {}), // The children moved *in*: the moves naming this parent by `MOVED_TO_TAG` // (SPEC §7.2, FOL-45). A `#via` read cannot see them — their parent is in a // `value` — so without this a child moved here was drawn under nothing. ...(movedBy && manifest.records ? { movedIn: { kinds: [manifest.records.changeKind], [`#${MOVED_TO_TAG}`]: [parent], limit: children.limit ?? 100 } } : {}), } } /** * The addresses of children a pooled answer says were moved in to this parent: * the target of every change naming it by `MOVED_TO_TAG`, of the child kind. * * **A hint, never the answer.** A later move away names the *new* parent, not * this one, so a change found here may be stale; the child's own fold decides * (`childrenFrom`), exactly as it decides for a child created here. */ function movedInAddressesOf( events: SignedEvent[], childFilter: NonNullable>, records: Manifest['records'], ): string[] { if (!childFilter.movedIn || !records) return [] const prefix = `${childFilter.kind}:` const targets = events .filter((e) => e.kind === records.changeKind && hasTagValue(e, MOVED_TO_TAG, childFilter.parent)) .map((e) => tagValue(e, records.targetTag)) .filter((address): address is string => !!address && address.startsWith(prefix)) return [...new Set(targets)] } /** * The child objects, picked back out of the merged result set. * * **Matched on the filter's own criteria, never on kind alone.** Peek's Topic * declares `kind:9` messages as its children and `kind:9` as its comment kind, * so a merged response carries both under one number and only the tag tells * them apart. An event can honestly be both — Ship writes a `kind:9` with an * `a` naming the object *and* an `h` naming the Folder — and it appeared in * both result sets when these were two queries. Re-applying each filter's own * predicate reproduces that, rather than making them compete. */ function childrenFrom(args: { events: SignedEvent[] childFilter: ReturnType manifest: Manifest webTemplate?: string viaRecommendation: boolean /** What {@link childChangeFilters} asked for; empty when it asked nothing. */ childChanges?: SignedEvent[] /** What {@link childOwnersOf} answered: the authors whose children carry actions. */ childOwners?: Set }): ForeignObject[] | undefined { const { events, childFilter, manifest, webTemplate, viaRecommendation, childChanges = [], childOwners = new Set() } = args if (childFilter === undefined) return undefined if (childFilter === null) return [] const childProjection = manifest.projections?.[String(childFilter.kind)] if (!childProjection) return [] const records = foldRuleOf(manifest) const { movedBy } = childFilter const created = childRootsOf(events, childFilter) const createdAddresses = new Set(created.map(childAddressOf)) // Moved in: the roots `childChangeFilters` read for the moves naming this parent, // newest version of each, and only those not already found as created here. const movedIn = new Set(movedInAddressesOf(events, childFilter, manifest.records).filter((a) => !createdAddresses.has(a))) const movedInRoots = new Map() for (const event of childChanges) { const address = event.kind === childFilter.kind ? childAddressOf(event) : undefined if (!address || !movedIn.has(address)) continue const current = movedInRoots.get(address) if (!current || event.created_at > current.created_at) movedInRoots.set(address, event) } return [...created, ...[...movedInRoots.values()].sort(byOrder)].flatMap((event) => { const address = childAddressOf(event) /* The child's own fold — MAN-7. Until this, a card's children were built from their roots alone, so a moved issue was drawn under the project it was created in, and its status was whatever the root said at creation. Folded as the object itself is (`foldOf`, the first target tag), and dropped for the reasons the Folder listing drops a file: - **moved away** — SPEC §7.2: a `#via` read finds the children created here, including the ones moved since, and a consumer MUST drop a child whose folded `movedBy` names another parent. An empty value, or one that names no parent at all, is not this parent either. Only a folded value counts: without one the `via` tag is the parent, and that is what found the child. - **hidden** by the app's own `hiddenWhen`, or **archived** (FOL-46). - **moved in** and since moved on, or never really here — FOL-45. A child found by its move's `MOVED_TO_TAG` has no `via` naming this parent, so only a folded value that names it keeps it: the tag is how it was found, not proof that it is still here. */ const folded = address ? foldOf(childChanges, address, manifest, event) : {} if (movedBy && folded[movedBy] && folded[movedBy].value !== childFilter.parent) return [] if (address && movedIn.has(address) && folded[movedBy!]?.value !== childFilter.parent) return [] if (records.hiddenWhen && folded[records.hiddenWhen.field]?.value === records.hiddenWhen.equals) return [] if (address && manifest.records && archiveOf(childChanges, address, records, event)) return [] return [ buildChildObject({ root: event, manifest, projection: childProjection, records, webTemplate, viaRecommendation, folded, ownsKind: childOwners.has(event.pubkey), }), ] }) } /** * The authors of a card's children whose kind the card's own app owns — the * only children that carry actions (LTX-2). * * A child is drawn through its parent's manifest, and that need not be the * manifest its kind resolves to: an outside app may list Ship's issues under a * kind of its own and declare text actions on them that Ship keeps to itself. * Offered on the child, they would tell Leaf it may write a field Ship never * shared, with no `declaredBy` to say whose they are. So a child carries * actions only when its kind, resolved for its author as a file read on its * own is, lands on the parent's manifest. Per author, because an author's * recommendation is part of that answer, and through the cache, so a project's * issues by three people cost three lookups once. Nothing is asked when the * parent's app declares no action on the child's kind, and only for authors of * roots that came back: a move naming the parent is anyone's to write, and the * addresses it lists would otherwise cost a lookup each, root or not. */ async function childOwnersOf( plan: ObjectPlan, answered: SignedEvent[], childChanges: SignedEvent[], query: QueryFn, cache?: ProjectionCache, ): Promise> { const { childFilter, resolved } = plan if (!childFilter || resolveActions(resolved.manifest, childFilter.kind, {}).length === 0) return new Set() const movedIn = new Set(movedInAddressesOf(answered, childFilter, resolved.manifest.records)) const authors = new Set([ ...childRootsOf(answered, childFilter).map((e) => e.pubkey), ...childChanges .filter((e) => e.kind === childFilter.kind && movedIn.has(childAddressOf(e) ?? '')) .map((e) => e.pubkey), ]) const owners = await Promise.all( [...authors].map(async (pubkey) => { const owner = await resolveManifest({ kind: childFilter.kind, pubkey, identifier: '', relays: [] }, query, cache).catch( () => null, ) return owner?.address === resolved.address ? [pubkey] : [] }), ) return new Set(owners.flat()) } /** The child roots a `list` filter asked for, picked back out of a pooled answer on its own criteria. */ function childRootsOf(events: SignedEvent[], childFilter: NonNullable>): SignedEvent[] { return events .filter((e) => e.kind === childFilter.kind && hasTagValue(e, childFilter.via, childFilter.parent)) .sort(byOrder) } /** A child's address, or undefined for a regular event, which has none. */ function childAddressOf(event: SignedEvent): string | undefined { const identifier = tagValue(event, 'd') return identifier === undefined ? undefined : pointerToAddress({ kind: event.kind, pubkey: event.pubkey, identifier, relays: [] }) } /** * The second read a card's children need, or none — MAN-7. * * A child's changes name the child's address, which is not known until the * children are back, so this cannot ride in the object's own request the way * the children do (SHI-13). It is asked **only when there is a fold to read**: * the app declares change events (`records`) and the list holds at least one * addressable child. Peek's Topic lists `kind:9` messages, which nothing can * change by address, so a Topic card stays one request; a Ship project card * with issues is two. * * The alternative on the ticket — folding from a cached Folder read — was not * taken: interop's caches are the caller's (`ProjectionCache` holds manifests, * never objects), a card is pasted into Folders other than its own, and a stale * cached fold is the moved-issue bug again with a delay on it. * * Grouped as the archive reads are ({@link archiveFilters}), but smaller: a * filter's `limit` covers every change naming every address in it, and a relay * answers newest first, so a group whose changes overflow the page loses its * *oldest* — and a move made long ago is exactly what must not fall off. Ten * issues to a page of 1,000 is a hundred changes each; a card of 200 is twenty * filters, still one POST. * * Nothing when the root did not come back: the object is unreachable and its * children are never drawn, so their changes would be a request for nothing. */ function childChangeFilters(plan: ObjectPlan, answered: SignedEvent[]): Record[] { const { childFilter, resolved, pointer } = plan const records = resolved.manifest.records if (!childFilter || !records) return [] if (!answered.some((e) => e.kind === pointer.kind && e.pubkey === pointer.pubkey && tagValue(e, 'd') === pointer.identifier)) { return [] } const created = childRootsOf(answered, childFilter).flatMap((e) => childAddressOf(e) ?? []) // Moved in (FOL-45): found by their moves, so their roots are not in the answer // yet. They are read here with the changes, in the same POST, and folded the same. const movedIn = movedInAddressesOf(answered, childFilter, records).filter((address) => !created.includes(address)) const addresses = [...new Set([...created, ...movedIn])] const roots = new Map() for (const address of movedIn) { // A `d` may itself hold ':' — everything after the pubkey is the identifier. const [, pubkey, ...rest] = address.split(':') roots.set(pubkey, [...(roots.get(pubkey) ?? []), rest.join(':')]) } return [ ...(addresses.length ? archiveFilters(addresses, [records], CHILD_CHANGE_GROUP) : []), ...[...roots].map(([pubkey, identifiers]) => ({ kinds: [childFilter.kind], authors: [pubkey], '#d': identifiers, limit: identifiers.length })), ] } /** Addresses per filter when reading a card's children's changes — see {@link childChangeFilters}. */ const CHILD_CHANGE_GROUP = 10 /** * Build a `ForeignObject` from an event that may have no address of its own. * * `buildObject` takes an `AddressPointer` and assumes one exists. A `kind:9` * has no `d` tag, so there is nothing to point at — its only handle is its * event id. That is the whole of PRO-6's finding (6), and it is why `ref` and * `eventId` exist alongside `address`. * * A regular event carries **no actions**, and that is correct rather than a * limitation: an action emits a change carrying an `a` tag naming what it * changes, and a regular event cannot be named that way. An addressable child — * a Ship issue read under its project — carries the actions its own app * declares for its kind, as it does when read on its own, so whether its app * shares its text is the same answer either way (LTX-2) — when the parent's app * is that app; listed by another, it carries none (`childOwnersOf`). Borrowed creations * (`declaredBy`) are added only on a file read on its own. */ function buildChildObject(args: { root: SignedEvent manifest: Manifest projection: Projection records: RecordsRule webTemplate?: string viaRecommendation: boolean /** * The `nevent1…` for a non-addressable object, when the caller has one. * * Only `resolveForeignEvent` passes it: a child reached through a `list` slot * is drawn inside its parent and has nowhere of its own to open, while an * event somebody referenced directly does. Without it a message has no * `openUrl` at all, which is PRO-11's "its web link opens that message". */ nevent?: string /** * The child's own fold, when {@link childChangeFilters} read its changes * (MAN-7). Empty for a regular event, which nothing can change by address. */ folded?: Record /** * Whether `manifest` is the one this child's kind resolves to for its author, * as {@link childOwnersOf} found. Its actions are offered only then. */ ownsKind?: boolean }): ForeignObject { const { root, manifest, projection, webTemplate, viaRecommendation, nevent, folded = {}, ownsKind = false } = args const identifier = tagValue(root, 'd') const addressable = identifier !== undefined const pointer: AddressPointer = { kind: root.kind, pubkey: root.pubkey, identifier: identifier ?? '', relays: [], } const address = addressable ? pointerToAddress(pointer) : undefined const naddr = addressable ? encodeNaddr(pointer) : undefined const { slots, meta } = resolveSlots(projection, root, folded, manifest) return { // A regular event's identity is its id; a replaceable one's is its address, // which survives the author replacing the event. ref: address ?? root.id, address, naddr, eventId: root.id, kind: root.kind, widget: projection.widget, appName: manifest.name, ...nounOf(projection), slots, meta, comments: [], listsChildren: listsChildrenOf(projection, manifest), // Same field in both builders: a consumer must not get it on a file read one // way and not the other. A child already knows its parent contextually, but // an inconsistent shape is what makes a consumer defensive about a value it // should be able to trust. parentRef: parentRefOf(manifest, root.kind, root, folded), ...parentFieldsOf(manifest, root.kind), folder: writeFolderOf(root), scopedTo: tagValue(root, 'h'), /* `` is whichever form this object actually has. NIP-89's template says nothing about which NIP-19 entity it will be handed — Ship's declares `naddr` in its own tag because every Ship object is addressable, and a template for an app with non-addressable objects is handed an `nevent`. Substituting the one the object *has* is what makes a single template serve both, and what stops a message linking to nothing. */ openUrl: (naddr ?? nevent) ? webTemplate?.replace('', (naddr ?? nevent) as string) : undefined, // See the note above: nothing can be declared to act on a regular event, and an event read by its id // (`nevent`) may be an older version of its file, its changes unread, so it is offered none either. // A child drawn through an app that does not own its kind gets none: they are not its owner's (`childOwnersOf`). actions: addressable && !nevent && ownsKind ? actionsOfFile(manifest, projection, root, folded) : [], viaRecommendation, changedAt: changedAtOf(root, folded), } } // ── A Folder's project, for the topic sidebar (PEEK-24) ───────────────────── /** * A Folder's project and the tickets in motion in it. * * Both are ordinary `ForeignObject`s — the same shape an `naddr` in a message * resolves to, carrying the same slots, the same declared actions and the same * link back into the owning app. The sidebar therefore renders the owning app's * objects with the owning app's affordances rather than a reduced copy of them, * and a ticket is as actionable there as it is inline in a conversation. */ export interface FolderProject { project: ForeignObject /** * Every ticket in the project, finished or not: started work first, then the * queue, then what is done. Archived ones are left out — an app hiding a * record from its own lists is saying it is no longer part of the project. */ tickets: ForeignObject[] /** Tickets still to do. With `doneCount`, the total the panel counts against. */ openCount: number /** Finished tickets — the numerator in the panel's "1/3". */ doneCount: number } /** * What a status *means*. **The owning app's declaration, with a fallback.** * * *Rewritten by PRO-2.* This used to open "Peek's editorial rule, not the * owning app's", on the grounds that a vocabulary entry was `{value, label, * colour}` and said nothing about whether work was open or finished. That was * true, and treating it as an editorial position was the mistake: Peek is not * the expert on what a Ship status means. Ship is. * * A vocabulary entry now carries `stage` — `open | started | done | dropped` — * and that is read first. The word lists below survive only as the * compatibility path for a manifest published before the field existed, which * cannot be given one retroactively (the `emits.alsoRead` reason, one level * up). * * The old comment named the cost of guessing and called it acceptable: *"an app * whose statuses are spelled differently shows an empty section until its * values are added here. That is the failure worth having."* For a layer whose * purpose is making the third app cheap to build, it is not — the third app * ships, its words are not in the set, and its panel renders blank, which is * PEE-10's failure exactly. A declared stage is what removes the guess. * * Two named sets rather than one allowlist and an "everything else": the panel * reads "1/3", done over the two sets added together, and a status that is * neither should land in neither. A parked "Backlog" or "Triage" is real work * nobody is doing and it would inflate the total into meaninglessness; an * unrecognised status some other app invented is not evidence of anything. * * Leaving both out is also what keeps "3/3" reachable. A cancelled ticket is * not outstanding and never becomes done, so counting it in the total would * leave a finished project stuck at 3/4 for good. * * The visible cost: with something cancelled, the total is smaller than the * number of rows listed below it. The count is about progress through the work, * not about how long the list is, and a total nothing can ever complete is the * worse of the two. * * The other cost is honest: an app whose statuses are spelled differently shows * an empty section until its values are added here. That is the failure worth * having — the alternative is a count that quietly includes work nobody is on. */ const OPEN_STATUSES = new Set([ 'todo', 'to do', 'in progress', 'started', 'doing', 'in review', 'review', ]) const DONE_STATUSES = new Set([ 'done', 'completed', 'complete', 'closed', 'cancelled', 'canceled', 'duplicate', ]) /** `in_progress`, `In-Progress` and `In Progress` are the same status. */ const normalizeStatus = (value: string) => value.trim().toLowerCase().replace(/[-_\s]+/g, ' ') /** * Match on either what the object *says* or what a reader *sees* — the raw * value (`in_progress`) or the vocabulary label ("In Progress"). Apps disagree * about which of the two is the human-readable one, and checking both costs * nothing. */ const inSet = (set: Set) => (raw: string | undefined, label: string | undefined) => [raw, label].some((v) => v !== undefined && set.has(normalizeStatus(v))) const isOpenStatusByLabel = inSet(OPEN_STATUSES) const isDoneStatusByLabel = inSet(DONE_STATUSES) /** The four stages a manifest may declare. Anything else is not a stage. */ type Stage = 'open' | 'started' | 'done' | 'dropped' const STAGES = new Set(['open', 'started', 'done', 'dropped']) /** * The stage the owning app declares for a status value, or undefined. * * `undefined` means "this manifest does not say", which is a different fact * from "this status is not progress" and must not be collapsed into one — the * caller falls back to the word lists only in the first case. * * A `stage` outside the four is ignored rather than trusted. Validation is an * honour system (RFC 0.4 §13.4) and this is a consumer reading another app's * self-description; an unrecognised value is treated as undeclared, which * degrades to the fallback instead of inventing a fifth stage. */ function declaredStage( manifest: Manifest, spec: SlotSpec | undefined, raw: string | undefined, ): Stage | undefined { if (!spec?.map || raw === undefined) return undefined const stage = manifest.vocabularies?.[spec.map]?.find((v) => v.value === raw)?.stage return stage && STAGES.has(stage as Stage) ? (stage as Stage) : undefined } /** * Is this status finished work? * * `dropped` counts as done rather than open, which is what keeps "3/3" * reachable: a cancelled ticket is never going to become done, so leaving it * outstanding pins a finished project below its total for ever. It is not * *progress* either, and an app that wanted to draw that distinction now can — * the stage is on the wire and this fold is the consumer's, not the protocol's. */ function isDone(stage: Stage | undefined, raw: string | undefined, label: string | undefined) { if (stage) return stage === 'done' || stage === 'dropped' return isDoneStatusByLabel(raw, label) } /** * Is a resolved `status` slot finished work — done or dropped — by its words? * * For a consumer holding only the object, not its manifest: the declared stage * is not on a {@link ResolvedSlot}, so this is the word-list fallback alone. * `[` uses it to put a closed issue after an open one (PEE-21). */ export function isClosedStatus(slot: ResolvedSlot | undefined): boolean { return slot !== undefined && isDoneStatusByLabel(slot.value, slot.label) } function isOpen(stage: Stage | undefined, raw: string | undefined, label: string | undefined) { if (stage) return stage === 'open' || stage === 'started' return isOpenStatusByLabel(raw, label) } /** * Started work sits above the queue. * * Both halves are open, but "in progress" is what somebody opened the panel to * find; ordering by activity alone would bury it under whatever was filed most * recently. */ const STARTED = new Set(['in progress', 'started', 'doing', 'in review', 'review']) const isStartedByLabel = inSet(STARTED) function isStarted(stage: Stage | undefined, raw: string | undefined, label: string | undefined) { if (stage) return stage === 'started' return isStartedByLabel(raw, label) } /** * The containment relation a manifest declares: "this kind holds that kind". * * Read from the `list` slot, which says it outright, falling back to the * inference this used to depend on. Returns null when the app says neither — in * which case Peek does not guess, and the sidebar shows nothing. */ function containmentFor( manifest: Manifest, containerKind: number, ): { childKind: number; linkTag: string; limit?: number; match?: 'address' | 'identifier' } | null { /* The declared answer first — PRO-2. A `list` slot says outright which kind this one holds and by which tag. What follows below is the older path, and it is worth naming what it does: it reads an action that *creates* a child (`toAddressOf: "self"`) and infers a *read* relationship from it. That worked, and it was a deduction from a write declaration — an app offering no create-action, or offering one for a kind it does not actually contain, was invisible or wrong respectively. Kept as a compatibility path rather than deleted, because a manifest published before PRO-2 cannot be given a `list` slot retroactively and consumers upgrade before producers do. It goes when nothing in use relies on it. */ const projection = manifest.projections?.[String(containerKind)] const listSlot = projection && !Array.isArray(projection.slots.list) ? projection.slots.list : undefined if (listSlot?.children) { const { kind, via, limit, match } = listSlot.children return { childKind: kind, linkTag: via, limit, match } } for (const action of manifest.actions ?? []) { if (action.emits.toAddressOf !== 'self') continue if (!asArray(action.appliesTo).includes(String(containerKind))) continue return { childKind: action.emits.kind, linkTag: action.emits.setTag ?? 'a' } } return null } /** * How deep a consumer will follow `list` slots. **The consumer's budget.** * * A child rendered through its own projection may declare a `list` of its own, * so resolution is recursive and something has to stop it. That something is * here rather than in the manifest: the app at risk of the render loop is the * one drawing it, and a producer able to set this number could hang any * consumer that trusted it (RFC 0.4 §13.4 — validation is an honour system). * * One level is what the panel needs: a project and its issues. Raising it is a * decision about this app's boot cost, not about anyone else's data. */ const MAX_LIST_DEPTH = 1 /** * The project living in a Folder, plus the work currently in motion in it. * * Starts from a **Folder** rather than an naddr, which is the difference * between this and `resolveForeignObject`: a Peek topic and a Linear-lite * project are one container (RFC_UPDATES.md §1.1), so a topic already knows * enough to ask "what project is this?" without anyone pasting a reference. * * Still knows nothing about Linear-lite. Which kind is a project, which kind is * an issue, how they link, how status folds and what it is called all come off * published manifests at runtime — the same discipline as the rest of this file. * * Returns null for "nothing to show": no manifest declares a container, no * project in this Folder and none named by the tickets in it, or the object * cannot be read. All of those are ordinary states the caller renders as an * empty sidebar, not errors. */ export async function resolveFolderProject( folder: string, query: QueryFn, /** Defaults to asking the relay. The browser passes a cached lookup. */ lookupPeople?: PeopleFn, /** * How many `list` levels have already been followed to get here. Callers * outside this module leave it at 0; it exists so that following a child's * own `list` is a budget check rather than a thing nobody remembered. */ depth = 0, /** See {@link ProjectionCache}. Omitting it is exactly the old behaviour. */ cache?: ProjectionCache, ): Promise { // The render loop this forbids is not hypothetical: two folders naming each // other's projects resolve forever, and the manifest declaring them is // another app's. See MAX_LIST_DEPTH. if (depth > MAX_LIST_DEPTH) return null // 1. Which kinds are containers? A Folder query needs kind numbers up front, // and the only non-app-specific source for them is the published handlers. // This pass is discovery only — the manifest that actually *renders* the // project is resolved below, recommendation-first, once its author is known. const handlerFilter = [{ kinds: [KIND_HANDLER_INFORMATION], limit: 20 }] const handlers = await query(handlerFilter) const containers = new Map() let discovered: Manifest | undefined for (const event of handlers) { const manifest = parseManifest(event) for (const kind of Object.keys(manifest?.projections ?? {})) { const relation = manifest && containmentFor(manifest, Number(kind)) if (relation) { containers.set(Number(kind), relation) discovered ??= manifest } } } if (containers.size === 0) { return null } // 2. One round trip for everything in the Folder: containers, their children, // and the changes that have landed on either — `h` is what they share. // Changes are needed *before* a project is chosen, because whether one is // archived is itself a folded field. const childKinds = [...new Set([...containers.values()].map((c) => c.childKind))] const discoveredRule = discovered?.records const inFolderRaw = await query([ { '#h': [folder], kinds: [...containers.keys(), ...childKinds], limit: 200 }, ...(discoveredRule ? [{ '#h': [folder], kinds: [discoveredRule.changeKind], limit: 500 }] : []), ]) /* **A Folder is not a file inside itself.** A channel's own `kind:39000` comes back from an `#h` query for that channel. It carries no `h` tag — the relay scopes a discovery event to the channel it describes, so it arrives with the contents. What marks it out is that its `d` *is* the folder uuid; a file sitting in a Folder has its own uuid, different from the Folder's. Dropped here, once, rather than at each use. Two places downstream ask "does this Folder hold a container", and they have to agree: `holdsContainer` decides whether to go looking for a project the Folder does not hold, and `roots` decides which container is the subject. Filtering only the second makes them disagree and the panel resolves to nothing at all. Until Peek published a manifest this could not bite, because only Ship's kinds were containers and a `39000` was never a candidate. PRO-6 declared Topic a container — it holds messages — so the Topic became a candidate, won the contest against the actual project, and the sidebar listed the *messages* as tickets. Each was titled with its author's pubkey, because a message resolved through Peek's own projection has `title: {field: "pubkey"}`. App-neutral on purpose: this is a fact about folders and discovery events, not about Peek. */ const inFolder = inFolderRaw.filter((e) => tagValue(e, 'd') !== folder) const addressOfEvent = (event: SignedEvent) => pointerToAddress({ kind: event.kind, pubkey: event.pubkey, identifier: tagValue(event, 'd') ?? '', relays: [], }) /** * 2b. A Folder can hold a project's *work* without holding the project. * * An event cannot change its own `h`. So a project created in one Folder and * later paired with a Peek topic keeps the `h` it was born with, and the * pairing is expressed the only way an append-only record can express it — a * change event. Ship published that change only in the Folder the *record* * lives in, so nothing carrying the topic's `h` named the project at all: the * query above saw a Folder full of tickets and no project, and the panel said * "no project linked" about a Folder holding eleven of its issues (PEEK-24). * * A consumer cannot require the producer to have got that right, and this one * must keep working against the events already on the relay, so it reads * whatever the Folder does hold. * * Two things in the Folder can name it, and both use a tag the manifest has * already declared rather than any new vocabulary. * * A **ticket** names its project in the containment relation's link tag, * which is the same tag step 5 reads to decide which tickets belong here. A * Folder whose tickets all point at one project *is* that project's Folder, * whichever Folder the project record happens to sit in. * * A **change published into this Folder** names its target in the records * rule's target tag. That is an app saying out loud "the object at this * address belongs here" — the only way to say it when the object's own `h` * cannot be moved, and the one that does not need a ticket to exist yet. * Estiva Ship writes it when a project is paired with a topic. * * Reading both matters, because they fail in opposite directions: the tickets * cover a Folder paired before anybody wrote the statement, and the statement * covers a Folder paired before anybody filed a ticket. Where they disagree * the sort below decides, and it already prefers whichever candidate the * Folder's work actually belongs to. * * Its changes come by address rather than by `h`, because they are wherever * the record is, not here. Skipped entirely when the Folder does hold a * container, which is the ordinary case and already correct. */ const holdsContainer = inFolder.some((e) => containers.has(e.kind)) const namedInFolder = holdsContainer ? [] : [ ...new Set( inFolder.flatMap((event) => { const relation = [...containers.values()].find((c) => c.childKind === event.kind) if (relation) { const link = tagValue(event, relation.linkTag) return link ? [link] : [] } if (discoveredRule && event.kind === discoveredRule.changeKind) { const target = tagValue(event, discoveredRule.targetTag) return target ? [target] : [] } return [] }), ), ] // Only a container may be adopted this way. A child's link tag can name // anything, and a ticket that points at another ticket must not become the // subject of the panel. const linkedOutward = namedInFolder.flatMap((link) => { try { const pointer = referenceToPointer(link) return containers.has(pointer.kind) ? [{ link, pointer }] : [] } catch { return [] } }) const adopted = linkedOutward.length ? await query([ ...linkedOutward.map(({ pointer }) => ({ kinds: [pointer.kind], authors: [pointer.pubkey], '#d': [pointer.identifier], limit: 1, })), ...(discoveredRule ? [ { kinds: [discoveredRule.changeKind], '#a': linkedOutward.map(({ link }) => link), limit: 500, }, ] : []), ]) : [] /** Everything the Folder holds, plus whatever it was adopted from. */ const known = adopted.length ? [...inFolder, ...adopted] : inFolder /** Is this object one its owning app would keep out of its own lists? */ const isHidden = (event: SignedEvent, rule: RecordsRule | undefined, source = known) => { if (!rule?.hiddenWhen) return false const applied = source.filter( (e) => e.kind === rule.changeKind && tagValue(e, rule.targetTag) === addressOfEvent(event), ) return foldChanges(applied, rule, event)[rule.hiddenWhen.field]?.value === rule.hiddenWhen.equals } const roots = known .filter((e) => containers.has(e.kind)) .filter((e) => !isHidden(e, discoveredRule)) if (roots.length === 0) { return null } /** * Which project, when a Folder holds several. * * Nothing forbids more than one, and pairing makes it common rather than * exotic: a topic accumulates a project per attempt. Ranking by age alone * picked whichever was created last — in practice a stray — so the work * decides instead. The project the Folder's tickets point at is the project * the Folder is about; age only breaks the tie. */ const ticketsFor = (candidate: SignedEvent) => { const relation = containers.get(candidate.kind)! const candidateAddress = addressOfEvent(candidate) return inFolder.filter( (e) => e.kind === relation.childKind && tagValue(e, relation.linkTag) === candidateAddress, ).length } const root = [...roots].sort( (a, b) => ticketsFor(b) - ticketsFor(a) || b.created_at - a.created_at, )[0] const pointer: AddressPointer = { kind: root.kind, pubkey: root.pubkey, identifier: tagValue(root, 'd') ?? '', relays: [], } const address = pointerToAddress(pointer) // 3. Now the authoritative manifest — the object's author gets to say which // app renders their project (kind:31989), same as for an inline reference. const resolved = await resolveManifest(pointer, query, cache) if (!resolved) { return null } const { manifest, viaRecommendation } = resolved const projection = manifest.projections?.[String(root.kind)] if (!projection) { return null } const records = foldRuleOf(manifest) const relation = containmentFor(manifest, root.kind) ?? containers.get(root.kind)! // 4. Every change in the Folder, folded per object. Already fetched in step 2 // unless the authoritative manifest orders records differently from the one // discovery happened to find, which is the only case worth a second trip. // That trip asks by address as well as by Folder: an adopted project's own // changes are not in this Folder, and a `#h` query alone would fold it // without them and report the defaults as its state. const changes = !manifest.records ? // The app declares no change kind, so there is nothing to ask for. Not a // filter that matches nothing — a kind is `u16` and the relay refuses an // out-of-range one outright. See `foldRuleOf`. [] : discoveredRule?.changeKind === records.changeKind ? known.filter((e) => e.kind === records.changeKind) : await query([ { kinds: [records.changeKind], '#h': [folder], limit: 500 }, ...(adopted.length ? [{ kinds: [records.changeKind], '#a': [address], limit: 500 }] : []), ]) const changesFor = (target: string) => changes.filter((e) => tagValue(e, records.targetTag) === target) const project = buildObject({ root, pointer, manifest, projection, folded: foldChanges(changesFor(address), records, root), viaRecommendation, webTemplate: resolved.webTemplate, }) // 5. The tickets. A child either names this project or names nothing at all — // an unlinked ticket still lives in the Folder, and the Folder *is* the // project, so dropping it would under-report live work. const ticketProjection = manifest.projections?.[String(relation.childKind)] const statusSpec = ticketProjection && !Array.isArray(ticketProjection.slots.status) ? ticketProjection.slots.status : undefined // A project whose tickets have no declared projection is still worth showing; // it just has nothing to expand into. /** * One lookup for everyone the panel could name, shared by every object it * returns. The project's lead is the only one drawn today; the tickets carry * the same map so a renderer that starts showing assignees needs no second * trip, and so `ForeignObject` means the same thing whichever resolver built * it. */ const withPeople = async (objects: ForeignObject[]) => { const people = await (lookupPeople ?? peopleViaRelay(query))([ ...new Set(objects.flatMap(pubkeysIn)), ]) return objects.map((object) => ({ ...object, people })) } if (!ticketProjection) { const [only] = await withPeople([project]) return { project: only, tickets: [], openCount: 0, doneCount: 0 } } const rows: { ticket: ForeignObject; rank: number; activityMs: number }[] = [] let openCount = 0 let doneCount = 0 for (const event of inFolder) { if (event.kind !== relation.childKind) continue /* What the link is compared against depends on what the child carries. Ship's issues name their project by full address; Peek's messages name their channel with `h`, which holds the bare uuid — the parent's `d`, not its address. The manifest says which (`match`), because guessing is what PRO-2 removed and comparing against both would silently accept a child that named something else entirely whose `d` happened to collide. */ const expected = relation.match === 'identifier' ? pointer.identifier : address const link = tagValue(event, relation.linkTag) if (link !== undefined && link !== expected) continue // Archived is the one exclusion left. Everything else the project holds is // listed; a record its own app hides is not part of the project any more. if (isHidden(event, records, changes)) continue const ticketPointer: AddressPointer = { kind: event.kind, pubkey: event.pubkey, identifier: tagValue(event, 'd') ?? '', relays: [], } const ticketChanges = changesFor(pointerToAddress(ticketPointer)) const folded = foldChanges(ticketChanges, records, event) const status = statusSpec ? resolveSlot(statusSpec, event, folded, manifest) : null const raw = statusSpec ? rawSlotValue(statusSpec, event, folded, false)?.value : undefined // The owning app's declaration first, the label guess only if it has none. const stage = declaredStage(manifest, statusSpec, raw) const done = isDone(stage, raw, status?.value) if (done) doneCount += 1 else if (isOpen(stage, raw, status?.value)) openCount += 1 // A status in neither set — parked, or one this consumer has never seen — // is still a ticket and still listed. It just cannot claim to be progress // in either direction, so it stays out of both counts. rows.push({ ticket: buildObject({ root: event, pointer: ticketPointer, manifest, projection: ticketProjection, folded, viaRecommendation, webTemplate: resolved.webTemplate, }), rank: isStarted(stage, raw, status?.value) ? 0 : done ? 2 : 1, activityMs: Math.max(event.created_at * 1000, ...ticketChanges.map(orderingMs)), }) } // Started work, then the queue, then what is finished; within each, whatever // moved most recently. The whole project is here — the order is what makes it // scannable rather than a dump. rows.sort((a, b) => a.rank - b.rank || b.activityMs - a.activityMs) /* The producer's declared `limit`, applied after sorting rather than before. It says how many children are worth rendering, so cutting before the sort would drop whichever happened to be read first and could hide every started ticket behind a hundred finished ones. The counts above are deliberately computed over everything: "4 of 117" stays true when only 200 rows are drawn, and a total that changed with the render budget would be a different and worse number. */ const capped = relation.limit ? rows.slice(0, relation.limit) : rows const [resolvedProject, ...tickets] = await withPeople([project, ...capped.map((r) => r.ticket)]) return { project: resolvedProject, tickets, openCount, doneCount } } /** * Build the event that performs a manifest-declared action. * * Pure, and separate from the Convex action for the same reason `projection.ts` * is separate from `foreign.ts`: this is the part worth testing against a live * relay, and it must not need a deployment or a signed-in session to run. * * Returns a string on refusal rather than throwing — every failure here is * something a user should read. */ /** * NIP-01's parameterized-replaceable range. An event in it is addressed by * `(kind, pubkey, d)`, so one created without a `d` has no address — it cannot * be referenced, commented on, or acted upon, and the owning app will not find * it where it looks. */ const isAddressableKind = (kind: number) => kind >= 30000 && kind < 40000 /** * The tags a new root may name its Folder with — SPEC §7.3's `placement`. * * The two a relay indexes for a Folder's containment read and {@link folderOf} * reads. Closed, because a root placed in any other tag belongs to no Folder * and nothing would say so. */ const PLACEMENTS: readonly string[] = ['h', 'buzz-channel'] /** * The tags a prose value carries — SPEC §7.3's `format`, MAN-1. * * **Tagged only when the caller says the value is in the declared format**, and * then checked, because a reader decides the model by the tag alone (§13.4): a * `content-format` on marker text renders nothing, and one on a document the * relay cannot parse renders JSON at a person. Without `contentFormat` the value * is marker text and carries nothing, which every reader must accept — so a * consumer that draws a text box for a prose field still publishes correctly. * * A document's attachments are named again as `imeta`, which §13.3 requires of * every event holding one and which ingest verifies against stored blobs. */ function proseTags(args: { label: string declared: string | undefined contentFormat: string | undefined value: string }): string[][] | string { const { label, declared, contentFormat, value } = args if (contentFormat === undefined) return [] if (proseFormatOf(declared) !== contentFormat) { return proseFormatOf(declared) ? `"${label}" takes ${proseFormatOf(declared)}, not ${contentFormat}.` : `"${label}" declares no ${contentFormat} value, so it is written as plain text.` } // Clearing a field is not a document; an empty value says nothing to tag. if (value === '') return [] try { parseBlockDocument(value) } catch (e) { return `"${label}" was handed ${contentFormat} that is ${(e as Error).message}.` } return [[CONTENT_FORMAT_TAG, contentFormat], ...attachmentsInDocument(value, contentFormat).map(imetaTag)] } /** * The event an object-creating action publishes. * * Split out because it shares almost nothing with a change: the tags come from * the form rather than from `records`, and the result is a new object rather * than a statement about an existing one. * * **Every refusal names what was allowed**, and that is the requirement rather * than a nicety. The owning app cannot enforce any of this — anyone can publish * anything — so a consumer that guesses is the one putting junk in a shared * record, and a consumer told only "invalid" cannot do better next time. */ function buildCreationEvent(args: { declared: ManifestAction vocabularies?: Manifest['vocabularies'] address: string folder: string value: string | Record newId?: string contentFormat?: string pubkey: string createdAtMs: number }): UnsignedActionEvent | string { const { declared, address, folder, value, newId } = args const properties = declared.input!.properties! const required = declared.input!.required ?? [] if (typeof value === 'string') { return `"${declared.label}" takes a form: ${Object.keys(properties).join(', ')}.` } const allowed = Object.keys(properties) for (const name of Object.keys(value)) { if (!allowed.includes(name)) { return `"${name}" is not a field of "${declared.label}" — it takes ${allowed.join(', ')}.` } } for (const name of required) { if (!value[name]?.trim()) return `"${name}" is required by "${declared.label}".` } /* A property may name a vocabulary of its own, checked exactly as a scalar action's is — the honour system does not get weaker because there are several fields. Ship declares none today (`add-issue` takes a bare title), so this is a path an app grows into rather than one in use. */ for (const [name, held] of Object.entries(value)) { const vocabName = properties[name]?.enum if (!vocabName || !held) continue const vocab = args.vocabularies?.[vocabName] ?? [] if (!vocab.some((entry) => entry.value === held)) { return `"${held}" is not one of ${vocab.map((e) => e.value).join(', ')}.` } } // A person property is checked as a person change is (SPEC §7.3, SHA-28). const people: string[] = [] for (const [name, held] of Object.entries(value)) { if (properties[name]?.type !== 'pubkey') continue const problem = personKeyProblem(declared.label, held) if (problem) return problem if (held !== '' && !people.includes(held)) people.push(held) } if (isAddressableKind(declared.emits.kind) && !newId) { return `Creating a kind ${declared.emits.kind} needs an identifier, and none was supplied.` } /* At most one property may target `content` — PRO-18. Checked before anything is built rather than trusted, because two of them is a producer bug whose symptom is one field silently disappearing into the other. The honour system means a manifest can say this; it does not mean a consumer has to publish the result. */ const bodyFields = Object.keys(properties).filter((name) => properties[name]?.target === 'content') if (bodyFields.length > 1) { return `"${declared.label}" declares ${bodyFields.length} fields writing to content (${bodyFields.join(', ')}), and an event has one.` } const bodyField = bodyFields[0] const placement = declared.emits.placement ?? 'h' if (!PLACEMENTS.includes(placement)) { return `"${declared.label}" places its object with "${placement}", and a Folder is named by ${PLACEMENTS.join(' or ')}.` } // Only the content field can be prose, so only its value is ever tagged. const prose = proseTags({ label: declared.label, declared: bodyField ? properties[bodyField]?.format : undefined, contentFormat: args.contentFormat, value: bodyField ? (value[bodyField] ?? '') : '', }) if (typeof prose === 'string') return prose const tags: string[][] = [] if (isAddressableKind(declared.emits.kind)) tags.push(['d', newId!]) // A property's name is the tag it writes, unless it declared `content`. See // `ManifestAction.input`. for (const [name, held] of Object.entries(value)) { if (held !== '' && name !== bodyField) tags.push([name, held]) } // The parent. `toAddressOf: "self"` names the object the action was invoked // on; any other value is a shape nothing declares yet, and guessing at one // would publish a link the owning app never asked for. if (declared.emits.setTag && declared.emits.toAddressOf === 'self') { tags.push([declared.emits.setTag, address]) } tags.push([placement, folder], ...prose) // Each person the new root names, once, where the relay indexes them — the // property's own tag is not indexed (SPEC §11.8, decided 2026-10-05). for (const person of people) tags.push(['p', person]) return { pubkey: args.pubkey, created_at: Math.floor(args.createdAtMs / 1000), kind: declared.emits.kind, tags, content: bodyField ? (value[bodyField] ?? '') : '', } } /** * A person's key as an input of `type: "pubkey"` takes it — SPEC §7.3, SHA-28. * The `p` a writer adds must be what `#p` matches, so an npub or upper-case hex * is refused rather than converted: the person picking it saw something else. */ const PERSON_KEY = /^[0-9a-f]{64}$/ /** Why `value` is neither `''` nor a person's key, or undefined when it is one. */ function personKeyProblem(label: string, value: string): string | undefined { if (value === '' || PERSON_KEY.test(value)) return undefined // A consumer shows this to the person, whose picker went wrong upstream; the // rule for developers is SPEC §7.3's, not theirs to act on. return `"${label}" needs a person picked from the list, so nothing was changed.` } /** What {@link buildActionEvent} and {@link buildActionEvents} are handed. */ export interface ActionEventArgs { manifest: { records?: RecordsRule; actions?: ManifestAction[]; vocabularies?: Manifest['vocabularies'] } kind: number /** Address of the object being acted on. */ address: string /** Author of the object — needed for NIP-22's `P`/`p` tags. */ objectAuthor: string folder: string actionId: string /** * A scalar for a change or a comment; `{ property: value }` for an * object-creating action, whose form has several fields. */ value: string | Record /** * A fresh identifier for an object being created, when its kind is * parameterized-replaceable and therefore needs a `d`. * * Supplied rather than generated: ADR 0002 §10 constraint 2 — the runtime * reaches for nothing and is handed everything. It also makes the built event * a pure function of its inputs, which is what lets a test assert on one. */ newId?: string /** * The comment being answered, when this comment is a reply (PRO-20). * * SPEC §6.4's table: the uppercase `A`/`K`/`P` still name the file, and the * lowercase trio names this comment — its id, its kind and its author — * instead of repeating the file. So a reply carries no lowercase `a` and is * absent from the file's `#a` read; {@link threadsOf} is the read that finds * it. `kind` is the parent's own, which for one of Ship's replies is `9`. * * Refused on any action that is not a comment: nothing else has a parent. * * `place` is the comment's own `block` and `quote` tags, when it is on part * of the file: the reply copies them, so its thread keeps its place once the * comment is deleted (SPEC §13.6, as conversation's `buildReply` does). * * why: https://github.com/estiva-app/estiva-docs/blob/main/decisions/a-reply-on-a-page-keeps-its-threads-place.md */ replyTo?: { id: string; kind: number; author: string; place?: readonly string[][] } /** * The format the action's prose value is in, when it is not marker text — * `'estiva-blocks-1'` for a block document (SPEC §7.3's `format`, MAN-1). * * The value of a scalar change, or of a creation's `content` field. Only a * format the action declares for that value is accepted, and the value is * parsed before it is tagged. Omitted, the value is written untagged as * marker text, which is always correct. */ contentFormat?: string /** * What the person said about a change, carried as its `content` — the * resolution summary of an archive (FOL-46), or any change's note. A change * only: a comment's text is its `value`, and a creation has its own body. */ note?: string pubkey: string createdAtMs: number } /** * Build the event that performs a manifest-declared action — one event. * * **Refuses an action declaring `emits.listed`**, which is more than one event * (SPEC §7.3): building the root alone would publish an object its Folder does * not show, and report success. {@link buildActionEvents} builds those. */ export function buildActionEvent(args: ActionEventArgs): UnsignedActionEvent | string { const declared = args.manifest.actions?.find((a) => a.id === args.actionId) if (declared?.emits.listed === true && (declared.emits.kind === KIND_DELETION || isCreationAction(declared))) { return `"${declared.label}" is followed by a Folder command, so it is built with buildActionEvents.` } return buildOneActionEvent(args) } /** An action that makes a new object rather than changing one — a form. */ function isCreationAction(declared: ManifestAction): boolean { return declared.input?.type === 'object' && !!declared.input.properties } /** * Every event an action publishes, **in publish order** — SPEC §7.3, MAN-1. * * The first is what {@link buildActionEvent} builds. An action declaring * `emits.listed` is followed by the Folder commands that keep its Folder's * listing true, planned by `planPlaceFile` / `planUnlistFile`: * * - a creation: `kind:1852 add` naming the new address in the Folder it was * placed in, when `folderHasState` — none when the Folder has no state, where * a command would hide everything else filed there; * - a deletion: `kind:1852 remove` from each of `listedIn` that has state. * * Both facts come from the caller's read of `kind:30890` and are required * rather than defaulted, because a guess here is silent loss. Publish in order * and stop at the first refusal: each event assumes the ones before it landed. */ export function buildActionEvents( args: ActionEventArgs & { /** For a `listed` creation: whether the Folder has state — from a read. */ folderHasState?: boolean /** For a `listed` deletion: every Folder that lists the object, with `hasState` — from a read. */ listedIn?: readonly FolderRef[] }, ): UnsignedActionEvent[] | string { const first = buildOneActionEvent(args) if (typeof first === 'string') return first const declared = args.manifest.actions!.find((a) => a.id === args.actionId)! if (declared.emits.listed !== true) return [first] if (declared.emits.kind === KIND_DELETION) { if (!args.listedIn) return `"${declared.label}" unlists what it deletes, and needs the Folders that list it.` // One remove per Folder, however many reads it came back from. const folders = [...new Map(args.listedIn.map((listing) => [listing.id, listing])).values()] return [ first, ...folders.flatMap((listing) => planUnlistFile(args.pubkey, args.createdAtMs, { folder: listing.id, address: args.address, hasState: listing.hasState }), ), ] } if (isCreationAction(declared)) { if (args.folderHasState === undefined) { return `"${declared.label}" lists what it creates, and needs to know whether the Folder has state.` } // A listing names addresses, and an object with no `d` has none. const identifier = first.tags.find((t) => t[0] === 'd')?.[1] if (identifier === undefined) { return `"${declared.label}" lists what it creates, and a kind ${first.kind} has no address to list.` } const created = pointerToAddress({ kind: first.kind, pubkey: first.pubkey, identifier, relays: [] }) return [first, ...planPlaceFile(args.pubkey, args.createdAtMs, { folder: args.folder, address: created, hasState: args.folderHasState })] } // A change or a comment adds nothing to a Folder and removes nothing from it. return [first] } function buildOneActionEvent(args: ActionEventArgs): UnsignedActionEvent | string { const { manifest, kind, address, folder, actionId, value, replyTo } = args const records = manifest.records const declared = manifest.actions?.find((a) => a.id === actionId) if (!declared) return `This app does not offer "${actionId}".` const appliesTo = Array.isArray(declared.appliesTo) ? declared.appliesTo : [declared.appliesTo] if (!appliesTo.includes(String(kind))) { return `"${declared.label}" does not apply to a kind ${kind}.` } const isCreation = isCreationAction(declared) // A reply is a comment with a parent — what the scalar path below builds for // an action that is neither a deletion nor a change. Refused here, before any // branch that would build something else and quietly drop the parent. if (replyTo && (isCreation || declared.emits.kind === KIND_DELETION || declared.emits.field)) { return `"${declared.label}" is not a comment, so it cannot reply to one.` } // Prose is a creation's content or a change's value (§7.3). A deletion has // no value, and a comment's body is §13.2 marker text. if (args.contentFormat !== undefined && !isCreation && (!declared.emits.field || declared.emits.kind === KIND_DELETION)) { return `"${declared.label}" takes no prose value, so it takes no content format.` } // A note is a change's `content`. Anywhere else it would be dropped, and the // resolution a person typed lost without a word. if (args.note !== undefined && (isCreation || !declared.emits.field || declared.emits.kind === KIND_DELETION)) { return `"${declared.label}" is not a change, so it takes no note.` } // An object-creating action is a different event entirely — a new object // rather than a change to one — so it branches before the scalar path. if (isCreation) { return buildCreationEvent({ ...args, declared, vocabularies: manifest.vocabularies }) } if (typeof value !== 'string') { return `"${declared.label}" takes a single value, not a form.` } /* A deletion is NIP-09, built from the NIP rather than from the manifest for the same reason a comment is: no app owns `kind:5`. The `a` tag names the object — the shape for an addressable target, and the branch on which the relay checks the actor against the *address's* author rather than one event's — and `k` says what kind it was, as the NIP asks. No `h`: the request is about the object, not about a channel, and the relay does not read one here. Before the fold-rule check because, like a creation, a deletion folds nothing. The value is ignored rather than refused. A consumer's confirm dialog has nothing to pass, and `''` is what it will pass. */ if (declared.emits.kind === KIND_DELETION) { return { pubkey: args.pubkey, created_at: Math.floor(args.createdAtMs / 1000), kind: KIND_DELETION, tags: [ ['a', address], ['k', String(kind)], ], content: '', } } /* **A fold rule is needed to write a change, and only to write a change.** This was the first line of the function, refusing every action of every app that declares no `records` — which read as a guard and was a gate on an unrelated feature. PRO-12 settled the principle on the read side: *"SPEC mandates `records`; the runtime correctly stopped requiring it."* The write side never followed, and the tags below are the only place it is used. Found by INT-9 against production, and only there: Peek declares no fold rule, deliberately — *"a topic's name is a tag the relay wrote, and a message is immutable. A consumer that folded nothing would render both correctly."* So Peek's first action was refused with "That app does not say how its records are written", which is true, irrelevant, and impossible to act on. A creation reads nothing and folds nothing; there is nothing for a fold rule to say about it. */ if (!records) return 'That app does not say how its records are written.' // Validate against the manifest's own vocabulary. The owning app cannot // enforce this — anyone can publish anything (RFC_UPDATES.md §3) — so a // consumer that skips the check is the one putting junk in the shared record. // Checking here is Peek keeping its side of the honour system. if (declared.input?.enum) { const vocab = manifest.vocabularies?.[declared.input.enum] ?? [] if (!vocab.some((entry) => entry.value === value)) { return `"${value}" is not one of ${vocab.map((e) => e.value).join(', ')}.` } } // A change whose input is a person names them (SPEC §7.3's `pubkey`, SHA-28). // `''` clears it and names nobody; anything else that is not a key would fold // as the field's value and make nobody a member, so it is refused here. const namesPerson = !!declared.emits.field && declared.input?.type === 'pubkey' if (namesPerson) { const problem = personKeyProblem(declared.label, value) if (problem) return problem } // A prose value's `content-format` and `imeta` — a change only, per the // refusal above. Ship's order: the tag before `ts`, the files after it. const prose = declared.emits.field ? proseTags({ label: declared.label, declared: declared.input?.format, contentFormat: args.contentFormat, value }) : [] if (typeof prose === 'string') return prose const [formatTag, ...files] = prose const tags: string[][] = declared.emits.field ? [ [records.targetTag, address], [records.fieldTag, declared.emits.field], [records.valueTag, value], ['h', folder], ...(formatTag ? [formatTag] : []), ] : // NIP-22 comment. Built from the ratified NIP rather than from the // manifest, which is legitimate precisely because no app owns kind:1111 — // the same reason the owning app uses it. Uppercase tags name the thread // root, lowercase the immediate parent: the file again for a top-level // comment, the comment answered for a reply (SPEC §6.4). [ ['A', address], ['K', String(kind)], ['P', args.objectAuthor], ...(replyTo ? [ ['e', replyTo.id], ['k', String(replyTo.kind)], ['p', replyTo.author], // Only a `kind:1111` has a place (§13.6): a Ship `kind:9` parent's `block` means nothing. // conversation's `placeOf` filter, inline so interop's conversation floor stays where it is. // One of each, the first, as readers read it: a stranger's extra tags are not copied under the replier's key. ...(replyTo.kind === KIND_COMMENT ? ['block', 'quote'].map((name) => replyTo.place?.find((t) => t[0] === name)).filter((t): t is string[] => !!t).map((t) => [...t]) : []), ] : [ ['a', address], ['k', String(kind)], ['p', args.objectAuthor], ]), ['h', folder], ] // The manifest told us how it orders events; write events that can be ordered. // Without `ts`, a change from here and one from the owning app in the same // second would be separated by event id — arbitrarily, and differently // depending on which app you asked (FRICTION.md A6). if (records.order?.includes('ts')) tags.push(['ts', String(args.createdAtMs)]) tags.push(...files) // The person, where the relay indexes them: `value` is not indexed, so // without this `#p` never finds the change and §11.8 makes nobody a member. // After `ts`, where Ship's own builder has put it since FOL-30. if (namesPerson && value !== '') tags.push(['p', value]) // A comment names, where the relay indexes them, the people its body mentions // by `nostr:npub…` (SPEC §13.1) — what `buildComment`'s callers in Ship and // Peek add. Without them the body draws a mention chip and nobody is told: // `#p` is how a reader finds that it was named (§11.8). Once each, after the // `p` the comment already carries for its object or parent, and no more `p` // in all than a message may carry (Buzz's cap). Only for `comment` itself: an // action with no field that declared some other kind is not a mention. if (!declared.emits.field && declared.id === 'comment') { for (const [, person] of mentionTagsFor(value)) { const ps = tags.filter((t) => t[0] === 'p') if (ps.length >= MAX_MENTIONED_PEOPLE) break if (!ps.some((t) => t[1] === person)) tags.push(['p', person]) } } // A move also names its new parent where a relay indexes it (SPEC §7.2, FOL-45): // `value` is not indexed, so without this no read by the new parent finds a // child moved in. Only for the field the kind declares as `movedBy`, and only // for an address of the declaring kind (any kind for a bare file) — a move to // the top names no parent, and a value of another kind names none either. const moved = declared.emits.field ? movedByOf(manifest, kind) : undefined const parentAddress = moved?.parentKind === undefined ? /^\d+:[0-9a-f]{64}:/ : new RegExp(`^${moved.parentKind}:[0-9a-f]{64}:`) if (moved?.field === declared.emits.field && typeof value === 'string' && parentAddress.test(value)) { tags.push([MOVED_TO_TAG, value]) } return { pubkey: args.pubkey, created_at: Math.floor(args.createdAtMs / 1000), kind: declared.emits.kind, tags, content: declared.emits.field ? (args.note ?? '') : value, } } /** * Test seam: resolve one projection's `title` slot against one event. * * Exported so the tag-fallback rules can be pinned without standing up a fake * relay. `resolveSlots` and `resolveSlot` are the real path; this only picks * the one slot out of them. */ export function resolveFolderProjectSlotsForTest( manifest: Manifest, root: SignedEvent, ): string | undefined { const projection = manifest.projections?.[String(root.kind)] if (!projection) return undefined return resolveSlots(projection, root, {}, manifest).slots.title?.value } // ── The folder read model — RFC 0.4 §4 and §5 ─────────────────────────────── /** * The Folder as the relay maintains it — RFC 0.4 §12.1. * * Relay-signed, addressable, and **global**. §4.2 is worth reading before * changing that last word: three of the four properties a folder must have are * impossible if its state carries an `h`, because `h` files it under a channel * and gates reads by access. Exploring folders you are not in, following one, * and a file having one home while being referenced from elsewhere all need an * address that resolves without membership. So the channel keeps doing access * and conversation, and the folder becomes a layer above it. * * **Read-only here.** A client changes a folder by publishing a `kind:1852` * command and the relay emits the new state (§4.1). A folder is not a * user-signed event because NIP-01 addressable events are single-signer by * construction — the address is `(pubkey, kind, d)`, so a second person adding * a file would not replace your folder, they would create a different one. */ export const KIND_FOLDER_STATE = 30890 /** * NIP-29 group metadata — the Folder's channel. * * Named here for two unrelated jobs: it carries the Folder's title — its * `name`, and only that (SPEC §3.2) — and its own address is what makes a Peek * topic a file like any other (§5.2). */ const KIND_CHANNEL = 39000 /** NIP-29 group admins — the relay's list of who holds which role in a channel. */ const KIND_CHANNEL_ADMINS = 39001 /** The roles on a `kind:39001` that may rename a Folder, and so archive it (SPEC §3.2). */ const FOLDER_ADMIN_ROLES = new Set(['owner', 'admin']) /** The `name` tag, when there is an event to read it off at all. */ const nameOf = (event: SignedEvent | undefined) => (event ? tagValue(event, 'name') : undefined) /** * The owners and admins a `kind:39001` lists — kept only when the channel's own * signer, the relay, signed it: anybody can publish an event of that shape. * * why: https://github.com/estiva-app/estiva-docs/blob/main/decisions/only-owners-and-admins-rename-or-archive-a-folder.md */ function folderAdminsOf(admins: SignedEvent | undefined, channel: SignedEvent | undefined): string[] { if (!admins || !channel || admins.pubkey !== channel.pubkey) return [] return [ ...new Set( admins.tags .filter((tag) => tag[0] === 'p' && tag[1] && tag.slice(2).some((role) => FOLDER_ADMIN_ROLES.has(role))) .map((tag) => tag[1]), ), ] } /** * A Folder's own archive (SPEC §3.2): the `archived` changes on its channel's * address, counting only one carrying the Folder's own `h` whose author the * relay lists as its owner or admin. From anybody else the relay accepts the * change, and it means nothing. */ function folderArchiveOf(changes: SignedEvent[], folder: Pick): Archive | undefined { // A Folder's own rule (h = the Folder, by its admins) stands in for the root check. return folder.channel ? archiveOf(changes, folder.channel, ARCHIVE_RULE, undefined, new Set(folder.admins ?? [])) : undefined } /** One folder, enough to draw a sidebar row. */ export interface FolderSummary { /** * The folder's uuid. **One uuid, three roles** — the channel id, the `h` on * everything in it, and the `d` on both its `39000` and its `30890` (§5.2, * measured across all of production's channels). */ id: string /** * The title: its `kind:39000`'s `name`, and only that (SPEC §3.2). A `name` * an older `kind:1852` left on the state is never read — any member may send * one, and only owners and admins may rename the channel. * * why: https://github.com/estiva-app/estiva-docs/blob/main/decisions/only-owners-and-admins-rename-or-archive-a-folder.md */ name?: string /** * True once the relay maintains state for it, rather than it being a bare channel. * * why: https://github.com/estiva-app/estiva-docs/blob/main/decisions/a-folder-is-a-group-the-relay-lists.md */ hasState: boolean /** * Present when the Folder's relay-signed `kind:39000` carries `["private"]` * (NIP-29). What `planMoveFile` reads as `FolderRef.private`. */ private?: true /** * Who may rename and archive it: the owners and admins on its relay-signed * `kind:39001`. Offer Archive only to them — a reader ignores an archive from * anybody else (§3.2). Absent when the relay returned no admin list. */ admins?: string[] /** * The folders whose state lists this one as a file — present only when there * are any. * * A channel is addressable (§5.2), so a folder can be placed inside another * the way any file is, and Peek's topics are (FOL-22): each is a channel * listed by its Folder's state. A sidebar that drew every channel as a top-level * folder would show the Folder and, beside it, every topic in it. This is what * lets a consumer tell the two apart without reading any folder's contents: * `listFolders` already holds every state, so the answer is free. */ listedIn?: string[] /** * The addresses this folder's state lists, in the state's order — present * only when there are any. * * `listedIn` in the other direction, and free for the same reason: the * states are already in hand. It is the unresolved list — a row that names * an archived or unreachable record is still here — so a consumer that * draws one app's own records under a folder (Ship's projects under a Folder, * FOL-5) intersects it with the records it holds, and a consumer that draws * another app's files resolves them with {@link resolveFolderContents}. */ addresses?: string[] /** * The Folder's own address as a file — its `kind:39000`, `39000::`. * What a state lists when it holds this Folder, and what an `archived` * change on the Folder names. Absent only for a state whose channel the * relay did not return. */ channel?: string /** * Present when the Folder is archived (FOL-46) — or, for a record's channel, * when the record it belongs to is (`with` names the record). See * {@link Archive}. * * {@link topLevelFolders} leaves an archived Folder out, and * {@link resolveFolderContents} leaves one out of the Folder that lists it, * so its subtree goes with it. What it holds stays readable: a link still * opens it, and a file it lists that another Folder also lists stays visible * there. */ archived?: Archive } /** * The folders a person navigates by: the ones with state that no other folder * lists (FOL-5). * * Most channels a relay answers with are nobody's folder — an app's record * channels, the channel a record's conversation lives in, a test topic — and * each is reached through the object it belongs to. Two facts the summary * already carries decide it, with no further read: a `kind:30890` exists once * somebody has sent a folder command at the channel, which a record channel * never gets; and a folder another one lists (`listedIn`) is a row inside * that one, not a section beside it. * * Not a list of ids: a new Folder is a folder somebody creates, not a code * change. Moved here from Peek and Ship, which each held the same line. * * An archived Folder is not navigated by (FOL-46), and a listing that has not * read archives — a summary with no `archived` field — keeps every Folder. */ export function topLevelFolders>( folders: T[], ): T[] { return folders.filter((folder) => folder.hasState && !folder.listedIn?.length && !folder.archived) } /** A folder and everything in it, each file drawn through its owner's manifest. */ export interface FolderContents extends FolderSummary { /** The folder state's address, absent until a state event exists. */ address?: string /** * The files this folder holds. * * **Peers by construction, not by special case.** A Peek topic and a Ship * project sit side by side because both are `a` tags in one list, and §5.2's * finding is exactly that one tag type is enough to name either: a channel * turned out to be addressable after all, so a topic needs no wrapper and no * second mechanism. Nothing in this function knows what either app is. * * Ordered as the folder lists them. A file that resolved to nothing is * **absent rather than marked** — see {@link resolveFolderContents}. */ files: ForeignObject[] /** * Where the list came from, because the two are not equivalent. * * `state` is the model. `channel` is the approximation available before a * folder has any state: containment inferred from `h`, which can list an * app's records and **can never list a topic**, because under `h` the topic * *is* the container rather than a thing inside it. A consumer that needs to * explain a short list is reading this field. */ source: 'state' | 'channel' } /** * Every folder this identity can see, for a sidebar. * * Both shapes in one pass: folders the relay maintains state for, and bare * channels that have none yet. A channel with state appears once, titled by its * `kind:39000` (SPEC §3.2), with the owners and admins its `kind:39001` lists. * * **A direct route, deliberately.** §4.2's post-mortem on REW-11 is that * discovering children only through their parent loses them when the parent * goes; a sidebar built by walking something else would inherit exactly that. * This asks for folders by kind. */ export async function listFolders(query: QueryFn): Promise { const events = await query([ { kinds: [KIND_FOLDER_STATE], limit: 500 }, { kinds: [KIND_CHANNEL], limit: 500 }, { kinds: [KIND_CHANNEL_ADMINS], limit: 500 }, ]) const byId = new Map() const channelOf = new Map() const adminsOf = new Map() for (const event of events) { const id = tagValue(event, 'd') if (!id) continue if (event.kind === KIND_CHANNEL) channelOf.set(id, event) if (event.kind === KIND_CHANNEL_ADMINS) adminsOf.set(id, event) if (event.kind !== KIND_FOLDER_STATE && event.kind !== KIND_CHANNEL) continue const state = event.kind === KIND_FOLDER_STATE const existing = byId.get(id) const addresses = state ? event.tags.filter((tag) => tag[0] === 'a' && tag[1]).map((tag) => tag[1]) : (existing?.addresses ?? []) byId.set(id, { id, hasState: state || (existing?.hasState ?? false), ...(addresses.length ? { addresses } : {}), }) } // The title, privacy and admins are the channel's (§3.2), whichever order the events arrived. for (const [id, folder] of byId) { const channel = channelOf.get(id) if (!channel) continue const name = nameOf(channel) if (name !== undefined) folder.name = name if (channel.tags.some((tag) => tag[0] === 'private')) folder.private = true const admins = folderAdminsOf(adminsOf.get(id), channel) if (admins.length) folder.admins = admins folder.channel = pointerToAddress({ kind: KIND_CHANNEL, pubkey: channel.pubkey, identifier: id, relays: [] }) } /* A folder listed in another folder's state is a file there. Read off the states already in hand: an `a` naming a `kind:39000` is a channel, and its `d` is the folder's id. Both `KIND_CHANNEL` and the state's own `d` are uuids, so no address has to be built to compare them. */ const listedIn = new Map() for (const event of events) { if (event.kind !== KIND_FOLDER_STATE) continue const container = tagValue(event, 'd') if (!container) continue for (const tag of event.tags) { if (tag[0] !== 'a' || !tag[1]) continue const [kind, , identifier] = tag[1].split(':') if (Number(kind) !== KIND_CHANNEL || !identifier || identifier === container) continue const containers: string[] = listedIn.get(identifier) ?? [] if (!containers.includes(container)) containers.push(container) listedIn.set(identifier, containers) } } const { recordsOf, archives, rootOf } = await placeRecordChannels(events, byId, listedIn, query) for (const [id, containers] of listedIn) { const folder = byId.get(id) if (folder) folder.listedIn = containers } markArchivedFolders(byId, recordsOf, archives, rootOf) return [...byId.values()].sort((a, b) => (a.name ?? a.id).localeCompare(b.name ?? b.id)) } /** * Which Folders are archived — FOL-46, from the changes {@link placeRecordChannels} * read with the record roots. * * A Folder is archived by a change naming its channel's address, the way any * file is. A record's channel — a Ship project's conversation — is archived * with its record, so an archived project's channel stops lighting its Folder's * dot the way the project stopped being listed — only when every record naming * the channel is archived, since two projects may share one. Its own archive * wins when it has one. * * A refused read throws before this, like the reads beside it: a listing that * silently reported every Folder live would put archived Folders back in * everyone's sidebar. */ function markArchivedFolders( byId: Map, recordsOf: Map, changes: SignedEvent[], /** Each record's newest root, so an archive signed in another Folder is ignored (SPEC §6.2). */ rootOf: ReadonlyMap, ): void { for (const folder of byId.values()) { const own = folderArchiveOf(changes, folder) if (own) { folder.archived = own continue } const records = recordsOf.get(folder.id) ?? [] const archives = records.map((record) => archiveOf(changes, record, ARCHIVE_RULE, rootOf.get(record))) if (records.length && archives.every(Boolean)) folder.archived = { ...archives[0]!, with: records[0] } } } /** How many Folders deep {@link archiveImpact} follows nested Folders. A request per Folder. */ const MAX_ARCHIVE_DEPTH = 3 /** What archiving one file would hide, for the warning a person reads first — FOL-46. */ export interface ArchiveImpact { /** * The files that would go out of every list with it, counted by kind: its * children, and — for a Folder — every file only that Folder lists, with * their children. Files already archived are not counted; they are hidden * already. Kinds with no files are absent. */ hides: { kind: number; count: number }[] /** * Files a Folder lists that another Folder, not archived, lists too. They * stay visible there, and a warning that said they would be hidden would * make Archive look more destructive than it is. Empty for a file that is * not a Folder. */ staysVisible: { address: string; kind: number; in: { id: string; name?: string }[] }[] } /** * Count what archiving `address` would hide — the read behind the warning. * * "Hides 14 issues and 6 topics that are only here; 2 topics also in Estiva HQ * stay visible." The words are the app's; the counts are the same in every * app, which is why they are read here. * * - **A Folder** (a `kind:39000` address, `FolderSummary.channel`): what it * lists, split by whether another Folder lists it too, the children of what * would be hidden, and — three Folders deep — what a nested Folder only it * lists holds. Pass `folders` from the app's own `listFolders` to save that * read. * - **Any other file**: the children its kind declares (a project's issues), and * bare files placed under it (sub-topics), by their root's `a` tag. * * One level of children, as a listing draws them. A few requests, whatever the * size: the Folder, one per app's manifest (memoised by `cache`), the children, * and their archives. */ export async function archiveImpact( address: string, query: QueryFn, options: { folders?: FolderSummary[]; cache?: ProjectionCache } = {}, ): Promise { const pointer = addressPointerOf(address) if (!pointer) return { hides: [], staysVisible: [] } const counts = new Map() const count = (kind: number, n = 1) => counts.set(kind, (counts.get(kind) ?? 0) + n) const staysVisible: ArchiveImpact['staysVisible'] = [] let parents: { address: string; pointer: AddressPointer }[] = [{ address, pointer }] if (pointer.kind === KIND_CHANNEL) { const folders = options.folders ?? (await listFolders(query)) parents = [] /* A nested Folder only this one lists goes out of view with it, and so does everything only *that* one lists — walked the same way, a Folder at a time, to a small depth. "Only" is judged against the live Folders outside the tree being archived. */ const inTree = new Set([pointer.identifier]) const walk = async (folder: string, depth: number) => { const contents = await resolveFolderContents(folder, query, async () => ({}), options.cache) for (const file of contents.files) { if (!file.address) continue const others = folders.filter( (other) => !inTree.has(other.id) && !other.archived && other.addresses?.includes(file.address!), ) if (others.length) { staysVisible.push({ address: file.address, kind: file.kind, in: others.map(({ id, name }) => ({ id, name })) }) continue } count(file.kind) const filePointer = addressPointerOf(file.address) if (!filePointer) continue if (filePointer.kind === KIND_CHANNEL) { if (depth < MAX_ARCHIVE_DEPTH && !inTree.has(filePointer.identifier)) { inTree.add(filePointer.identifier) await walk(filePointer.identifier, depth + 1) } } else { parents.push({ address: file.address, pointer: filePointer }) } } } await walk(pointer.identifier, 0) } // A record channel lists a project *and* its issues; each is counted once. const counted = new Set(parents.map((p) => p.address)) for (const [kind, n] of await countLiveChildren(parents, counted, query, options.cache)) count(kind, n) return { hides: [...counts].map(([kind, n]) => ({ kind, count: n })), staysVisible, } } /** The children of each parent that are not archived already, by kind. */ async function countLiveChildren( parents: { address: string; pointer: AddressPointer }[], counted: ReadonlySet, query: QueryFn, cache?: ProjectionCache, ): Promise> { const byKind = new Map() if (parents.length === 0) return byKind const byApp = new Map() for (const { pointer } of parents) byApp.set(manifestKeyOf(pointer), pointer) const manifests = new Map() await Promise.all( [...byApp].map(async ([key, pointer]) => { const resolved = await resolveManifest(pointer, query, cache) if (resolved) manifests.set(key, resolved) }), ) const filters: Record[] = [] const wanted: { kind: number; via: string; parent: string; rule: RecordsRule }[] = [] for (const { address, pointer } of parents) { const manifest = manifests.get(manifestKeyOf(pointer))?.manifest const projection = manifest?.projections?.[String(pointer.kind)] const declared = projection && declaredChildSpec(projection) // A declared child is the parent's app's, and folds by its rule; a sub-topic is a bare file, SPEC's. const specs = [ ...(declared ? [{ kind: declared.kind, via: declared.via, rule: archiveRuleOf(manifest) }] : []), // A sub-topic may sit under a file of any kind (SPEC §6.7). ...(declared?.kind === KIND_BARE_FILE && declared.via === 'a' ? [] : [{ kind: KIND_BARE_FILE, via: 'a', rule: ARCHIVE_RULE }]), ] for (const spec of specs) { wanted.push({ ...spec, parent: address }) filters.push({ kinds: [spec.kind], [`#${spec.via}`]: [address], limit: RELAY_PAGE_CEILING }) } } const events: SignedEvent[] = [] for (let start = 0; start < filters.length; start += MAX_FILTERS_PER_QUERY) { events.push(...(await query(filters.slice(start, start + MAX_FILTERS_PER_QUERY)))) } const children = new Map() for (const event of events) { const identifier = tagValue(event, 'd') if (identifier === undefined) continue const match = wanted.find((w) => w.kind === event.kind && hasTagValue(event, w.via, w.parent)) if (!match) continue const child = pointerToAddress({ kind: event.kind, pubkey: event.pubkey, identifier, relays: [] }) if (counted.has(child)) continue // The newest version is the root an archive is checked against (SPEC §6.2). const held = children.get(child) if (!held || event.created_at > held.root.created_at) children.set(child, { kind: event.kind, rule: match.rule, root: event }) } if (children.size === 0) return byKind const changes = await readArchiveChanges([...children.keys()], query, [...children.values()].map((c) => c.rule)) for (const [child, { kind, rule, root }] of children) { if (archiveOf(changes, child, rule, root)) continue byKind.set(kind, (byKind.get(kind) ?? 0) + 1) } return byKind } /** * A record's own channel is listed wherever the record is (FOL-42). * * A state lists a record — a Ship project, say — by its address, and the record * names the channel its conversation lives in with {@link folderOf}'s tags. No * state lists that channel, so without this `listFolders` reports it placed * nowhere, and a consumer folding an unread verdict into the containers of a * channel (Peek's Folder dot) has nowhere to fold it: correct, and drawn * nowhere. Measured on production 2026-09-22: of the 43 records the six * states list, 23 name a channel other than the state's own and no state lists * any of them. This places 21 channels and moves no top-level Folder. * * Derived rather than published, so it cannot drift: a `kind:1852` adding each * channel to its Folder would have to be repeated by every app that places a * record, and would stay wrong the first time one did not. * * Both of `folderOf`'s spellings, because production has both — about half of * Ship's project records carry `buzz-channel` and half an `h` naming their own * channel. Nothing here knows either app: the tags are the relay's. * * Two channels are never placed this way: * * - **The container itself.** A file published *into* its Folder carries that * Folder's `h` — every Peek file does — and a Folder is not inside itself. * - **A channel with state of its own.** That is a Folder in its own right, and * a sidebar draws it as a section exactly when nothing lists it; states place * it, by `kind:1852`, and a tag on some record must not take a section out of * somebody's sidebar. Measured: one project's `buzz-channel` is such a Folder. * * **One extra request, and a refused one throws** like the Folder read before * it. The roots are read in one POST, grouped by `(kind, author)` like * `resolveFolderContents` does. * * 0.28.0 swallowed a refusal and returned the listing without these * placements, reasoning that every placement it still reported was true. On * production that was the bug it was meant to prevent: Peek reads the listing * once per page load, the load is when the relay's budget is most contested, * and one refused read on 2026-09-22 left a project's channel unplaced for the * whole session — the Folder's dot dark, and nothing anywhere saying why. A * listing missing placements looks exactly like a correct one, so only the * caller can recover, and only if it is told. Keeping the last good listing * and retrying is the caller's to do. */ async function placeRecordChannels( events: SignedEvent[], byId: Map, listedIn: Map, query: QueryFn, ): Promise<{ recordsOf: Map; archives: SignedEvent[]; rootOf: Map }> { const containersOf = new Map() for (const event of events) { if (event.kind !== KIND_FOLDER_STATE) continue const container = tagValue(event, 'd') if (!container) continue for (const tag of event.tags) { if (tag[0] !== 'a' || !tag[1]) continue let pointer: AddressPointer try { pointer = referenceToPointer(tag[1]) } catch { continue } if (pointer.kind === KIND_CHANNEL) continue const key = `${pointer.kind}:${pointer.pubkey}:${pointer.identifier}` const entry = containersOf.get(key) ?? { pointer, containers: [] } if (!entry.containers.includes(container)) entry.containers.push(container) containersOf.set(key, entry) } } // Every Folder's archive, and every listed record's, ride with the roots // (FOL-46): the channel addresses are in hand from the listing and the // record addresses are the keys above, so neither needs the roots first. const channels = [...byId.values()].flatMap((folder) => (folder.channel ? [folder.channel] : [])) const archiveAsk = archiveFilters([...new Set([...channels, ...containersOf.keys()])]) if (containersOf.size === 0) { const archives: SignedEvent[] = [] for (let start = 0; start < archiveAsk.length; start += MAX_FILTERS_PER_QUERY) { archives.push(...(await query(archiveAsk.slice(start, start + MAX_FILTERS_PER_QUERY)))) } return { recordsOf: new Map(), archives, rootOf: new Map() } } const groups = new Map() for (const { pointer } of containersOf.values()) { const key = `${pointer.kind}:${pointer.pubkey}` const group = groups.get(key) ?? { kind: pointer.kind, pubkey: pointer.pubkey, identifiers: [] } group.identifiers.push(pointer.identifier) groups.set(key, group) } const filters: Record[] = [] for (const { kind, pubkey, identifiers } of groups.values()) { for (let start = 0; start < identifiers.length; start += RELAY_PAGE_CEILING) { const chunk = identifiers.slice(start, start + RELAY_PAGE_CEILING) filters.push({ kinds: [kind], authors: [pubkey], '#d': chunk, limit: chunk.length }) } } filters.push(...archiveAsk) const answered: SignedEvent[] = [] for (let start = 0; start < filters.length; start += MAX_FILTERS_PER_QUERY) { answered.push(...(await query(filters.slice(start, start + MAX_FILTERS_PER_QUERY)))) } // A state lists files, never change events, so the kind tells them apart. const archives = answered.filter((e) => e.kind === ARCHIVE_RULE.changeKind) const roots = answered.filter((e) => e.kind !== ARCHIVE_RULE.changeKind) // Newest wins, as it does for any addressable event a relay has not yet replaced. const newest = new Map() for (const root of roots) { const key = `${root.kind}:${root.pubkey}:${tagValue(root, 'd') ?? ''}` const held = newest.get(key) if (!held || root.created_at > held.created_at) newest.set(key, root) } const recordsOf = new Map() for (const [key, { containers }] of containersOf) { const root = newest.get(key) const channel = root && folderOf(root) if (!channel || byId.get(channel)?.hasState) continue // A file published into the Folder that lists it names that Folder, and // does not own it: a topic's `h` is its Folder's. if (!containers.includes(channel)) recordsOf.set(channel, [...(recordsOf.get(channel) ?? []), key]) for (const container of containers) { if (container === channel) continue const placed = listedIn.get(channel) ?? [] if (!placed.includes(container)) placed.push(container) listedIn.set(channel, placed) } } return { recordsOf, archives, rootOf: newest } } /** * Everything in one folder, resolved through the manifests of the apps that own it. * * The cross-app read the whole model rests on: given a folder id, list its * files whatever kind they are and whoever wrote them, so two apps drawing the * same folder show the same things. Both apps consume this rather than either * one owning it. * * ## A file the reader cannot see is absent, not "unavailable" * * The one rule here that is a product decision rather than a mechanism, and a * deliberate divergence from upstream's NIP-MP, whose fold requires the * opposite for public repositories. **The count is the disclosure**: a folder * that renders three rows and two greyed-out placeholders has told an outsider * how much they are missing, which for a folder named after a person is the * sensitive part. So an address that resolves to nothing is dropped, and * nothing in the return value counts what was dropped. * * This is why the batch below cannot use {@link resolveForeignObject} * unchanged: that returns `unreachable: true` so an inline reference can say * "you may not have access", which is right for one pasted link and wrong for * a list. * * ## Round trips do not grow with the folder * * **Flat in the number of files, linear in the number of apps.** The folder * itself, the handler sweep, the contents, one query for every root at once * and every change with it, and one for the people — then two more per * distinct `(kind, author)` for NIP-89 discovery, which {@link ProjectionCache} * memoises away. * * Measured against production, reading three folders through one cache: * * | folder | files | cold | warm | * | --- | --- | --- | --- | * | Shared foundation packages | 24 | 11 | 5 | * | Feedback on Peek | 25 | 9 | 5 | * | Folders | 9 | 6 | 4 | * * Twenty-five files and nine cost the same warm, which is the property worth * having. A resolve per file would have been four *each* against a relay that * meters reads at 300 a minute — a folder of twenty-five would not have loaded. */ export async function resolveFolderContents( folder: string, query: QueryFn, /** Defaults to asking the relay. The browser passes a cached lookup. */ lookupPeople?: PeopleFn, /** See {@link ProjectionCache}. Omitting it is exactly the old behaviour. */ cache?: ProjectionCache, options: { /** * Return archived files — and files hidden with an archived parent — each * carrying its {@link Archive}, instead of leaving them out. For a view * that lists what was archived, and for the listings a move plans from * (`planMoveFromFolders`): what is archived beneath a file moves with it. * Every other list wants the default. */ includeArchived?: boolean /** * Only what the relay-signed `30890` lists: a Folder with none lists no * files, rather than the files that name it themselves. For a Folder's * card ({@link ArchiveReadOptions.folderFiles}). */ listedOnly?: boolean } = {}, ): Promise { // 1. The folder itself. Both kinds in one trip: the state is the model and // the channel is what names a folder that has none yet. const identity = await query([ { kinds: [KIND_FOLDER_STATE], '#d': [folder], limit: 1 }, { kinds: [KIND_CHANNEL], '#d': [folder], limit: 1 }, { kinds: [KIND_CHANNEL_ADMINS], '#d': [folder], limit: 1 }, ]) const state = identity.find((e) => e.kind === KIND_FOLDER_STATE && tagValue(e, 'd') === folder) const channel = identity.find((e) => e.kind === KIND_CHANNEL && tagValue(e, 'd') === folder) const admins = folderAdminsOf( identity.find((e) => e.kind === KIND_CHANNEL_ADMINS && tagValue(e, 'd') === folder), channel, ) const channelAddress = channel ? pointerToAddress({ kind: KIND_CHANNEL, pubkey: channel.pubkey, identifier: folder, relays: [] }) : undefined const summary: FolderSummary & { address?: string } = { id: folder, name: nameOf(channel), hasState: !!state, ...(channel?.tags.some((tag) => tag[0] === 'private') ? { private: true as const } : {}), ...(admins.length ? { admins } : {}), ...(channelAddress ? { channel: channelAddress } : {}), ...(state ? { address: pointerToAddress({ kind: state.kind, pubkey: state.pubkey, identifier: folder, relays: [] }) } : {}), } const { addresses, placed } = state ? // §5.2: one tag type lists every file, topics included, with no special // case. The order is the folder's, so it is preserved rather than sorted. { addresses: [...new Set(state.tags.filter((t) => t[0] === 'a' && t[1]).map((t) => t[1]))], placed: new Map(), } : options.listedOnly ? { addresses: [], placed: new Map() } : await addressesByContainment(folder, query) if (addresses.length === 0) { // Nothing listed, so no change query to ride on: the Folder's own archive // is one small read of its own. const archived = channelAddress ? folderArchiveOf(await readArchiveChanges([channelAddress], query), summary) : undefined return { ...summary, ...(archived ? { archived } : {}), files: [], source: state ? 'state' : 'channel' } } // 2. Group by (kind, author): one manifest answers for every file an app owns // in this folder, and the cache makes the second folder free. const pointers = addresses.flatMap((address) => { try { return [{ address, pointer: referenceToPointer(address) }] } catch { // An `a` tag nothing can parse is somebody else's bug and not worth a // whole folder. Dropped like any other unresolvable file. return [] } }) const groups = new Map() for (const { address, pointer } of pointers) { const key = `${pointer.kind}:${pointer.pubkey}` const group = groups.get(key) if (group) group.addresses.push(address) else groups.set(key, { pointer, addresses: [address] }) } // Keyed by app rather than by group: the bare file's manifest does not // depend on who wrote the file, so a folder of topics from five people // resolves it once, cache or no cache. const byApp = new Map() for (const { pointer } of groups.values()) byApp.set(manifestKeyOf(pointer), pointer) const manifests = new Map() await Promise.all( [...byApp].map(async ([key, pointer]) => { const resolved = await resolveManifest(pointer, query, cache) if (resolved) manifests.set(key, resolved) }), ) // 3. Every root, and every change against every address, in two filters. // Change kinds are a set because two apps may fold differently, and a kind // is a u16 — the relay refuses an out-of-range one outright, so an app // declaring no `records` contributes no filter rather than an empty one. // // SPEC's change kind is always asked for, whatever the apps declare: any // file may be archived (FOL-46), a nested Folder included, and so may this // Folder, whose own address rides in the same filter. const changeKinds = [ ...new Set([ ARCHIVE_RULE.changeKind, ...[...manifests.values()].flatMap((r) => (r.manifest.records ? [r.manifest.records.changeKind] : [])), ]), ] const nestedFolderIds = pointers.filter(({ pointer }) => pointer.kind === KIND_CHANNEL).map(({ pointer }) => pointer.identifier) const events = await query([ ...[...groups.values()].map(({ pointer, addresses: group }) => ({ kinds: [pointer.kind], authors: [pointer.pubkey], '#d': group.map((a) => referenceToPointer(a).identifier), limit: group.length, })), { kinds: changeKinds, '#a': addresses, limit: 500 }, // The archives again, in groups small enough that a busy Folder's other // changes cannot push them off the page — the filter above holds 500 // changes for every file together, and one Folder has already had 688. // Same request. ...archiveFilters(channelAddress ? [...addresses, channelAddress] : addresses), // A Folder listed here counts its archive only from its own admins (§3.2). Same request. ...(nestedFolderIds.length ? [{ kinds: [KIND_CHANNEL_ADMINS], '#d': nestedFolderIds, limit: nestedFolderIds.length }] : []), ]) const ownArchive = folderArchiveOf(events, summary) if (ownArchive) summary.archived = ownArchive // 4. Build each file, in the order the folder listed them. Archived files are // held back until the cascade below has read their parents. const listed: { file: ForeignObject; archive?: Archive }[] = [] const archiveByAddress = new Map() const parentByAddress = new Map() // Whose app reads a parent outside this listing: its child's, when that app draws it. const manifestByAddress = new Map() for (const { address, pointer } of pointers) { const resolved = manifests.get(manifestKeyOf(pointer)) // No app claims this kind, so there is no projection to draw it with. if (!resolved) continue const projection = resolved.manifest.projections?.[String(pointer.kind)] if (!projection) continue const root = events.find( (e) => e.kind === pointer.kind && e.pubkey === pointer.pubkey && tagValue(e, 'd') === pointer.identifier, ) // The disclosure rule. Absent, not "unavailable". if (!root) continue const records = foldRuleOf(resolved.manifest) const folded = foldChanges( events.filter((e) => e.kind === records.changeKind && hasTagValue(e, records.targetTag, address)), records, root, ) const nestedAdmins = pointer.kind === KIND_CHANNEL ? new Set( folderAdminsOf( events.find((e) => e.kind === KIND_CHANNEL_ADMINS && tagValue(e, 'd') === pointer.identifier), root, ), ) : undefined const archive = archiveOf(events, address, archiveRuleOf(resolved.manifest), root, nestedAdmins) archiveByAddress.set(address, archive) const parent = parentRefOf(resolved.manifest, pointer.kind, root, folded) // Plain, as the listing's own addresses are, so an `naddr` parent listed here is found here. parentByAddress.set(address, parent && (plainAddress(parent) ?? parent)) manifestByAddress.set(address, resolved.manifest) // An app hiding a record from its own lists is saying it is not part of the // folder any more. `hiddenWhen` is the app's own declaration of that — the // archive above is the same statement for a kind whose app declared none. // Not reported as an archive: the app said "hidden", which may mean more. const hidden = !!records.hiddenWhen && folded[records.hiddenWhen.field]?.value === records.hiddenWhen.equals if (hidden && !archive) continue /* A placed file stays listed only while the placement is current. The statement that placed it says "field f of this object is this folder". The object's own fold — every change against its address, wherever each was published — knows what f is *now*. If the two agree, the file is here; if the fold has moved on, somebody has since placed it somewhere else and this folder is holding a statement that has been superseded, which an append-only stream cannot retract. Ship linking a project to a second Folder is the case: the first Folder keeps the old statement for ever, and without this check would list the project for ever. Compared through the app's own field and value tags, so nothing here knows the field is called `folder`. */ const placements = placed.get(address) if (placements) { const current = placements.some((statement) => { const field = tagValue(statement, records.fieldTag) return field !== undefined && folded[field]?.value === folder }) if (!current) continue } listed.push({ archive, file: buildObject({ root, pointer, manifest: resolved.manifest, projection, folded, viaRecommendation: resolved.viaRecommendation, webTemplate: resolved.webTemplate, }), }) } /* 5. The cascade (FOL-46): a file under an archived file is hidden with it — an issue with its project, a sub-topic with its topic. Computed here and never written to the children, so unarchiving the parent restores exactly what was there. The chain is walked through everything this Folder lists. Where it leaves the Folder, the rest is walked as a link reads it (SPEC §6.5, PEEKM-2): each ancestor with its root, so its archive is folded by §6.2's `h` rule, at any depth. One read per level for every such exit, and none when the chain stays here or meets an archived file first. Only by parent. A file this Folder lists is never hidden because some *other* Folder that lists it is archived — that is what lets a file placed in two Folders stay visible in the one that is not archived. */ // One walk per exit and the app reading it, as walkArchivedAbove keys its // reads: two children of one outside parent, drawn by different apps, each // read it by their own app's rule. const exits = new Map; archive?: Archive }>() const exitOf = new Map() const drawnBy: Manifest[] = [] for (const { file, archive } of listed) { if (archive || !file.address) continue const seen = new Set([file.address]) let child = file.address for (let at = parentByAddress.get(child); at && !seen.has(at); child = at, at = parentByAddress.get(at)) { if (!archiveByAddress.has(at)) { const plain = plainAddress(at) if (!plain) break const manifest = manifestByAddress.get(child) const own = manifest?.projections?.[String(addressPointerOf(plain)!.kind)] ? manifest : undefined if (own && !drawnBy.includes(own)) drawnBy.push(own) const key = `${plain}|${own ? drawnBy.indexOf(own) : ''}` if (!exits.has(key)) exits.set(key, { at: plain, manifest, seen }) exitOf.set(file.address, key) break } if (archiveByAddress.get(at)) break seen.add(at) } } await walkArchivedAbove( [...exits.values()].map((exit) => ({ at: exit.at, manifest: exit.manifest, seen: exit.seen, mark: (found: Archive) => { exit.archive = found }, })), query, cache, ) const files: ForeignObject[] = [] for (const { file, archive } of listed) { const exit = file.address ? exitOf.get(file.address) : undefined const effective = archive ?? (file.address ? archivedAncestorOf(file.address, (a) => parentByAddress.get(a), (a) => archiveByAddress.get(a)) : undefined) ?? (exit ? exits.get(exit)?.archive : undefined) if (!effective) files.push(file) else if (options.includeArchived) files.push({ ...file, archived: effective }) } const people = await (lookupPeople ?? peopleViaRelay(query))([ ...new Set(files.flatMap(pubkeysIn)), ]) return { ...summary, files: files.map((file) => ({ ...file, people })), source: state ? 'state' : 'channel', } } /** * The contents of a folder that has no state event — containment by `h`. * * **The approximation, and it is worth being precise about what it cannot do.** * Before folder state exists, the only thing on the wire saying a file is in a * folder is the file's own `h` (or `buzz-channel`, for a record published * globally — {@link folderOf} has both spellings and why). That lists an app's * records perfectly well and **cannot list a topic**: under `h` the topic is * the container, so it would have to be inside itself. * * Kept because both wire shapes coexist permanently — no migration is * available, and a reader that only understood folder state would show every * folder on production as empty. * * Two filters rather than one: an `#h` query does not return a record placed * globally, and reading only `h` was what made five of Ship's fifteen projects * unactionable before `folderOf` existed. * * ## And the files placed here — the union rule (FOL-20) * * A folder lists the files whose `h` it is **and** the files placed in it. * An event cannot change its own `h`, so a project filed in one Folder and * later linked to a Folder keeps the `h` it was born with, and the only thing * that can say "it belongs over there now" is a change event. Ship publishes * that change twice — into the record's own Folder, and *into the Folder * being linked*: a `kind:1851` carried by the Folder's `h`, targeting the * project, whose value is the Folder itself. The second copy is the * placement, and it is what this reads. * * {@link resolveFolderProject} has read the same statement since PEEK-24 to * answer "which project is this topic paired with". This is the listing's * half of it, and it is the rule the tidy-up rests on: five Folders, * fourteen projects linked into them and none re-created, so a Folder * whose `h` is on nothing lists its projects anyway. * * App-neutral by the same discipline as everything else here. The change * kinds and target tags come off the published `records` rules, and a * placement is recognised by shape rather than by vocabulary: a change * carried in this folder, naming a target, **whose value is this folder's * id**. That last clause is what separates a placement from an ordinary edit * somebody published into the wrong Folder — the relay accepts those — which * would otherwise list a file wherever a stray change about it had landed. * Whether the placement is still *current* is decided in * {@link resolveFolderContents}, once the target's fold is known. * * One request either way: the change filter rides in the same `/query` as * the two containment filters, and the change kinds come off the handler * sweep this already made. */ async function addressesByContainment( folder: string, query: QueryFn, ): Promise<{ addresses: string[]; placed: Map }> { // Which kinds could be files? Every kind any app declares a projection for. // The only non-app-specific source for a kind number is a published manifest. const handlers = await query([{ kinds: [KIND_HANDLER_INFORMATION], limit: HANDLER_SWEEP_LIMIT }]) const manifests = handlers.flatMap((event) => parseManifest(event) ?? []) // The bare file is listed by no manifest on the relay — its projection is // built in — so it has to be asked for by name, or a folder with no state // would show every project and issue and none of the topics. const kinds = [ ...new Set([ KIND_BARE_FILE, ...manifests.flatMap((manifest) => Object.keys(manifest.projections ?? {}).map(Number).filter(Number.isFinite), ), ]), ] // Every app's change kind, each with the tags that make one a placement. A // kind is a u16 and the relay refuses an out-of-range one, so an app with no // `records` contributes nothing rather than an empty rule. const rules = new Map() for (const manifest of manifests) { if (manifest.records) rules.set(manifest.records.changeKind, manifest.records) } const found = await query([ { '#h': [folder], kinds, limit: 500 }, { '#buzz-channel': [folder], kinds, limit: 500 }, ...(rules.size ? [{ '#h': [folder], kinds: [...rules.keys()], limit: 500 }] : []), ]) const held = found.filter((event) => kinds.includes(event.kind)) const addressOf = (event: SignedEvent) => pointerToAddress({ kind: event.kind, pubkey: event.pubkey, identifier: tagValue(event, 'd') ?? '', relays: [], }) // Newest first, whichever event put the file here. A placement's time is // the statement's, so a project linked in yesterday sorts above one whose // record has sat here for a month — the order a person expects of "recently // added". const newest = new Map() const seen = (address: string, at: number) => newest.set(address, Math.max(newest.get(address) ?? 0, at)) for (const event of held) { const d = tagValue(event, 'd') /* Two exclusions, and both are about what a file *is*. **A file is addressable** — RFC 0.4 §5, "anything with an address that a folder can list". A `kind:9` message carries no `d`, so it has no address, so it is conversation rather than contents. That is the whole discriminator and it needs no kind numbers. **A folder is not a file inside itself.** A channel's own `39000` comes back from an `#h` query for that channel — the relay scopes a discovery event to the channel it describes, so it arrives with the contents. What marks it out is that its `d` *is* the folder uuid. */ if (d === undefined || d === folder) continue seen(addressOf(event), event.created_at) } const contained = new Set(newest.keys()) const placed = new Map() for (const event of found) { const rule = rules.get(event.kind) if (!rule) continue const target = tagValue(event, rule.targetTag) // A file already here by `h` needs no placement, and reading one for it // would subject it to the currency check that placements alone deserve. if (!target || contained.has(target)) continue if (tagValue(event, rule.valueTag) !== folder) continue placed.set(target, [...(placed.get(target) ?? []), event]) seen(target, event.created_at) } return { addresses: [...newest].sort((a, b) => b[1] - a[1]).map(([address]) => address), placed, } }